Command palette morph
Free · MITA search field morphs into a command palette through a shared layout animation, with fuzzy matching, highlighted letters, grouped results, a sliding selection, arrow keys, Enter, Esc and Ctrl/Cmd+K.
Installation
$ npx shadcn@latest add https://goodui.dev/r/command-palette-morph.jsonThe file lands in components/goodui/command-palette-morph.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/command-palette-morph works too.
Usage
import { CalendarDays, CalendarPlus, Receipt, UserPlus, UserRound, Users } from "lucide-react"
import { useRouter } from "next/navigation"
import { CommandPaletteMorph, type CommandPaletteItem } from "@/components/goodui/command-palette-morph"
export function AppSearch() {
const router = useRouter()
const items: CommandPaletteItem[] = [
{ id: "new-booking", label: "New booking", group: "Actions", icon: CalendarPlus, shortcut: ["N"], onSelect: () => router.push("/bookings/new") },
{ id: "add-client", label: "Add client", group: "Actions", icon: UserPlus, onSelect: () => router.push("/clients/new") },
{ id: "go-calendar", label: "Calendar", group: "Pages", icon: CalendarDays, shortcut: ["G", "C"], onSelect: () => router.push("/calendar") },
{ id: "go-clients", label: "Clients", group: "Pages", icon: Users, onSelect: () => router.push("/clients") },
{ id: "inv-2041", label: "INV-2041", group: "Invoices", icon: Receipt, hint: "Overdue · $240", onSelect: () => router.push("/invoices/2041") },
{ id: "c-maya", label: "Maya Chen", group: "Clients", icon: UserRound, hint: "12 visits", keywords: ["maya@studio.co"], onSelect: () => router.push("/clients/maya") },
]
return <CommandPaletteMorph items={items} triggerLabel="Search clients, invoices…" />
}When to use
- A global Cmd/Ctrl+K search in a dashboard: jump to pages, find clients, run actions like "New booking".
- A header search field that should open into something bigger without a jarring modal pop.
- Power-user shortcuts, with each command's keys shown next to it.
- Searching one list inline on a page: use search-expand or a plain input with filtering.
- Choosing a value for a form field: use select.
- A short menu of actions on a button: use dropdown-stagger.
| If you need | Use |
|---|---|
| Search field in the header | variant="field" (default) with triggerLabel |
| Compact icon in a toolbar or on mobile | variant="icon" (triggerLabel becomes its aria-label) |
| Second palette on the same page | hotkey={false} on all but one |
| Find records by email or number | keywords: ["maya@studio.co"] on the item |
| Open it from elsewhere | open + onOpenChange (controlled) |
Examples
Icon trigger without the hotkey
<CommandPaletteMorph items={items} variant="icon" hotkey={false} triggerLabel="Quick search" />One handler for every item, translated
<CommandPaletteMorph
items={items}
onSelect={(item) => track("command", item.id)}
placeholder="Suchen oder Befehl eingeben…"
triggerLabel="Suchen…"
emptyMessage="Keine Treffer"
labels={{ dialog: "Befehlspalette", results: "Ergebnisse", navigate: "navigieren", select: "auswählen", resultCount: (n) => `${n} Ergebnisse` }}
/>API reference
| Prop | Type | Default |
|---|---|---|
| items* Everything the palette can find; items are listed in this order (grouped by group) until the user types. | CommandPaletteItem[] | — |
| open Whether the palette is open (controlled). | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | false |
| onOpenChange Called when the palette opens or closes (trigger, hotkey, Esc, backdrop or a selection). | (open: boolean) => void | — |
| onSelect Called with the chosen item, after the item's own onSelect. | (item: CommandPaletteItem) => void | — |
| placeholder Placeholder of the search input inside the palette. | string | "Search or type a command…" |
| triggerLabel Text on the field trigger. | string | "Search…" |
| variant `field` looks like a search input; `icon` is a round icon button. | "field" | "icon" | "field" |
| hotkey Open and close with Ctrl+K / Cmd+K. Enable it on one palette per page. | boolean | true |
| emptyMessage Shown when nothing matches; defaults to "No results" with the query. | React.ReactNode | — |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<CommandPaletteMorphLabels> | — |
Also accepts every <button> attribute.
Accessibility
| Ctrl+K / Cmd+K | Opens or closes the palette from anywhere on the page (when hotkey is on). |
| ArrowDown / ArrowUp | Moves the highlighted result, wrapping around, and scrolls it into view. |
| Enter | Runs the highlighted result and closes the palette. |
| Escape | Closes the palette and returns focus to the trigger. |
| Tab | Kept inside the palette: focus stays on the search input. |
- The trigger is a <button> with aria-haspopup="dialog" and aria-expanded; the open palette is role="dialog" with aria-modal and an aria-label from labels.dialog.
- The input is a role="combobox" controlling a role="listbox"; the highlighted option is exposed through aria-activedescendant and aria-selected, and results sit in role="group" sections under their headings.
- A polite live region announces the result count (labels.resultCount) as the user types.
- Opening locks page scroll and focuses the input; closing restores scroll and focus.
- Built-in strings go through labels, including the default empty state (labels.noResults, labels.noMatch(query)); emptyMessage replaces it entirely.
Motion
- The trigger's border box and search icon are shared-layout elements that morph into the palette panel and its icon on a spring (stiffness 380, damping 34, mass 0.9), and back on close.
- The backdrop fades in 200 ms; the input fades in after 80 ms and the results slide down 4px and fade in after 100 ms. Exits are 80 ms fades.
- The selection highlight glides between results on a stiff spring (stiffness 600, damping 42).
- Reduced motion: the panel, icon, backdrop, contents and highlight change instantly.
- Filtering is a small in-memory fuzzy scorer over label and keywords, memoised per query. It is meant for hundreds of items, not a remote search; for server results, update items as the user types.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Put navigation and actions in each item's onSelect; onSelect on the component runs after it for every item (analytics, logging).
- group is a plain string heading; items keep their order within a group until a query sorts them by score.
- Only one palette per page should keep hotkey on; pass hotkey={false} on the others.
- It renders position: fixed without a portal; do not place it inside an element with transform, filter or backdrop-filter, or the overlay will be clipped to it.
- shortcut is display only; wire real hotkeys yourself (for example with the kbd component's onTrigger).
FAQ
How does matching work?
A substring match in the label scores highest (more at a word start); otherwise the letters must appear in order, with bonuses for consecutive letters and word starts. Keyword matches rank slightly lower and are not highlighted.
Is it a drop-in for cmdk?
No. It is a single component fed by an items array, not composable Command.* parts. That keeps it to one file, at the cost of custom item layouts.
Related
Search icon that springs open into a field with a recent-searches dropdown and a / shortcut; Esc or blur folds it back when empty.
A dropdown menu springs open while its items stagger in, with a highlight that glides between items, submenus, checkbox and radio items, and full keyboard navigation.
Keyboard keys and combos in flat, raised 3D, outline, glass, neo and solid styles. Keycaps depress when the real keys are pressed, Mod maps to ⌘ or Ctrl, and onTrigger turns any combo or G-then-T sequence into a working hotkey.
Fully custom single or multi select: outline, filled, ghost, underline, pill and neo triggers; icons, avatars, descriptions and groups; search with match highlight, chips, clear, type-ahead and full keyboard; animated menu that flips up when space runs out.