Elements

85 / 122 · Composer

Command palette

Everything the app can do, one keystroke away and grouped by where it acts.

esc
Thread
Session

Installation

npx shadcn@latest add "@assistant-ui/elements-command-palette"

Usage

import { CommandPalette } from "@/components/elements/command-palette";

<CommandPalette
  commands={commands}
  query={query}
  activeId={activeId}
  onQueryChange={setQuery}
  onRun={run}
/>

Props

CommandPalette

commands*PaletteCommand[]Everything runnable. Groups are derived from the entries and keep first-seen order.
query*stringCurrent filter text.
activeId*stringWhich row is highlighted. The element only styles it; wire your own arrow-key and Enter handling and drive this prop from it.
onQueryChange(query: string) => voidCalled as the filter is typed.
onRun(id: string) => voidCalled when a command is chosen.
classNamestringExtra classes merged onto the root.

PaletteCommand

id*stringStable identity, compared against activeId and reported by onRun.
label*stringCommand text, and the field the query filters on.
group*stringHeading the command sits under.
keys*string[]Shortcut, one chip per key.

Source

command-palette.tsx
"use client";

import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, floating, mono } from "./surfaces";

export interface PaletteCommand {
  id: string;
  label: string;
  group: string;
  keys: readonly string[];
}

export function CommandPalette({
  commands,
  query,
  activeId,
  onQueryChange,
  onRun,
  className,
}: {
  commands: readonly PaletteCommand[];
  query: string;
  activeId: string;
  onQueryChange?: (query: string) => void;
  onRun?: (id: string) => void;
  className?: string;
}) {
  const matches = commands.filter((command) =>
    command.label.toLowerCase().includes(query.toLowerCase()),
  );
  const groups = [...new Set(matches.map((command) => command.group))];

  return (
    <div
      className={cn(
        floating,
        "flex w-full max-w-sm flex-col overflow-hidden rounded-[20px]",
        className,
      )}
    >
      <div className="flex items-center gap-2.5 px-3.5 py-3">
        <SearchIcon className="text-foreground/30 size-3.5 shrink-0" />
        <input
          value={query}
          onChange={(event) => onQueryChange?.(event.target.value)}
          placeholder="Type a command"
          aria-label="Type a command"
          className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 bg-transparent text-[13.5px] outline-none"
        />
        <span
          className={cn(
            field,
            mono,
            "text-foreground/35 rounded px-1.5 py-0.5",
          )}
        >
          esc
        </span>
      </div>

      <div className="border-foreground/[0.07] flex max-h-72 flex-col overflow-y-auto border-t p-1.5">
        {groups.map((group) => (
          <div key={group} className="flex flex-col">
            <span className={cn(mono, "text-foreground/25 px-2 pt-2 pb-1")}>
              {group}
            </span>
            {matches
              .filter((command) => command.group === group)
              .map((command) => (
                <button
                  key={command.id}
                  type="button"
                  onClick={() => onRun?.(command.id)}
                  className={cn(
                    "flex items-center gap-2 rounded-xl px-2 py-1.5 text-start transition-colors",
                    command.id === activeId
                      ? "bg-foreground/[0.06]"
                      : "hover:bg-foreground/[0.03]",
                  )}
                >
                  <span className="min-w-0 flex-1 truncate text-[13px]">
                    {command.label}
                  </span>
                  <span className="flex shrink-0 gap-1">
                    {command.keys.map((key) => (
                      <span
                        key={key}
                        className={cn(
                          field,
                          mono,
                          "text-foreground/40 rounded px-1.5 py-0.5",
                        )}
                      >
                        {key}
                      </span>
                    ))}
                  </span>
                </button>
              ))}
          </div>
        ))}
        {matches.length === 0 && (
          <span className="text-foreground/30 px-2 py-4 text-center text-xs">
            No command matches “{query}”
          </span>
        )}
      </div>
    </div>
  );
}