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
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.
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 7 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CommandProps aus components/ui/command-palette.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
itemsPflichtCommandItem[]defaultQueryoptionalstringPre-filled search, for demos
onDoneoptional() => voidCalled after an item ran, e.g. to close the palette
autoFocusoptionalboolean
Eingebaute Texte
Diese Texte schreibt Befehlspalette selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 7 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| placeholder | Englisch Type a command or search | Deutsch Befehl eingeben oder suchen |
| empty | Englisch Nothing found. Try another word. | Deutsch Nichts gefunden. Versuch ein anderes Wort. |
| navigate | Englisch to move | Deutsch bewegen |
| select | Englisch to open | Deutsch öffnen |
| close | Englisch to close | Deutsch schließen |
| label | Englisch Command menu | Deutsch Befehlsmenü |
| results | Englisch results | Deutsch 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
'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.