Use Cases (Aktualisiert: 2.6.2026)

React-Befehlspalette mit Claude Code bauen: Suche, ARIA und Tastatursteuerung

Baue mit Claude Code eine zugängliche React-Befehlspalette mit Suche, Tastatursteuerung, ARIA und TypeScript-Code.

React-Befehlspalette mit Claude Code bauen: Suche, ARIA und Tastatursteuerung

Eine Befehlspalette ist die schnelle Abkürzung durch das Produkt

Eine Befehlspalette öffnet sich meist mit Cmd+K oder Ctrl+K und erlaubt, Aktionen per Suche auszuführen: Seite öffnen, Datensatz erstellen, Einstellung ändern oder einen Workflow starten. Für Anfänger ist das ein Suchfeld für Funktionen. Für Power-User ist es der schnellste Weg durch eine komplexe Oberfläche.

Claude Code kann so eine UI schnell erzeugen, aber die Anforderung muss präzise sein. Es reicht nicht, ein hübsches Modal mit einer Liste zu bauen. In der Praxis brauchst du Tastaturnavigation, Fokussteuerung, ARIA-Rollen, robuste Suche, Schutz vor versehentlichen Aktionen und eine klare Trennung zwischen UI und auszuführendem Kommando. ARIA bedeutet hier: Attribute, die Screenreadern die Bedeutung der Oberfläche erklären. Ein Combobox-Pattern ist ein Eingabefeld mit zugehöriger Ergebnisliste.

Masa hat diese Komponente zuerst in einem kleinen Redaktions-Admin getestet. Die erste Version sah gut aus, führte aber Enter während der japanischen IME-Eingabe aus und meldete Screenreadern nicht, welche Option aktiv war. Die folgende Version vermeidet diese Fehler.

Vertiefend passen Tastatur-Shortcuts, Accessibility mit Claude Code und Performance-Optimierung. Offizielle Referenzen sind React useDeferredValue, React useMemo, WAI-ARIA Combobox Pattern, WAI-ARIA Modal Dialog Pattern und die Claude Code Docs.

Umsetzung mit Claude Code

Gib Claude Code nicht nur den Wunsch nach einer Befehlspalette, sondern die Prüfbedingungen:

BedarfUmsetzung
ÖffnenGlobaler Cmd+K / Ctrl+K Shortcut
SucheFilter über label, category, description, keywords
PerformanceuseDeferredValue für Eingabegefühl, useMemo für Ergebnisberechnung
TastaturArrow, Home, End, Enter, Escape und Tab
Barrierefreiheitdialog, combobox, listbox, option, aria-activedescendant
SicherheitDestruktive Aktionen bestätigen oder auf Review-Seite leiten
npm create vite@latest command-palette-demo -- --template react-ts
cd command-palette-demo
npm install
npm run dev

Kopierbarer React/TypeScript-Code

import {
  useDeferredValue,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import "./command-palette.css";

export type Command = {
  id: string;
  label: string;
  category: string;
  description?: string;
  keywords?: string[];
  shortcut?: string;
  run: () => void | Promise<void>;
};

type Props = {
  commands: Command[];
  label?: string;
};

const normalize = (value: string) => value.trim().toLocaleLowerCase();

function rank(command: Command, terms: string[], fullQuery: string) {
  if (terms.length === 0) return 1;

  const label = command.label.toLocaleLowerCase();
  const category = command.category.toLocaleLowerCase();
  const description = command.description?.toLocaleLowerCase() ?? "";
  const keywords = (command.keywords ?? []).join(" ").toLocaleLowerCase();
  const haystack = `${label} ${category} ${description} ${keywords}`;

  if (terms.some((term) => !haystack.includes(term))) return -1;
  if (label === fullQuery) return 100;
  if (label.startsWith(fullQuery)) return 80;
  if (label.includes(fullQuery)) return 60;
  if (category.includes(fullQuery)) return 40;
  return 20;
}

export function CommandPalette({ commands, label = "Befehlspalette" }: Props) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const closeButtonRef = useRef<HTMLButtonElement>(null);
  const listboxId = useId();
  const titleId = useId();
  const deferredQuery = useDeferredValue(query);
  const normalizedQuery = normalize(deferredQuery);

  const filtered = useMemo(() => {
    const terms = normalizedQuery.split(/\s+/).filter(Boolean);
    return commands
      .map((command) => ({ command, score: rank(command, terms, normalizedQuery) }))
      .filter((item) => item.score >= 0)
      .sort((a, b) => b.score - a.score || a.command.label.localeCompare(b.command.label))
      .map((item) => item.command);
  }, [commands, normalizedQuery]);

  const activeCommand = filtered[activeIndex];
  const activeOptionId = activeCommand ? `${listboxId}-${activeIndex}` : undefined;

  useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
        event.preventDefault();
        setOpen((current) => !current);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, []);

  useEffect(() => {
    if (!open) return;
    setQuery("");
    setActiveIndex(0);
    const frameId = window.requestAnimationFrame(() => inputRef.current?.focus());
    return () => window.cancelAnimationFrame(frameId);
  }, [open]);

  useEffect(() => {
    setActiveIndex((index) => {
      if (filtered.length === 0) return 0;
      return Math.min(index, filtered.length - 1);
    });
  }, [filtered.length]);

  function execute(command: Command | undefined) {
    if (!command) return;
    setOpen(false);
    setQuery("");
    Promise.resolve(command.run()).catch((error) => {
      console.error("Command failed", error);
    });
  }

  function move(delta: number) {
    setActiveIndex((index) => {
      if (filtered.length === 0) return 0;
      return (index + delta + filtered.length) % filtered.length;
    });
  }

  function onInputKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
    if (event.nativeEvent.isComposing) return;

    if (event.key === "ArrowDown") {
      event.preventDefault();
      move(1);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      move(-1);
    } else if (event.key === "Home") {
      event.preventDefault();
      setActiveIndex(0);
    } else if (event.key === "End") {
      event.preventDefault();
      setActiveIndex(Math.max(filtered.length - 1, 0));
    } else if (event.key === "Enter") {
      event.preventDefault();
      execute(activeCommand);
    } else if (event.key === "Escape") {
      event.preventDefault();
      setOpen(false);
    }
  }

  function onDialogKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
    if (event.key !== "Tab") return;

    const focusable = [closeButtonRef.current, inputRef.current].filter(Boolean) as HTMLElement[];
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    if (!first || !last) return;

    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  }

  return (
    <>
      <button type="button" className="cp-trigger" onClick={() => setOpen(true)}>
        Commands <kbd>Ctrl K</kbd>
      </button>

      {open ? (
        <div className="cp-backdrop" onMouseDown={() => setOpen(false)}>
          <div
            className="cp-dialog"
            role="dialog"
            aria-modal="true"
            aria-labelledby={titleId}
            onMouseDown={(event) => event.stopPropagation()}
            onKeyDown={onDialogKeyDown}
          >
            <div className="cp-header">
              <h2 id={titleId}>{label}</h2>
              <button
                ref={closeButtonRef}
                type="button"
                className="cp-close"
                onClick={() => setOpen(false)}
                aria-label="Close command palette"
              >
                Esc
              </button>
            </div>

            <label className="cp-search-label" htmlFor={`${listboxId}-input`}>
              Commands suchen
            </label>
            <input
              ref={inputRef}
              id={`${listboxId}-input`}
              className="cp-input"
              value={query}
              onChange={(event) => {
                setQuery(event.target.value);
                setActiveIndex(0);
              }}
              onKeyDown={onInputKeyDown}
              role="combobox"
              aria-autocomplete="list"
              aria-expanded="true"
              aria-controls={listboxId}
              aria-activedescendant={activeOptionId}
              placeholder="Command suchen..."
            />

            <ul id={listboxId} className="cp-list" role="listbox">
              {filtered.length === 0 ? (
                <li className="cp-empty">Keine passenden Commands</li>
              ) : (
                filtered.map((command, index) => (
                  <li
                    id={`${listboxId}-${index}`}
                    key={command.id}
                    className="cp-option"
                    role="option"
                    aria-selected={index === activeIndex}
                    data-active={index === activeIndex ? "true" : "false"}
                    onMouseMove={() => setActiveIndex(index)}
                    onMouseDown={(event) => event.preventDefault()}
                    onClick={() => execute(command)}
                  >
                    <span>
                      <strong>{command.label}</strong>
                      {command.description ? <small>{command.description}</small> : null}
                    </span>
                    <span className="cp-meta">
                      <span>{command.category}</span>
                      {command.shortcut ? <kbd>{command.shortcut}</kbd> : null}
                    </span>
                  </li>
                ))
              )}
            </ul>
          </div>
        </div>
      ) : null}
    </>
  );
}

Beispiel-Commands

import type { Command } from "./CommandPalette";

export function createCommandActions(navigate: (href: string) => void): Command[] {
  return [
    {
      id: "new-draft",
      label: "Neuen Artikel schreiben",
      category: "Content",
      description: "Öffnet einen leeren Entwurf",
      keywords: ["create", "post", "draft"],
      shortcut: "N",
      run: () => navigate("/editor/new"),
    },
    {
      id: "media-library",
      label: "Medienbibliothek öffnen",
      category: "Media",
      description: "Hero-Bilder und Screenshots verwalten",
      keywords: ["image", "asset", "upload"],
      shortcut: "G M",
      run: () => navigate("/media"),
    },
    {
      id: "toggle-theme",
      label: "Theme wechseln",
      category: "Settings",
      description: "Light und Dark Mode umschalten",
      keywords: ["dark", "light"],
      shortcut: "T",
      run: () => {
        const root = document.documentElement;
        root.dataset.theme = root.dataset.theme === "dark" ? "light" : "dark";
      },
    },
    {
      id: "publish-current",
      label: "Aktuellen Artikel veröffentlichen",
      category: "Publishing",
      description: "Fragt vor dem Veröffentlichen nach",
      keywords: ["publish", "release", "deploy"],
      shortcut: "P",
      run: () => {
        if (window.confirm("Aktuellen Artikel veröffentlichen?")) {
          navigate("/publish/current");
        }
      },
    },
  ];
}

Kompakte Styles

.cp-trigger {
  border: 1px solid #cbd5e1;
  border-radius: 6px;
  background: #ffffff;
  color: #0f172a;
  cursor: pointer;
  padding: 8px 10px;
}

.cp-backdrop {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 14vh 16px 16px;
  background: rgb(15 23 42 / 0.52);
}

.cp-dialog {
  width: min(680px, 100%);
  overflow: hidden;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  background: #ffffff;
  box-shadow: 0 24px 80px rgb(15 23 42 / 0.24);
}

.cp-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  border-bottom: 1px solid #e2e8f0;
  padding: 10px 12px;
}

.cp-header h2 {
  margin: 0;
  font-size: 14px;
}

.cp-close,
.cp-meta kbd {
  border: 1px solid #cbd5e1;
  border-radius: 6px;
  background: #f8fafc;
  padding: 4px 8px;
}

.cp-search-label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
}

.cp-input {
  width: 100%;
  border: 0;
  border-bottom: 1px solid #e2e8f0;
  font-size: 16px;
  outline: none;
  padding: 14px 16px;
}

.cp-input:focus {
  box-shadow: inset 0 0 0 2px #2563eb;
}

.cp-list {
  max-height: min(420px, 52vh);
  margin: 0;
  overflow-y: auto;
  padding: 8px;
  list-style: none;
}

.cp-option {
  display: flex;
  min-height: 58px;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  border-radius: 6px;
  cursor: pointer;
  padding: 10px;
}

.cp-option[data-active="true"] {
  background: #eff6ff;
}

.cp-option small {
  display: block;
  color: #64748b;
  margin-top: 3px;
}

.cp-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #475569;
  font-size: 12px;
}

.cp-empty {
  color: #64748b;
  padding: 32px 12px;
  text-align: center;
}

Typische Use Cases

Erstens: SaaS-Adminbereiche. User-Suche, Billing, Audit Logs, Einladungen und Feature Flags liegen oft tief in der Navigation. Die Palette macht sie direkt erreichbar.

Zweitens: CMS und Medienseiten. Neue Entwürfe, Bildbibliothek, Kategorien, Pre-Publish-Checks und Indexierung lassen sich als Commands bündeln.

Drittens: Dokumentations- und Developer-Tools. API-Referenzen, CLI-Beispiele, Troubleshooting und Kopieraktionen passen sehr gut in eine Command Palette.

Viertens: interne Operations-Tools. Tickets, Kundendatensätze, CSV-Exporte und Benachrichtigungen sind schnell erreichbar, müssen aber serverseitig erneut auf Berechtigungen geprüft werden.

Konkrete Stolperfallen

Verschiebe den DOM-Fokus nicht in jedes li. Der Fokus bleibt im input, während aria-activedescendant die aktive Option beschreibt. Sonst wird Tippen nach der Pfeiltasten-Navigation mühsam.

Ignoriere IME nicht. Ohne event.nativeEvent.isComposing kann Enter beim Bestätigen japanischer, chinesischer oder koreanischer Eingabe versehentlich einen Command ausführen.

Berechne schwere Filter nicht ungeprüft in jedem Render. useDeferredValue und useMemo helfen, aber bei sehr großen Datenmengen brauchst du serverseitige Suche und Pagination.

Mache destruktive Aktionen nicht zu schnell. Löschen, Veröffentlichen, Senden und Abrechnen brauchen Bestätigung, Review-Seite oder Permission Gate.

Ergebnis und nächster Schritt

Im Test von Masa wurden wiederkehrende Redaktionsaktionen von mehreren Klicks auf Ctrl+K, wenige Zeichen und Enter reduziert. Der größte Gewinn war nicht nur Geschwindigkeit, sondern weniger Kontextwechsel. Die Publish-Aktion blieb bewusst hinter einer Bestätigung, weil eine gute Palette schnelle, aber keine riskanten Wege bauen soll.

Starte mit 10 bis 20 Commands und miss, welche wirklich genutzt werden. Danach kannst du Ranking, zuletzt verwendete Commands und serverseitige Suche ergänzen. Für Prompts und Review-Checklisten nutze das kostenlose Cheatsheet. Für Team-Rollout oder UI-Architektur hilft die Beratung.

#Claude Code #Befehlspalette #UI #Tastaturbedienung #React
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.