Command Palette
Find app actions and destinations from a keyboard shortcut, with nested pages and key hints.
import { CommandPalette, CommandPalettePage, CommandPaletteState } from '@youj/react/command'
Installation & usage
npm i @youj/react cmdkFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Requires cmdk, an optional dependency. Import it from @youj/react/command.
Use a command palette to put every action and destination in the app one shortcut away, for people who live on the keyboard. It isn't the only way to reach anything: each command should also have a visible home in the interface. For a searchable list inside a page, a picker or a filter, use Command on its own.
import { CommandGroup, CommandItem, CommandPalette } from '@youj/react/command'
export function AppPalette() {
return (
<CommandPalette>
{({ page, push, close }) =>
page?.id === 'projects' ? (
<ProjectRows onOpen={close} />
) : (
<CommandGroup heading="Suggestions">
<CommandItem onSelect={() => push({ id: 'projects', label: 'Projects', placeholder: 'Search projects…' })}>
Switch project…
</CommandItem>
<CommandItem onSelect={() => { deploy(); close() }}>Deploy to production</CommandItem>
</CommandGroup>
)
}
</CommandPalette>
)
}Render it once, near the root of the app. It's a CommandDialog with pages: the rows are yours, built from Command's parts (CommandGroup, CommandItem, CommandShortcut, CommandLoading…), and children can be a function of the palette's state. shortcut sets the key that toggles it with ⌘ or Ctrl from anywhere on the page. It's k by default, and false turns it off. The shortcut steps aside when something else on the page has already handled the keystroke.
Description
A palette with grouped rows, shortcuts and project rows that open a second page of actions.
Choosing a team member opens a page of roles, and choosing a role steps back to the team.
A row can open a page with push({ id, label, placeholder }). The page's label joins a breadcrumb under the search field and names the field for screen readers. Each page starts with an empty search and its first row active. Backspace on an empty search, or Escape, steps back one page. The last Escape closes the palette, and the next time it opens, it starts from the first page.
Rows in their own components reach the stack with useCommandPalette(), which returns the same state as the render function: page, pages, search, push, pop and close. pop() steps back one page, and pop(0) returns to the first.
Here, choosing a role steps back to the team with pop(), so several people can be changed in one visit. checked on a row draws a check for the current value.
A search page that shows a loading row while it waits for issues, then the results or an empty message.
The render function receives the live search, so a page can fetch results as the user types. Show a CommandLoading row while the request runs and a CommandEmpty once the answer is in. Pass shouldFilter={false} when the results arrive already matched, or pass search and onSearchChange to control the query yourself.
Code
command-palette-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propschildren, open, defaultOpen… (13)
Props
| Prop | Type | |
|---|---|---|
open | boolean | – |
defaultOpen | booleandefault false | false |
onOpenChange | (open: boolean) => void | – |
shortcut | string | falseA key that toggles the palette with ⌘ (Ctrl elsewhere) from anywhere on the page. default 'k' | 'k' |
placeholder | stringThe first page's placeholder. | – |
title | stringThe dialog's accessible name. default "Command palette" | "Command palette" |
description | stringDescribes the dialog for screen readers. Default: the built-in "Search for a command to run" (a label, so translated with the rest). | – |
search | stringControlled search, e.g. to fetch results as the user types. | – |
onSearchChange | (search: string) => void | – |
shouldFilter | booleanTurn off cmdk's filtering when results arrive already matched. | – |
autoFocusField | booleanWhether the search field takes focus on open. By default it does from a
keyboard or a mouse and not from a touch (the list shows first). Pass true
when a search button opens it to type in; on iOS also call
| – |
className | string | – |
childrenRequired | ReactNode | ((state: CommandPaletteState) => ReactNode)The rows for the current page, or a function of the palette's state. | – |
useCommandPalette() returns CommandPaletteState and throws outside a palette. CommandPalettePage is { id: string; label: string; placeholder?: string }.
TouchOn a touch device the palette is a tall bottom sheet
Touch
On a touch device the palette is a tall bottom sheet. The search field is at the top, rows are 48px, and the footer of keyboard hints is gone. On a page, the breadcrumb is how you go back (its crumbs are big enough to tap). Pulling the sheet down closes the palette from any page, while Escape still steps back one page first. While the on-screen keyboard is up the sheet sits on top of it, so the search field stays visible.
The palette opens with the list in view and the keyboard down. If a search button opens it to type in, pass autoFocusField, and on iOS call primeKeyboard() from @youj/react in the button's tap handler before you open it. iOS only raises the keyboard for a focus that happens during the tap itself.
import { primeKeyboard } from '@youj/react'
import { CommandPalette } from '@youj/react/command'
<button
onClick={() => {
primeKeyboard()
setOpen(true)
}}
>
Search
</button>
<CommandPalette open={open} onOpenChange={setOpen} autoFocusField>
…
</CommandPalette>MotionOpen, close, backdrop… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
Openrises and scales in from the top third (yj-dialog) | duration.baseease.enter220ms · cubic-bezier(0.16, 1, 0.3, 1) | 220mscubic-bezier(0.16, 1, 0.3, 1) | |
| Close | duration.fastease.exit140ms · cubic-bezier(0.4, 0, 1, 1) | 140mscubic-bezier(0.4, 0, 1, 1) | |
Backdropfades in behind it (yj-backdrop) | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) | |
| Active rowglides as you arrow through, and across page changes | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 | |
| List heighteases between pages and result sets | duration.baseease.standard220ms · cubic-bezier(0.2, 0, 0, 1) | 220mscubic-bezier(0.2, 0, 0, 1) |
Reduced motion
The palette opens and closes at once, the highlight jumps and the list snaps to each new height. Pages, the breadcrumb and every key work the same.
The palette hangs from the top third of the screen, so the search field never moves while the list below it grows and shrinks between pages.
Reduced motion
The dialog appears and disappears without rising or fading, the active row's highlight jumps, and the list snaps between heights. Nothing about pages, search or the keys changes.
AccessibilityKeyboard, focus, announcements
Accessibility
- It's a modal dialog named by
title("Command palette" by default). Focus goes to the search field when it opens and returns to where it was when it closes. - The search field is a combobox over a listbox, as in Command. On a page, the field is named after the page.
- Changing page is announced ("Projects page"), and the breadcrumb is for the pointer:
BackspaceandEscapewalk it from the keyboard. - The footer spells out the keys, and
Escapereads Back while there's a page to go back to.
| Key | Action |
|---|---|
⌘K / Ctrl+K | Toggles the palette, or the key set by shortcut. |
↑ / ↓ | Moves the active row. |
Enter | Runs the active row, or opens its page. |
Backspace | On an empty search, steps back one page. |
Escape | Steps back one page, then closes the palette. |