Duck Command
The ⌘K palette on StickerDialog and a filtered listbox — no cmdk. The input keeps focus while the arrow keys move aria-activedescendant through the results, so a keystroke is never spent getting back to the field.
NavigationClient componentlucide-react
Preview
Nothing run yet. Press ⌘K anywhere on this page.
Installation
The CLI writes the source into your project and pulls in whatever it depends on.
$
pnpm dlx shadcn@latest add @duck/duck-commandPulls in @duck/theme, @duck/sticker-dialog, @duck/glow-input, @duck/hud-label, @duck/sticker-kbd. Already installed items are skipped.
Usage
import { DuckCommand, DuckCommandGroup, DuckCommandItem, DuckCommandEmpty } from "@/components/ui/duck-command"The file lands in components/ui/duck-command.tsx and belongs to you from that point on. Edit it in place rather than wrapping it.
Props
Everything not listed here is forwarded to the underlying element.
| Prop | Type | Default | Description |
|---|---|---|---|
| items | (DuckCommandItemData | DuckCommandGroupData)[] | - | The rows. An item is { value?, label, hint?, keywords?, icon?, shortcut?, disabled?, onSelect? }; a group is { heading?, items }. Mix them — consecutive bare items collect into one unheaded group. |
| open | boolean | - | Controlled. Leave it off and the palette runs on its own state. |
| defaultOpen | boolean | false | Uncontrolled start. |
| onOpenChange | (open: boolean) => void | - | Fires on the shortcut, on Escape, on the scrim and after a selection. |
| onSelect | (value: string, item: DuckCommandItemData) => void | - | Runs after the item's own onSelect. value falls back to the label. |
| recent | DuckCommandItemData[] | - | Shown instead of everything while the query is empty. Anything typed searches items. |
| recentLabel | string | "Recent" | Heading over recent. |
| placeholder | string | "Type a command or search…" | Input placeholder. |
| label | string | "Command palette" | Names the dialog, the combobox and the listbox. Not drawn — the input is the only label a palette needs. |
| description | string | - | Replaces the default sr-only sentence describing the keys. |
| emptyMessage | React.ReactNode | "Nothing matches that." | Body of DuckCommandEmpty. |
| shortcut | boolean | string | true | The global binding. true is Mod+K; a string is a key spec, either "mod+<key>" or a bare "<key>"; false binds nothing. Same keystroke closes what it opened. |
| filter | (item: DuckCommandItemData, query: string) => boolean | - | Replace the match. The query arrives trimmed and lower-cased. The default is substring across label, hint and keywords, then subsequence across the label. |
| closeOnSelect | boolean | true | Turn off for a palette that stays open while it toggles things. |
| footer | React.ReactNode | - | A strip under the list, for key hints or a result count. |
| holo | boolean | false | Iridescent ring instead of the die-cut edge. Forwarded to StickerDialogContent. |
| heading | string | - | On DuckCommandGroup: the group's label, in .hud typography. Omit for a run of rows with no heading. |
| active | boolean | false | On DuckCommandItem: this row is the activedescendant. Highlight and lime rail, not focus — focus stays in the input. |
Rules
What keeps this component from turning into noise.
- items is the API. Composed children are not accepted, because filtering children means a mount-order registry that watches the DOM to know which rows survived and whether "no results" is true — cmdk's whole architecture, and the thing this item exists to avoid. Palette rows come from a route table or a fetch anyway.
- DuckCommandGroup, DuckCommandItem and DuckCommandEmpty are the pieces DuckCommand renders. Use them directly only if you are rebuilding the body, and then you own the filtering and the keyboard.
- DuckDashboard's onSearch already binds Mod+K. Wire the palette to it and pass shortcut={false}, or drop onSearch and let the palette own the key — two handlers on one keystroke is the mistake to avoid here.
- A bare-key shortcut (shortcut="/") is ignored while focus is in an input, textarea, select or contenteditable. The Mod+K form is taken everywhere, because ⌘K types no character.
- Rows are role="option" divs, never buttons. A focusable row would put Tab into the results instead of out of the dialog.
- Matches are filtered in place and never re-ranked. A palette whose rows rearrange between keystrokes has to be re-read on every keystroke. Order the array the way you want it read, and put synonyms in keywords.
- Home and End move the active row rather than the caret. The query is a few characters; the list is the point.
- A row's shortcut caps are aria-hidden — "command sign, K" read after every row is noise. If the keys matter to a screen reader, put them in hint.
- The panel stays vertically centred. duck-dialog-in carries the centring translate through every frame, so moving top without rewriting those keyframes throws the palette across the viewport on arrival.
