← ui

command

ui

a command palette: a field that finds actions as you type, their keys drawn

npx shadcn add @ag/command
Already installed? Preview an update
npx shadcn add @ag/command --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Fixed: command typechecks under @base-ui/react 1.8, which types a group of items with an index signature; with 1.8 installed, it failed to compile.
Updated 2026-10-12 · release notes

also called cmdk, spotlight

shelf controls

uses dialog, fuzzy, input, key-router, keys, material, menu, scroll-area, search, shortcut

used by components

the demo above, verbatim

command.demo.tsx
"use client"

import {
  Copy,
  Download,
  Film,
  Moon,
  Pencil,
  Scissors,
  Share2,
  Trash2,
} from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Command, type CommandGroup } from "@/registry/base-nova/ui/command"
import { MaterialProvider } from "@/registry/base-nova/ui/material"

// #region actions
// What the palette offers, in groups; the hint is the press each stands for.
const actions = (say: (what: string) => void): CommandGroup[] => [
  {
    heading: "clip",
    actions: [
      {
        icon: <Scissors />,
        label: "split at the playhead",
        hint: "mod+b",
        keywords: ["cut"],
        onSelect: () => say("split"),
      },
      {
        icon: <Copy />,
        label: "duplicate clip",
        hint: "mod+d",
        onSelect: () => say("duplicate"),
      },
      {
        icon: <Pencil />,
        label: "rename clip",
        hint: "f2",
        onSelect: () => say("rename"),
      },
      {
        icon: <Trash2 />,
        label: "move to trash",
        hint: "mod+backspace",
        keywords: ["delete", "remove"],
        onSelect: () => say("trash"),
      },
    ],
  },
  {
    heading: "film",
    actions: [
      {
        icon: <Download />,
        label: "export the whole film",
        hint: "mod+shift+e",
        onSelect: () => say("export"),
      },
      {
        icon: <Share2 />,
        label: "share a link",
        onSelect: () => say("share"),
      },
      {
        icon: <Film />,
        label: "open in the player",
        onSelect: () => say("open"),
      },
    ],
  },
  {
    heading: "view",
    actions: [
      {
        icon: <Moon />,
        label: "switch to the dark theme",
        keywords: ["appearance"],
        onSelect: () => say("theme"),
      },
    ],
  },
]
// #endregion

// #region palette
function Palette() {
  const [open, setOpen] = React.useState(false)
  const [said, say] = React.useState("")
  return (
    <div className="flex items-center gap-3 text-current/70 text-sm">
      <Button variant="secondary" onClick={() => setOpen(true)}>
        commands
      </Button>
      {said && <span>ran: {said}</span>}
      <Command open={open} onOpenChange={setOpen} groups={actions(say)} />
    </div>
  )
}
// #endregion

export default function CommandDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="plain"
        glance
        title="actions, found by typing"
        description="arrows walk the list, enter runs the lit action, esc empties what is typed and then closes; a ⌘ shortcut drawn on a row runs it, unless the field edits with it"
        with="actions palette"
      >
        <Palette />
      </Sample>
      <Sample
        name="glass"
        title="in glass, over a picture"
        description="material glass round it: the dialog, its field and its keys"
        with="actions palette"
      >
        <MaterialProvider material="glass">
          <div className="grid place-items-center rounded-2xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-12 text-white">
            <Palette />
          </div>
        </MaterialProvider>
      </Sample>
    </div>
  )
}

the source, every file an install writes

command.tsx
"use client"

// A COMMAND PALETTE: a dialog holding a field and the actions it finds. Typing filters
// them (lib/fuzzy, the one matcher every search here uses: letters in order, word
// starts and runs first, case and accents folded), the letters it matched lit; the
// best match leads and is lit, so Enter runs what you meant. The actions come in
// groups under headings, each with its glyph and the keys it stands for drawn on the
// right (menu's `Hints`), and a key drawn is a key it answers to while open, as a menu's
// is, unless the field owns it (`ownedHere`: a letter is typing, ⌘X cuts text).
//
//   <Command
//     open={open}
//     onOpenChange={setOpen}
//     groups={[{ heading: "clip", actions: [{ icon: <Scissors />, label: "cut here",
//       hint: "mod+x", onSelect: cut }] }]}
//   />
//
// The consumer opens it; it registers no page-wide key of its own. Keys: arrows walk
// the list (the field keeps focus, the lit row is its `aria-activedescendant`, Base
// UI's autocomplete), Enter runs the lit action, Esc first empties what is typed and
// then closes (the dialog's own Esc, a role the key router leaves alone). The action
// runs once the dialog has shut and given focus back, so one that opens something
// focusable keeps it. Two materials (`material`, or a MaterialProvider round it): the
// dialog's plain or glass face, its field and keys in the same.

import { Autocomplete } from "@base-ui/react/autocomplete"
import { Search as SearchIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { fuzzyFilter, type Range } from "@/registry/base-nova/lib/fuzzy"
import { pressed } from "@/registry/base-nova/lib/keys"
import {
  Dialog,
  DialogContent,
  DialogTitle,
} from "@/registry/base-nova/ui/dialog"
import { InputGroup } from "@/registry/base-nova/ui/input"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
  type Material,
  type Scheme,
  useMaterial,
} from "@/registry/base-nova/ui/material"
import { Hints } from "@/registry/base-nova/ui/menu"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { Highlight } from "@/registry/base-nova/ui/search"
import { usePlatform } from "@/registry/base-nova/ui/shortcut"

/** One thing the palette does. */
export interface CommandAction {
  /** What it does, as people say it: the text a query is matched against and drawn
   *  with the letters it lit. */
  label: string
  onSelect: () => void
  /** what it does, as a glyph */
  icon?: React.ReactNode
  /** The keys it stands for, as `shortcut` reads them ("mod+k", "shift+f10"), drawn
   *  on the right; several are alternatives. */
  hint?: string | readonly string[]
  /** other words that find it ("remove" for "move to trash") */
  keywords?: readonly string[]
  disabled?: boolean
  /** Unique within its group; the label unless two share one. */
  id?: string
}

/** Actions under one heading. */
export interface CommandGroup {
  heading: string
  actions: readonly CommandAction[]
}

/** An action as the list draws it: where it is from and what the query lit. */
interface Row {
  key: string
  action: CommandAction
  ranges: Range[]
}

// A type, not an interface: Base UI 1.8 types a group with an index signature,
// which an object type satisfies and an interface never does.
type Section = {
  heading: string
  items: Row[]
}

export interface CommandProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  groups: readonly CommandGroup[]
  placeholder?: string
  /** What it is, for a screen reader: the dialog's title. */
  label?: string
  /** Said when nothing matches. */
  empty?: React.ReactNode
  /** Where it is portalled (a player's layer, so it shows in fullscreen). */
  container?: React.ComponentProps<typeof DialogContent>["container"]
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  className?: string
}

/** It stands high, where the eye already is, not in the middle of the page: the
 *  field stays put while the list under it grows and shrinks. */
const PLACE =
  "top-[min(15dvh,8rem)] translate-y-0 w-[min(36rem,calc(100vw-2rem))] gap-0 p-2"

const ROW =
  "flex scroll-my-1 cursor-default select-none items-center gap-3 px-2 py-2 text-[13px] text-current/85 outline-none data-disabled:opacity-45 data-highlighted:text-current"

const FACE: Record<Material, string> = {
  plain: "rounded-md data-highlighted:bg-foreground/8",
  glass: "rounded-[10px] data-highlighted:bg-current/12",
}

const hintsOf = (hint: CommandAction["hint"]): readonly string[] =>
  hint === undefined ? [] : typeof hint === "string" ? [hint] : hint

export function Command({
  open,
  onOpenChange,
  groups,
  placeholder = "type a command",
  label = "commands",
  empty = "nothing matches",
  container,
  material,
  scheme,
  className,
}: CommandProps) {
  const m = useMaterial(material)
  const platform = usePlatform()
  const [query, setQuery] = React.useState("")
  // The action picked runs once the dialog has shut and handed focus back (menu does
  // the same, for the same reason).
  const picked = React.useRef<(() => void) | null>(null)

  const sections = React.useMemo((): Section[] => {
    const all = groups.flatMap((g) =>
      g.actions.map((action) => ({ heading: g.heading, action })),
    )
    const seen = new Set<string>()
    for (const { heading, action } of all) {
      const key = `${heading}\u0000${action.id ?? action.label}`
      if (seen.has(key))
        throw new Error(
          `Command: two actions named "${action.id ?? action.label}" in "${heading}"; give one an id`,
        )
      seen.add(key)
    }
    const found = fuzzyFilter(all, query, ({ action }) => [
      action.label,
      ...(action.keywords ?? []),
    ])
    // Grouped in the order groups first appear: the groups' own order with nothing
    // typed, the best match's group first while typing.
    const out: Section[] = []
    for (const f of found) {
      const { heading, action } = f.item
      const at =
        out.find((s) => s.heading === heading) ??
        (out[out.push({ heading, items: [] }) - 1] as Section)
      at.items.push({
        key: `${heading}\u0000${action.id ?? action.label}`,
        action,
        ranges: f.ranges[0] ?? [],
      })
    }
    return out
  }, [groups, query])

  const run = (action: CommandAction) => {
    if (action.disabled) return
    picked.current = action.onSelect
    onOpenChange(false)
  }

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => onOpenChange(next)}
      onOpenChangeComplete={(isOpen) => {
        if (isOpen) return
        setQuery("")
        const action = picked.current
        picked.current = null
        action?.()
      }}
    >
      <DialogContent
        data-slot="command"
        container={container}
        material={m}
        scheme={scheme}
        className={cn(PLACE, className)}
      >
        <DialogTitle className="sr-only">{label}</DialogTitle>
        <Autocomplete.Root
          open
          inline
          autoHighlight="always"
          keepHighlight
          items={sections}
          filteredItems={sections}
          value={query}
          onValueChange={(value, details) => {
            // An action pressed runs; it never fills the field.
            if (details.reason !== "item-press") setQuery(value)
          }}
          itemToStringValue={(row: Row) => row.action.label}
        >
          <Autocomplete.Input
            data-slot="command-input"
            render={
              <InputGroup size="lg" start={<SearchIcon />} className="w-full" />
            }
            aria-label={label}
            placeholder={placeholder}
            spellCheck={false}
            autoComplete="off"
            onKeyDown={(event) => {
              if (event.key === "Escape" && query) {
                event.stopPropagation()
                event.preventBaseUIHandler()
                setQuery("")
                return
              }
              // What the field edits with stays the field's (typing, ⌘X, ⌘⌫).
              if (ownedHere(event.nativeEvent)) return
              const action = groups
                .flatMap((g) => g.actions)
                .find(
                  (a) =>
                    !a.disabled &&
                    hintsOf(a.hint).some((h) => pressed(h, event, platform)),
                )
              if (!action) return
              event.preventDefault()
              event.preventBaseUIHandler()
              run(action)
            }}
          />
          <ScrollArea className="max-h-[min(22rem,50dvh)] pt-4 pb-4">
            <Autocomplete.Empty
              data-slot="command-empty"
              className="px-2 py-6 text-center text-[13px] text-current/50 empty:hidden"
            >
              {empty}
            </Autocomplete.Empty>
            <Autocomplete.List data-slot="command-list">
              {(section: Section) => (
                <Autocomplete.Group
                  key={section.heading}
                  items={section.items}
                  data-slot="command-group"
                  className="not-last:mb-2"
                >
                  <Autocomplete.GroupLabel className="px-2 pt-1 pb-1.5 font-mono text-[11px] text-current/45">
                    {section.heading}
                  </Autocomplete.GroupLabel>
                  <Autocomplete.Collection>
                    {(row: Row) => (
                      <Autocomplete.Item
                        key={row.key}
                        value={row}
                        disabled={row.action.disabled}
                        onClick={() => run(row.action)}
                        data-slot="command-item"
                        className={cn(ROW, FACE[m])}
                      >
                        <span
                          aria-hidden
                          className="grid size-4 shrink-0 place-items-center [&_svg]:size-3.5"
                        >
                          {row.action.icon}
                        </span>
                        <span className="min-w-0 flex-1 truncate">
                          <Highlight
                            text={row.action.label}
                            ranges={row.ranges}
                            className="text-current decoration-current/40"
                          />
                        </span>
                        {row.action.hint && (
                          <Hints hint={row.action.hint} className="ml-auto" />
                        )}
                      </Autocomplete.Item>
                    )}
                  </Autocomplete.Collection>
                </Autocomplete.Group>
              )}
            </Autocomplete.List>
          </ScrollArea>
        </Autocomplete.Root>
      </DialogContent>
    </Dialog>
  )
}