Prompt library
A prompt library in four views. Templates by category with search and favorites, filling a template’s fields with a live preview, shared prompts with owner, version and visibility, and an editor that finds the fields as you type them.
One of 3 components in AI writing and creation
Variants
Details
What Prompt library brings along, read from the registry and the source.
- Category
- AI writing and creation
- Area
- App
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/prompt-library.tsx
- Brings along
- utilskiticonrevealmotion
- npm packages
- None
- Languages
- English and German, 43 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PromptLibraryProps interface from components/ui/prompt-library.tsx, with the comments from the code.
variantoptionalPromptLibraryVariantlibraryfill-templateteam-sharededitor
titleoptionalstringdescriptionoptionalstringtemplatesoptionalPromptTemplate[]templateoptionalPromptTemplatefill-template and editor: the template to work on
categoriesoptionalstring[]editor: categories to choose from
onUseoptional(prompt: string) => voidnewHrefoptionalstringlibrary: where "New prompt" leads, e.g. the editor page
onNewoptional() => voidlibrary: called by "New prompt" when there is no link
Built-in texts
Prompt library writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 43 texts
| Key | English | German |
|---|---|---|
| search | English Search prompts | German Prompts durchsuchen |
| all | English All | German Alle |
| favorites | English Favorites | German Favoriten |
| favorite | English Add {name} to favorites | German {name} zu Favoriten |
| unfavorite | English Remove {name} from favorites | German {name} aus Favoriten entfernen |
| uses | English {n} uses | German {n} Mal genutzt |
| fields | English {n} fields | German {n} Felder |
| fieldOne | English 1 field | German 1 Feld |
| use | English Use | German Nutzen |
| inChat | English In the chat | German Im Chat |
| none | English No prompt matches. Try another word or category. | German Kein Prompt passt. Versuch ein anderes Wort oder eine andere Kategorie. |
| newPrompt | English New prompt | German Neuer Prompt |
| fill | English Fill in | German Ausfüllen |
| preview | English Prompt | German Prompt |
| missing | English {n} fields left | German Noch {n} Felder offen |
| missingOne | English 1 field left | German Noch 1 Feld offen |
| ready | English Ready to use | German Bereit |
| copy | English Copy prompt | German Prompt kopieren |
| copied | English Copied | German Kopiert |
| send | English Use in chat | German Im Chat nutzen |
| sent | English Sent to the chat. | German An den Chat gesendet. |
| shared | English Shared prompts | German Geteilte Prompts |
| visibility | English Who can see it | German Wer es sieht |
| private | English Only me | German Nur ich |
| team | English Team | German Team |
| public | English Anyone with the link | German Alle mit dem Link |
| author | English Owner | German Erstellt von |
| updated | English Updated {when} | German Geändert {when} |
| version | English v{n} | German v{n} |
| share | English Share | German Teilen |
| copyLink | English Copy link | German Link kopieren |
| linkCopied | English Link copied | German Link kopiert |
| name | English Name | German Name |
| category | English Category | German Kategorie |
| descriptionField | English Short description | German Kurze Beschreibung |
| body | English Prompt | German Prompt |
| bodyHint | English Write {{name}} where something changes each time. Fields appear on their own. | German Schreib {{name}}, wo sich jedes Mal etwas ändert. Felder erscheinen von selbst. |
| detected | English Fields | German Felder |
| noFields | English No fields yet. Type {{ to add one. | German Noch keine Felder. Tippe {{, um eines anzulegen. |
| save | English Save prompt | German Prompt speichern |
| saved | English Saved as version {n}. | German Als Fassung {n} gespeichert. |
| test | English Test with sample values | German Mit Beispielwerten testen |
| chars | English {n} characters | German {n} Zeichen |
Accessibility
Read from the source of the component.
- Prompt library handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles group, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 677 lines
'use client';
import { Fragment, useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger, useScrollFade } from '@/components/ui/motion';
export type PromptLibraryVariant = 'library' | 'fill-template' | 'team-shared' | 'editor';
export interface PromptVariable {
/** The name used in the template, {{name}} */
name: string;
label: string;
placeholder?: string;
/** Offer a choice instead of free text */
options?: string[];
value?: string;
}
export interface PromptTemplate {
id: string;
title: string;
description: string;
category: string;
icon?: IconName;
/** The prompt with {{variables}} */
body?: string;
variables?: PromptVariable[];
/** How often the team used it */
uses?: number;
author?: string;
favorite?: boolean;
visibility?: 'private' | 'team' | 'public';
/** e.g. "2 days ago" */
updated?: string;
version?: number;
}
export interface PromptLibraryProps {
variant?: PromptLibraryVariant;
title?: string;
description?: string;
templates?: PromptTemplate[];
/** fill-template and editor: the template to work on */
template?: PromptTemplate;
/** editor: categories to choose from */
categories?: string[];
onUse?: (prompt: string) => void;
/** library: where "New prompt" leads, e.g. the editor page */
newHref?: string;
/** library: called by "New prompt" when there is no link */
onNew?: () => void;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
search: 'Search prompts',
all: 'All',
favorites: 'Favorites',
favorite: 'Add {name} to favorites',
unfavorite: 'Remove {name} from favorites',
uses: '{n} uses',
fields: '{n} fields',
fieldOne: '1 field',
use: 'Use',
inChat: 'In the chat',
none: 'No prompt matches. Try another word or category.',
newPrompt: 'New prompt',
fill: 'Fill in',
preview: 'Prompt',
missing: '{n} fields left',
missingOne: '1 field left',
ready: 'Ready to use',
copy: 'Copy prompt',
copied: 'Copied',
send: 'Use in chat',
sent: 'Sent to the chat.',
shared: 'Shared prompts',
visibility: 'Who can see it',
private: 'Only me',
team: 'Team',
public: 'Anyone with the link',
author: 'Owner',
updated: 'Updated {when}',
version: 'v{n}',
share: 'Share',
copyLink: 'Copy link',
linkCopied: 'Link copied',
name: 'Name',
category: 'Category',
descriptionField: 'Short description',
body: 'Prompt',
bodyHint: 'Write {{name}} where something changes each time. Fields appear on their own.',
detected: 'Fields',
noFields: 'No fields yet. Type {{ to add one.',
save: 'Save prompt',
saved: 'Saved as version {n}.',
test: 'Test with sample values',
chars: '{n} characters',
},
de: {
search: 'Prompts durchsuchen',
all: 'Alle',
favorites: 'Favoriten',
favorite: '{name} zu Favoriten',
unfavorite: '{name} aus Favoriten entfernen',
uses: '{n} Mal genutzt',
fields: '{n} Felder',
fieldOne: '1 Feld',
use: 'Nutzen',
inChat: 'Im Chat',
none: 'Kein Prompt passt. Versuch ein anderes Wort oder eine andere Kategorie.',
newPrompt: 'Neuer Prompt',
fill: 'Ausfüllen',
preview: 'Prompt',
missing: 'Noch {n} Felder offen',
missingOne: 'Noch 1 Feld offen',
ready: 'Bereit',
copy: 'Prompt kopieren',
copied: 'Kopiert',
send: 'Im Chat nutzen',
sent: 'An den Chat gesendet.',
shared: 'Geteilte Prompts',
visibility: 'Wer es sieht',
private: 'Nur ich',
team: 'Team',
public: 'Alle mit dem Link',
author: 'Erstellt von',
updated: 'Geändert {when}',
version: 'v{n}',
share: 'Teilen',
copyLink: 'Link kopieren',
linkCopied: 'Link kopiert',
name: 'Name',
category: 'Kategorie',
descriptionField: 'Kurze Beschreibung',
body: 'Prompt',
bodyHint: 'Schreib {{name}}, wo sich jedes Mal etwas ändert. Felder erscheinen von selbst.',
detected: 'Felder',
noFields: 'Noch keine Felder. Tippe {{, um eines anzulegen.',
save: 'Prompt speichern',
saved: 'Als Fassung {n} gespeichert.',
test: 'Mit Beispielwerten testen',
chars: '{n} Zeichen',
},
};
type Texts = (typeof TEXTS)['en'];
type P = PromptLibraryProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value);
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes plb-up{from{opacity:0;transform:translateY(8px)}}@keyframes plb-pop{0%{transform:scale(1)}45%{transform:scale(1.3)}100%{transform:scale(1)}}@keyframes plb-flash{from{background-color:color-mix(in oklab,var(--primary) 30%,transparent)}}' +
'@media (prefers-reduced-motion:no-preference){.plb-up{animation:plb-up .45s var(--ease-kit) var(--plb-d,0ms) both}.plb-pop{animation:plb-pop .45s var(--ease-kit)}.plb-flash{animation:plb-flash 1.2s ease-out both}}';
function Style() {
return (
<style href="kit-prompt-library" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const VAR = /\{\{\s*([\w-]+)\s*\}\}/g;
const varsOf = (body: string) => Array.from(new Set(Array.from(body.matchAll(VAR), (m) => m[1])));
function Stage({ title, description, children, className, action }: { title?: string; description?: string; children: React.ReactNode; className?: string; action?: React.ReactNode }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<div className="kit-container">
<div className="mx-auto w-full max-w-[68rem]">
{(title || description || action) && (
<Reveal className="mb-6 flex flex-col gap-3 sm:mb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
{title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
{description && <p className="mt-2 max-w-[40rem] text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
{action}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const primaryBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-[13.5px] font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-colors hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] disabled:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--card))] disabled:text-subtle-foreground disabled:shadow-none sm:h-9';
const quietBtn = 'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-4 text-[13.5px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9';
const field = 'w-full rounded-lg border border-border bg-background px-3 text-[14px] text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus:shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)] focus-visible:outline-none';
function Tile({ icon, className }: { icon?: IconName; className?: string }) {
return (
<span aria-hidden className={cn('grid size-10 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]', className)}>
<Icon name={icon ?? 'sparkle'} size={19} />
</span>
);
}
function Initials({ name }: { name: string }) {
const hue = (name.charCodeAt(0) * 37 + name.length * 11) % 5;
const tone = ['--primary', '--success', '--warning', '--info', '--destructive'][hue];
return (
<span aria-hidden className="grid size-7 shrink-0 place-items-center rounded-full text-[11px] font-semibold" style={{ background: `color-mix(in oklab, var(${tone}) 18%, var(--card))`, color: `color-mix(in oklab, var(${tone}) 70%, var(--foreground))` }}>
{name
.split(' ')
.map((p) => p[0])
.slice(0, 2)
.join('')}
</span>
);
}
/** The prompt body with each {{field}} as a chip, filled ones show their value */
function Body({ body, values, className }: { body: string; values?: Record<string, string>; className?: string }) {
const parts = body.split(/(\{\{\s*[\w-]+\s*\}\})/g);
return (
<p className={cn('whitespace-pre-wrap text-pretty text-[14.5px] leading-[1.8] text-foreground', className)}>
{parts.map((p, i) => {
const m = /^\{\{\s*([\w-]+)\s*\}\}$/.exec(p);
if (!m) return <Fragment key={i}>{p}</Fragment>;
const v = values?.[m[1]];
return v ? (
<mark key={`${i}-${v}`} className="plb-flash rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-0.5 text-foreground [box-decoration-break:clone]">
{v}
</mark>
) : (
<span key={i} className="rounded-[4px] border border-dashed border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] px-0.5 text-[13px] text-[color-mix(in_oklab,var(--primary)_70%,var(--foreground))]">
{m[1]}
</span>
);
})}
</p>
);
}
/* ------------------------------------------------------------------ */
/* library */
/* ------------------------------------------------------------------ */
function Library({ t, title, description, templates = [], onUse, newHref, onNew, className }: P) {
const locale = useLocale();
const [list, setList] = useSynced(templates);
const [cat, setCat] = useState('all');
const [q, setQ] = useState('');
const [pop, setPop] = useState<string | null>(null);
const [used, setUsed] = useState<string | null>(null);
const [row, fade] = useScrollFade<HTMLDivElement>();
const cats = Array.from(new Set(list.map((x) => x.category)));
const shown = list.filter((x) => (cat === 'all' || (cat === 'fav' ? x.favorite : x.category === cat)) && (!q || `${x.title} ${x.description}`.toLowerCase().includes(q.toLowerCase())));
const nf = (n: number) => n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US');
const chip = (id: string, label: string, icon?: IconName) => (
<button key={id} type="button" aria-pressed={cat === id} onClick={() => setCat(id)} className={cn('inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-[13px] font-medium transition-colors focus-visible:-outline-offset-2 sm:h-9', cat === id ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
{icon && <Icon name={icon} size={14} />}
{label}
</button>
);
return (
<Stage
title={title}
description={description}
className={className}
action={
newHref ? (
<a href={newHref} className={cn(primaryBtn, 'self-start sm:self-auto')}>
<Icon name="plus" size={15} />
{t.newPrompt}
</a>
) : onNew ? (
<button type="button" onClick={onNew} className={cn(primaryBtn, 'self-start sm:self-auto')}>
<Icon name="plus" size={15} />
{t.newPrompt}
</button>
) : undefined
}
>
<Reveal delay={60}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<label className="flex h-11 items-center gap-2 rounded-xl border border-border bg-card px-3 shadow-[var(--shadow-sm)] focus-within:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] focus-within:shadow-[var(--shadow-sm),0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] lg:w-72">
<Icon name="search" size={16} className="shrink-0 text-muted-foreground" />
<span className="sr-only">{t.search}</span>
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.search} className="w-0 min-w-0 flex-1 bg-transparent text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus-visible:outline-none" />
</label>
<div ref={row} role="group" aria-label={t.all} className={cn('kit-no-scrollbar -mx-6 flex gap-1 overflow-x-auto px-6 lg:mx-0 lg:px-0', fade)}>
{chip('all', t.all)}
{chip('fav', t.favorites, 'star')}
{cats.map((c) => chip(c, c))}
</div>
</div>
</Reveal>
{shown.length === 0 ? (
<p className="plb-up mt-4 rounded-2xl border border-dashed border-border-strong px-6 py-12 text-center text-[13.5px] text-muted-foreground">{t.none}</p>
) : (
<Stagger key={`${cat}-${q}`} as="ul" step={60} className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{shown.map((x) => {
const n = x.variables?.length ?? varsOf(x.body ?? '').length;
return (
<li key={x.id} className={cn(card, 'group relative flex flex-col p-4 transition-[border-color,transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0')}>
<div className="flex items-start justify-between gap-3">
<Tile icon={x.icon} />
<button
type="button"
aria-pressed={!!x.favorite}
aria-label={fill(x.favorite ? t.unfavorite : t.favorite, { name: x.title })}
onClick={() => (setList((s) => s.map((y) => (y.id === x.id ? { ...y, favorite: !y.favorite } : y))), setPop(x.id))}
className="-m-1.5 grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent sm:size-9"
>
<Icon key={pop === x.id ? `${x.id}-${x.favorite}` : x.id} name="star" size={17} className={cn(x.favorite && 'fill-[var(--warning)] text-warning-foreground', pop === x.id && 'plb-pop')} />
</button>
</div>
<p className="mt-3 text-[11.5px] font-medium text-subtle-foreground">{x.category}</p>
<h3 className="mt-0.5 text-[15.5px] font-semibold leading-snug text-foreground">{x.title}</h3>
<p className="mt-1 line-clamp-2 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{x.description}</p>
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
<span className="flex items-center gap-3 text-[12px] tabular-nums text-muted-foreground">
{n > 0 && (
<span className="inline-flex items-center gap-1">
<Icon name="edit" size={12} />
{n === 1 ? t.fieldOne : fill(t.fields, { n })}
</span>
)}
{x.uses !== undefined && <span>{fill(t.uses, { n: nf(x.uses) })}</span>}
</span>
<button type="button" onClick={() => (onUse?.(x.body ?? x.title), setUsed(x.id))} aria-live="polite" className={cn('inline-flex h-11 items-center gap-1 rounded-lg px-2.5 text-[13px] font-medium transition-colors hover:bg-accent sm:h-8', used === x.id ? 'text-success-foreground' : 'text-foreground')}>
{used === x.id ? t.inChat : t.use}
<Icon name={used === x.id ? 'check' : 'arrow-right'} size={14} className={used === x.id ? 'plb-up' : 'transition-transform duration-300 group-hover:translate-x-0.5'} />
</button>
</div>
</li>
);
})}
</Stagger>
)}
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* fill-template */
/* ------------------------------------------------------------------ */
function FillTemplate({ t, title, description, template, onUse, className }: P) {
const vars = template?.variables ?? [];
const [values, setValues] = useState<Record<string, string>>(() => Object.fromEntries(vars.map((v) => [v.name, v.value ?? ''])));
const [done, setDone] = useState<'copy' | 'send' | null>(null);
const id = useId();
const open = vars.filter((v) => !values[v.name]?.trim()).length;
const prompt = (template?.body ?? '').replace(VAR, (_, k) => values[k] || `{{${k}}}`);
const set = (k: string, v: string) => (setValues((s) => ({ ...s, [k]: v })), setDone(null));
return (
<Stage title={title} description={description} className={className}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start lg:gap-6">
<Reveal delay={60}>
<form className={cn(card, 'p-4 sm:p-5')} onSubmit={(e) => e.preventDefault()}>
<div className="flex items-center gap-3">
<Tile icon={template?.icon} />
<div className="min-w-0">
<p className="truncate text-[15px] font-semibold text-foreground">{template?.title}</p>
<p className="text-[12.5px] text-muted-foreground">{template?.category}</p>
</div>
</div>
<p className="mb-3 mt-5 text-[12.5px] font-medium text-subtle-foreground">{t.fill}</p>
<div className="space-y-3.5">
{vars.map((v) => (
<div key={v.name}>
<label htmlFor={`${id}-${v.name}`} className="mb-1.5 flex items-center justify-between text-[13px] font-medium text-foreground">
{v.label}
{values[v.name]?.trim() && <Icon name="check" size={14} className="plb-up text-success-foreground" />}
</label>
{v.options ? (
<div id={`${id}-${v.name}`} role="radiogroup" aria-label={v.label} className="flex flex-wrap gap-1.5">
{v.options.map((o, oi, all) => (
<button
key={o}
type="button"
role="radio"
aria-checked={values[v.name] === o}
tabIndex={values[v.name] === o || (!all.includes(values[v.name] ?? '') && oi === 0) ? 0 : -1}
onClick={() => set(v.name, o)}
onKeyDown={(e) => {
const d = ['ArrowRight', 'ArrowDown'].includes(e.key) ? 1 : ['ArrowLeft', 'ArrowUp'].includes(e.key) ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (oi + d + all.length) % all.length;
set(v.name, all[n]);
(e.currentTarget.parentElement?.children[n] as HTMLElement | undefined)?.focus();
}}
className={cn('inline-flex min-h-11 items-center rounded-lg border px-3 text-[13px] font-medium transition-colors sm:min-h-9', values[v.name] === o ? 'border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_12%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:text-foreground')}>
{o}
</button>
))}
</div>
) : (
<input id={`${id}-${v.name}`} value={values[v.name] ?? ''} onChange={(e) => set(v.name, e.target.value)} placeholder={v.placeholder} className={cn(field, 'h-11 sm:h-10')} />
)}
</div>
))}
</div>
</form>
</Reveal>
<Reveal delay={160} className="lg:sticky lg:top-6">
<div className={card}>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
<p className="text-[13px] font-medium text-foreground">{t.preview}</p>
<span className={cn('inline-flex items-center gap-1.5 text-[12.5px] font-medium', open ? 'text-muted-foreground' : 'text-success-foreground')} aria-live="polite">
{open ? (open === 1 ? t.missingOne : fill(t.missing, { n: open })) : (
<>
<Icon name="check-circle" size={14} />
{t.ready}
</>
)}
</span>
</div>
<div className="p-4 sm:p-5">
<Body body={template?.body ?? ''} values={values} />
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border bg-surface/60 px-4 py-3 sm:px-5">
{done === 'send' && <span className="plb-up mr-auto text-[13px] text-success-foreground">{t.sent}</span>}
<button
type="button"
disabled={open > 0}
onClick={() => {
navigator.clipboard?.writeText(prompt).catch(() => {});
setDone('copy');
}}
className={cn(quietBtn, 'disabled:opacity-50')}
>
<Icon name={done === 'copy' ? 'check' : 'copy'} size={15} className={done === 'copy' ? 'plb-up text-success-foreground' : undefined} />
{done === 'copy' ? t.copied : t.copy}
</button>
<button type="button" disabled={open > 0} onClick={() => (onUse?.(prompt), setDone('send'))} className={primaryBtn}>
<Icon name="send" size={15} />
{t.send}
</button>
</div>
</div>
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* team-shared */
/* ------------------------------------------------------------------ */
const VIS: Record<NonNullable<PromptTemplate['visibility']>, IconName> = { private: 'lock', team: 'users', public: 'globe' };
function TeamShared({ t, title, description, templates = [], className }: P) {
const [list, setList] = useSynced(templates);
const [copied, setCopied] = useState<string | null>(null);
return (
<Stage title={title} description={description} className={className}>
<Reveal delay={60}>
<div className={card}>
<div className="hidden grid-cols-[minmax(0,1fr)_9rem_11rem_8.5rem] gap-4 border-b border-border px-5 py-2.5 text-[12px] font-medium text-subtle-foreground lg:grid">
<span>{t.shared}</span>
<span>{t.author}</span>
<span>{t.visibility}</span>
<span className="text-right">{t.share}</span>
</div>
<Stagger as="ul" step={60} className="divide-y divide-border">
{list.map((x) => (
<li key={x.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-3 px-4 py-4 sm:px-5 lg:grid-cols-[minmax(0,1fr)_9rem_11rem_8.5rem] lg:items-center lg:gap-4">
<div className="col-span-2 flex min-w-0 items-start gap-3 lg:col-span-1">
<Tile icon={x.icon} className="size-9" />
<div className="min-w-0">
<p className="flex items-center gap-2 text-[14.5px] font-medium text-foreground">
<span className="truncate">{x.title}</span>
{x.version && <span className="shrink-0 rounded-md bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-1.5 py-px text-[11px] tabular-nums text-muted-foreground">{fill(t.version, { n: x.version })}</span>}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">{x.updated && fill(t.updated, { when: x.updated })}</p>
</div>
</div>
<span className="col-span-2 flex min-w-0 items-center gap-2 text-[13px] text-muted-foreground lg:col-span-1">
{x.author && (
<>
<Initials name={x.author} />
<span className="truncate">{x.author}</span>
</>
)}
</span>
<label className="relative col-span-1 block lg:col-span-1">
<span className="sr-only">{`${t.visibility}, ${x.title}`}</span>
<Icon name={VIS[x.visibility ?? 'team']} size={15} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<select value={x.visibility ?? 'team'} onChange={(e) => setList((s) => s.map((y) => (y.id === x.id ? { ...y, visibility: e.target.value as PromptTemplate['visibility'] } : y)))} className="h-11 w-full appearance-none rounded-lg border border-border bg-background pl-9 pr-8 text-[13.5px] text-foreground hover:border-border-strong sm:h-9">
{(['private', 'team', 'public'] as const).map((v) => (
<option key={v} value={v}>
{t[v]}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
</label>
<div className="flex justify-end">
<button
type="button"
disabled={x.visibility === 'private'}
onClick={() => {
navigator.clipboard?.writeText(`https://prompts.example/p/${x.id}`).catch(() => {});
setCopied(x.id);
window.setTimeout(() => setCopied((c) => (c === x.id ? null : c)), 1800);
}}
className={cn(quietBtn, 'w-full px-3 disabled:opacity-40 lg:w-auto')}
>
<Icon name={copied === x.id ? 'check' : 'link'} size={14} className={copied === x.id ? 'plb-up text-success-foreground' : undefined} />
{copied === x.id ? t.linkCopied : t.copyLink}
</button>
</div>
</li>
))}
</Stagger>
</div>
</Reveal>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* editor */
/* ------------------------------------------------------------------ */
function Editor({ t, title, description, template, categories = [], className }: P) {
const [name, setName] = useState(template?.title ?? '');
const [desc, setDesc] = useState(template?.description ?? '');
const [cat, setCat] = useState(template?.category ?? categories[0] ?? '');
const [body, setBody] = useState(template?.body ?? '');
const [version, setVersion] = useState(template?.version ?? 1);
const [saved, setSaved] = useState(false);
const [test, setTest] = useState(false);
const id = useId();
const names = varsOf(body);
const samples = Object.fromEntries((template?.variables ?? []).map((v) => [v.name, v.value ?? v.placeholder ?? '']));
return (
<Stage title={title} description={description} className={className}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-start lg:gap-6">
<Reveal delay={60}>
<form
className={cn(card, 'space-y-4 p-4 sm:p-5')}
onSubmit={(e) => {
e.preventDefault();
setVersion((v) => v + 1);
setSaved(true);
}}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
<label className="block">
<span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.name}</span>
<input value={name} onChange={(e) => (setName(e.target.value), setSaved(false))} className={cn(field, 'h-11 sm:h-10')} />
</label>
<label className="relative block">
<span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.category}</span>
<select value={cat} onChange={(e) => (setCat(e.target.value), setSaved(false))} className={cn(field, 'h-11 appearance-none pr-8 sm:h-10')}>
{categories.map((c) => (
<option key={c}>{c}</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute bottom-3.5 right-2.5 text-muted-foreground sm:bottom-3" />
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[13px] font-medium text-foreground">{t.descriptionField}</span>
<input value={desc} onChange={(e) => (setDesc(e.target.value), setSaved(false))} className={cn(field, 'h-11 sm:h-10')} />
</label>
<div>
<label htmlFor={`${id}-body`} className="mb-1 block text-[13px] font-medium text-foreground">
{t.body}
</label>
<p className="mb-2 text-[12.5px] text-muted-foreground">{t.bodyHint}</p>
<textarea id={`${id}-body`} rows={7} value={body} onChange={(e) => (setBody(e.target.value), setSaved(false))} className={cn(field, 'resize-y py-3 leading-relaxed')} />
<p className="mt-1 text-right text-[12px] tabular-nums text-subtle-foreground">{fill(t.chars, { n: body.length })}</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
<span aria-live="polite">{saved && <span className="plb-up inline-flex items-center gap-1.5 text-[13px] text-success-foreground"><Icon name="check-circle" size={15} />{fill(t.saved, { n: version })}</span>}</span>
<button type="submit" disabled={!name.trim() || !body.trim()} className={primaryBtn}>
{t.save}
</button>
</div>
</form>
</Reveal>
<Reveal delay={160} className="space-y-4 lg:sticky lg:top-6">
<div className={cn(card, 'p-4 sm:p-5')}>
<p className="text-[13px] font-medium text-foreground">
{t.detected} <span className="tabular-nums text-muted-foreground">{names.length}</span>
</p>
{names.length ? (
<ul className="mt-3 flex flex-wrap gap-1.5">
{names.map((n) => (
<li key={n} className="plb-up inline-flex h-8 items-center gap-1.5 rounded-lg border border-[color-mix(in_oklab,var(--primary)_40%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] px-2.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
<Icon name="edit" size={12} />
{n}
</li>
))}
</ul>
) : (
<p className="mt-2 text-[13px] text-muted-foreground">{t.noFields}</p>
)}
</div>
<div className={card}>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5 sm:px-5">
<p className="text-[13px] font-medium text-foreground">{t.preview}</p>
<label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12.5px] text-muted-foreground sm:min-h-8">
<input type="checkbox" checked={test} onChange={(e) => setTest(e.target.checked)} className="size-4 accent-[var(--primary)]" />
{t.test}
</label>
</div>
<div className="p-4 sm:p-5">
<Body body={body} values={test ? samples : undefined} className="text-[14px]" />
</div>
</div>
</Reveal>
</div>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* A prompt library: templates by category with search and favorites,
* filling a template's fields with a live preview, shared prompts with
* owner, version and visibility, and an editor that finds the fields as
* you type them.
*/
export function PromptLibrary({ variant = 'library', labels, ...props }: PromptLibraryProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
const body = (() => {
switch (variant) {
case 'fill-template':
// A new template from the Studio starts the form over
return <FillTemplate key={JSON.stringify(props.template)} {...p} />;
case 'team-shared':
return <TeamShared {...p} />;
case 'editor':
return <Editor key={JSON.stringify(props.template)} {...p} />;
default:
return <Library {...p} />;
}
})();
return (
<>
<Style />
{body}
</>
);
}Frequent questions
How do I install Prompt library?
With aiw add @ai-website/prompt-library. The command puts components/ui/prompt-library.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.
Which variants does Prompt library have?
4 variants, Library, Fill a template, Shared with the team and Prompt editor. You pick one with the variant prop, for example variant="fill-template".
Can I translate the texts of Prompt library?
Yes. Prompt library carries 43 texts of its own in English and German, such as “Search prompts”, “All” and “Favorites”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Prompt library?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Prompt library with aiw_components and install it with aiw_add @ai-website/prompt-library. Name the variant in your prompt, for example variant="library", and the assistant writes your content straight into the props.
Can I adjust Prompt library without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching PromptLibrary code to copy.