Zum Inhalt springen
UI Kit
Accent

Command Palette für React

Alles suchen und ausführen. Strg K oder ⌘K öffnet, Pfeile bewegen, Enter führt aus, und Gruppen halten Ordnung.

Eine von 9 Components in Overlays

2 Variants

Alles per Tastatur suchen und starten

Eine Command Palette gibt Vielnutzern einen Ort zum Suchen und Handeln, ohne Menüs zu durchforsten. Diese öffnet sich mit Strg K unter Windows und Linux und mit Cmd K am Mac, filtert beim Tippen, bewegt sich mit den Pfeiltasten und startet den markierten Eintrag mit Enter.

Die Befehlsliste ist die eingebettete Fassung in einer Seite oder einem Panel, etwa als Suche einer Doku. Palette ist der schwebende Dialog über der ganzen App, mit Gruppen wie Seiten, Aktionen und Zuletzt, einem Icon und einem Tastenkürzel je Eintrag und einem leeren Zustand mit Vorschlägen.

Benenne Einträge nach dem, was sie tun, Rechnung erstellen statt Rechnungen neu. Setz die fünf häufigsten Aktionen in den leeren Zustand, Menschen öffnen die Palette, bevor sie wissen, was sie tippen. Jede Aktion sollte auch ohne Palette erreichbar sein, sie ist eine Abkürzung.

Variants

Befehlsliste

Der Inhalt der Palette an Ort und Stelle. Einfach tippen, die Liste filtert über Namen, Hinweise und Stichwörter.

Palette

Im Modal nahe dem oberen Rand. Strg K oder ⌘K drücken, oder den Knopf nutzen.

Details

Was Befehlspalette mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Overlays
Bereich
Elements
Variants
2
Version
1.0.1 · seit 1.0.0
Datei
components/ui/command-palette.tsx
Bringt mit
utilskiticondialog
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle CommandProps aus components/ui/command-palette.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • itemsPflicht
    CommandItem[]
  • defaultQueryoptional
    string

    Pre-filled search, for demos

  • onDoneoptional
    () => void

    Called after an item ran, e.g. to close the palette

  • autoFocusoptional
    boolean

Eingebaute Texte

Diese Texte schreibt Befehlspalette selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 7 Texte anzeigen
placeholderEnglisch Type a command or searchDeutsch Befehl eingeben oder suchen
emptyEnglisch Nothing found. Try another word.Deutsch Nichts gefunden. Versuch ein anderes Wort.
navigateEnglisch to moveDeutsch bewegen
selectEnglisch to openDeutsch öffnen
closeEnglisch to closeDeutsch schließen
labelEnglisch Command menuDeutsch Befehlsmenü
resultsEnglisch resultsDeutsch Treffer

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Befehlspalette reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pos1, Ende und Leertaste.
  • Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
  • Setzt aria-activedescendant, aria-autocomplete, aria-controls, aria-label, aria-labelledby, aria-live und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen combobox, group, listbox und option.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 291 Zeilen
components/ui/command-palette.tsx
'use client';

import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { scrim, useDialogState, useModalMotion } from '@/components/ui/dialog';

const TEXTS = {
  en: { placeholder: 'Type a command or search', empty: 'Nothing found. Try another word.', navigate: 'to move', select: 'to open', close: 'to close', label: 'Command menu', results: 'results' },
  de: { placeholder: 'Befehl eingeben oder suchen', empty: 'Nichts gefunden. Versuch ein anderes Wort.', navigate: 'bewegen', select: 'öffnen', close: 'schließen', label: 'Befehlsmenü', results: 'Treffer' },
};
type Texts = (typeof TEXTS)['en'];

const CSS = '.kitfx-cmd-empty{animation:kitfx-cmd-in .45s var(--ease-kit) both}@keyframes kitfx-cmd-in{from{opacity:0;transform:translateY(6px) scale(.98)}}@media (prefers-reduced-motion:reduce){.kitfx-cmd-empty{animation:none}}';

export interface CommandItem {
  id: string;
  label: string;
  group?: string;
  icon?: IconName;
  /** Quiet text after the label, e.g. a path or a category */
  hint?: string;
  /** Shortcut keys shown at the end, e.g. ['⌘', 'N'] */
  keys?: string[];
  /** More words that should find this item */
  keywords?: string[];
  onSelect?: () => void;
  href?: string;
}

const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');

export interface CommandProps {
  items: CommandItem[];
  /** Pre-filled search, for demos */
  defaultQuery?: string;
  /** Called after an item ran, e.g. to close the palette */
  onDone?: () => void;
  autoFocus?: boolean;
  labels?: Partial<Texts>;
  className?: string;
}

/**
 * The searchable command list on its own: input, groups, keyboard (arrows, Home, End, Enter).
 * Use it inline (a launcher page, a sidebar) or inside <CommandPalette>.
 * The highlight glides between rows, rows glide to their new place while you filter.
 */
export function Command({ items, defaultQuery = '', onDone, autoFocus, labels, className }: CommandProps) {
  const t = useTexts(TEXTS, labels);
  const [query, setQuery] = useState(defaultQuery);
  const [active, setActive] = useState(0);
  const id = useId();
  const listRef = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<{ y: number; h: number; animate: boolean } | null>(null);
  const places = useRef<Map<string, number> | null>(null);

  const shown = useMemo(() => {
    const words = norm(query).split(/\s+/).filter(Boolean);
    if (!words.length) return items;
    return items.filter((i) => {
      const hay = norm([i.label, i.hint, i.group, ...(i.keywords ?? [])].join(' '));
      return words.every((w) => hay.includes(w));
    });
  }, [items, query]);

  const groups = useMemo(() => {
    const out: { name?: string; rows: { item: CommandItem; index: number }[] }[] = [];
    shown.forEach((item, index) => {
      const g = out.find((x) => x.name === item.group);
      if (g) g.rows.push({ item, index });
      else out.push({ name: item.group, rows: [{ item, index }] });
    });
    // Keep the visual order and the keyboard order the same
    let k = 0;
    out.forEach((g) => g.rows.forEach((r) => (r.index = k++)));
    return out;
  }, [shown]);
  const flat = groups.flatMap((g) => g.rows.map((r) => r.item));

  useEffect(() => setActive(0), [query]);

  // The highlight is one surface that travels to the active row
  useLayoutEffect(() => {
    const el = document.getElementById(`${id}-${active}`);
    if (!el || !flat.length) {
      setMark((m) => (m ? null : m));
      return;
    }
    setMark((m) => {
      const next = { y: el.offsetTop, h: el.offsetHeight, animate: m !== null };
      return m && m.y === next.y && m.h === next.h ? m : next;
    });
    const list = listRef.current;
    if (!list) return;
    const r = el.getBoundingClientRect();
    const lr = list.getBoundingClientRect();
    if (r.top < lr.top + 8) list.scrollTop -= lr.top + 8 - r.top;
    else if (r.bottom > lr.bottom - 8) list.scrollTop += r.bottom - lr.bottom + 8;
  }, [active, id, shown]);

  // Rows that stay glide from their old place, new rows fade in
  useLayoutEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const nodes = Array.from(list.querySelectorAll<HTMLElement>('[data-flip]'));
    const now = new Map(nodes.map((n) => [n.dataset.flip!, n.offsetTop]));
    const before = places.current;
    places.current = now;
    if (!before || prefersReducedMotion()) return;
    let fresh = 0;
    nodes.forEach((n) => {
      const was = before.get(n.dataset.flip!);
      const top = now.get(n.dataset.flip!)!;
      if (was === undefined) {
        n.animate([{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 320, delay: Math.min(fresh++, 6) * 25, easing: 'cubic-bezier(0.2,0.7,0.2,1)', fill: 'backwards' });
      } else if (was !== top) {
        n.animate([{ transform: `translateY(${was - top}px)` }, { transform: 'none' }], { duration: 340, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
      }
    });
  }, [shown]);

  const run = (item?: CommandItem) => {
    if (!item) return;
    item.onSelect?.();
    if (item.href) window.location.assign(item.href);
    onDone?.();
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const n = flat.length;
    if (!n) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => (a + 1) % n); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => (a - 1 + n) % n); }
    else if (e.key === 'Home' && e.ctrlKey) { e.preventDefault(); setActive(0); }
    else if (e.key === 'End' && e.ctrlKey) { e.preventDefault(); setActive(n - 1); }
    else if (e.key === 'Enter') { e.preventDefault(); run(flat[active]); }
  };

  return (
    <div className={cn('flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-[var(--highlight),var(--shadow-lg)]', className)}>
      <style href="kitfx-cmd" precedence="default">
        {CSS}
      </style>
      <div className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4">
        <Icon name="search" size={18} className="text-subtle-foreground" />
        <input
          role="combobox"
          aria-expanded
          aria-controls={`${id}-list`}
          aria-activedescendant={flat.length ? `${id}-${active}` : undefined}
          aria-autocomplete="list"
          aria-label={t.label}
          autoFocus={autoFocus}
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          onKeyDown={onKeyDown}
          placeholder={t.placeholder}
          spellCheck={false}
          className="h-full w-0 min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-subtle-foreground sm:text-[15px]"
        />
      </div>
      <div ref={listRef} id={`${id}-list`} role="listbox" aria-label={t.label} className="kit-scrollbar relative isolate min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
        {mark && (
          <span
            aria-hidden
            className={cn('pointer-events-none absolute inset-x-2 top-0 -z-10 rounded-lg bg-accent', mark.animate && 'transition-transform duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none')}
            style={{ height: mark.h, transform: `translateY(${mark.y}px)` }}
          />
        )}
        {groups.map((g, gi) => (
          <div key={g.name ?? gi} role="group" aria-labelledby={g.name ? `${id}-g${gi}` : undefined} className={cn(gi > 0 && 'mt-2')}>
            {g.name && (
              <div id={`${id}-g${gi}`} data-flip={`g:${g.name}`} className="px-2 pb-1.5 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-subtle-foreground">
                {g.name}
              </div>
            )}
            {g.rows.map(({ item, index }) => {
              const on = index === active;
              return (
                <div
                  key={item.id}
                  id={`${id}-${index}`}
                  data-flip={item.id}
                  role="option"
                  aria-selected={on}
                  onPointerMove={() => setActive(index)}
                  onPointerDown={(e) => e.preventDefault()}
                  onClick={() => run(item)}
                  className={cn('group/opt flex min-h-11 cursor-pointer items-center gap-3 rounded-lg px-2 text-sm text-foreground sm:min-h-10', on && !mark && 'bg-accent')}
                >
                  {item.icon && (
                    <span className={cn('grid size-7 shrink-0 place-items-center rounded-md border border-border bg-surface text-muted-foreground transition-[color,scale] duration-200 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', on && 'scale-105 text-foreground')}>
                      <Icon name={item.icon} size={15} />
                    </span>
                  )}
                  <span className="flex min-w-0 flex-1 items-baseline gap-2">
                    <span className="truncate">{item.label}</span>
                    {item.hint && <span className="shrink-0 text-subtle-foreground">{item.hint}</span>}
                  </span>
                  {item.keys && (
                    <span className="flex shrink-0 gap-1 max-sm:hidden">
                      {item.keys.map((k) => (
                        <kbd key={k} className="grid h-[22px] min-w-[22px] place-items-center rounded-[5px] border border-border bg-surface px-1.5 font-sans text-[11px] font-medium text-muted-foreground">
                          {k}
                        </kbd>
                      ))}
                    </span>
                  )}
                  {!item.keys && (
                    <Icon
                      name="corner-down-left"
                      size={15}
                      className={cn('shrink-0 text-subtle-foreground transition-[opacity,translate] duration-200 ease-[var(--ease-kit)] max-sm:hidden motion-reduce:transition-none', on ? 'translate-x-0 opacity-100' : '-translate-x-1 opacity-0')}
                    />
                  )}
                </div>
              );
            })}
          </div>
        ))}
        {!flat.length && (
          <div className="kitfx-cmd-empty grid place-items-center gap-3 px-6 py-12 text-center">
            <span className="grid size-10 place-items-center rounded-full bg-muted text-muted-foreground">
              <Icon name="search" size={18} />
            </span>
            <p className="text-sm text-muted-foreground">{t.empty}</p>
          </div>
        )}
      </div>
      <span aria-live="polite" className="sr-only">
        {query ? `${flat.length} ${t.results}` : ''}
      </span>
      <div className="flex shrink-0 items-center gap-4 border-t border-border bg-surface px-4 py-2.5 text-xs text-muted-foreground max-sm:hidden">
        {[
          [['↑', '↓'], t.navigate],
          [['↵'], t.select],
          [['Esc'], t.close],
        ].map(([keys, label]) => (
          <span key={label as string} className="flex items-center gap-1.5">
            {(keys as string[]).map((k) => (
              <kbd key={k} className="grid h-5 min-w-5 place-items-center rounded-[4px] border border-border bg-background px-1 font-sans text-[10.5px] font-medium">
                {k}
              </kbd>
            ))}
            {label as string}
          </span>
        ))}
      </div>
    </div>
  );
}

export interface CommandPaletteProps extends Omit<CommandProps, 'onDone' | 'autoFocus'> {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** The key that opens it with Ctrl or ⌘. Default "k". false turns the shortcut off. */
  shortcut?: string | false;
}

/**
 * The command menu in a modal: Ctrl K or ⌘K opens it anywhere, Escape closes it.
 * It grows out of the button that opened it and shrinks back when it closes.
 *   <CommandPalette items={[{ id: 'new', label: 'New invoice', group: 'Actions', icon: 'plus', keys: ['N'] }]} />
 */
export function CommandPalette({ open: openProp, defaultOpen, onOpenChange, shortcut = 'k', ...rest }: CommandPaletteProps) {
  const [open, setOpen] = useDialogState(openProp, defaultOpen, onOpenChange);
  const [modal, present] = useModalMotion(open, setOpen);
  const t = useTexts(TEXTS, rest.labels);
  useEffect(() => {
    if (!shortcut) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key.toLowerCase() === shortcut && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen(!open);
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [shortcut, open, setOpen]);
  return (
    <dialog
      {...modal}
      aria-label={t.label}
      className={cn('mx-auto mt-[12vh] mb-auto w-[calc(100%-1.5rem)] max-w-[40rem] overflow-visible bg-transparent p-0 text-left outline-none data-[closing]:pointer-events-none max-sm:mt-3', scrim)}
    >
      {present && <Command {...rest} autoFocus onDone={() => setOpen(false)} className="max-h-[min(30rem,76vh)]" />}
    </dialog>
  );
}

Häufige Fragen

Wie installiere ich Befehlspalette?

Mit aiw add @ai-website/command-palette. Der Befehl legt die Datei components/ui/command-palette.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und dialog. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Befehlspalette?

2 Varianten, Befehlsliste und Palette.

Kann ich die Texte von Befehlspalette übersetzen?

Ja. Befehlspalette bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Befehl eingeben oder suchen“, „Nichts gefunden. Versuch ein anderes Wort.“ und „bewegen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Befehlspalette einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Befehlspalette mit aiw_components und installieren sie mit aiw_add @ai-website/command-palette.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert