Skip to content
UI Kit
Accent color

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

4 variants

Variants

Library

Cards by category with search, a favorites filter and the number of fields and uses. Filtering lets the cards rise again in order.

Open in Studio

Fill a template

Fields on the left, the prompt on the right with each value lighting up as it is filled. Copy and use wait until every field is set.

Open in Studio

Shared with the team

Shared prompts with version, owner and who can see them. A private prompt has no link to copy.

Open in Studio

Prompt editor

Name, category and prompt. Every {{field}} typed shows up as a chip, the preview marks it and can fill in sample values. Saving counts the version up.

Open in Studio

Details

What Prompt library brings along, read from the registry and the source.

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

Props

The PromptLibraryProps interface from components/ui/prompt-library.tsx, with the comments from the code.

  • variantoptional
    PromptLibraryVariant

    libraryfill-templateteam-sharededitor

  • titleoptional
    string
  • descriptionoptional
    string
  • templatesoptional
    PromptTemplate[]
  • templateoptional
    PromptTemplate

    fill-template and editor: the template to work on

  • categoriesoptional
    string[]

    editor: categories to choose from

  • onUseoptional
    (prompt: string) => void
  • newHrefoptional
    string

    library: where "New prompt" leads, e.g. the editor page

  • onNewoptional
    () => void

    library: 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
searchEnglish Search promptsGerman Prompts durchsuchen
allEnglish AllGerman Alle
favoritesEnglish FavoritesGerman Favoriten
favoriteEnglish Add {name} to favoritesGerman {name} zu Favoriten
unfavoriteEnglish Remove {name} from favoritesGerman {name} aus Favoriten entfernen
usesEnglish {n} usesGerman {n} Mal genutzt
fieldsEnglish {n} fieldsGerman {n} Felder
fieldOneEnglish 1 fieldGerman 1 Feld
useEnglish UseGerman Nutzen
inChatEnglish In the chatGerman Im Chat
noneEnglish No prompt matches. Try another word or category.German Kein Prompt passt. Versuch ein anderes Wort oder eine andere Kategorie.
newPromptEnglish New promptGerman Neuer Prompt
fillEnglish Fill inGerman Ausfüllen
previewEnglish PromptGerman Prompt
missingEnglish {n} fields leftGerman Noch {n} Felder offen
missingOneEnglish 1 field leftGerman Noch 1 Feld offen
readyEnglish Ready to useGerman Bereit
copyEnglish Copy promptGerman Prompt kopieren
copiedEnglish CopiedGerman Kopiert
sendEnglish Use in chatGerman Im Chat nutzen
sentEnglish Sent to the chat.German An den Chat gesendet.
sharedEnglish Shared promptsGerman Geteilte Prompts
visibilityEnglish Who can see itGerman Wer es sieht
privateEnglish Only meGerman Nur ich
teamEnglish TeamGerman Team
publicEnglish Anyone with the linkGerman Alle mit dem Link
authorEnglish OwnerGerman Erstellt von
updatedEnglish Updated {when}German Geändert {when}
versionEnglish v{n}German v{n}
shareEnglish ShareGerman Teilen
copyLinkEnglish Copy linkGerman Link kopieren
linkCopiedEnglish Link copiedGerman Link kopiert
nameEnglish NameGerman Name
categoryEnglish CategoryGerman Kategorie
descriptionFieldEnglish Short descriptionGerman Kurze Beschreibung
bodyEnglish PromptGerman Prompt
bodyHintEnglish 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.
detectedEnglish FieldsGerman Felder
noFieldsEnglish No fields yet. Type {{ to add one.German Noch keine Felder. Tippe {{, um eines anzulegen.
saveEnglish Save promptGerman Prompt speichern
savedEnglish Saved as version {n}.German Als Fassung {n} gespeichert.
testEnglish Test with sample valuesGerman Mit Beispielwerten testen
charsEnglish {n} charactersGerman {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
components/ui/prompt-library.tsx
'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.

Often searched

Jump to

Copied to the clipboard