Command palette
A searchable palette of actions, opened with Ctrl+K or Cmd+K. It loads on its first open, never with the page.
Command palette
client - size not measured yet, lazyA searchable palette of actions, opened with Ctrl+K or Cmd+K. It loads on its first open, never with the page.
Try it
The code
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
import type { CommandDesignProps } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'button', label: 'Button' },
{ id: 'dialog', label: 'Dialog' },
{ id: 'drawer', label: 'Drawer' },
{ id: 'popover', label: 'Popover' },
{ id: 'tabs', label: 'Tabs' },
{ id: 'accordion', label: 'Accordion' },
{ id: 'table', label: 'Table' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'breadcrumb', label: 'Breadcrumb' },
{ id: 'tooltip', label: 'Tooltip' },
]
/** The docs stage: the basic palette, with the schema's props passed through, so the Try it
* controls drive it. defaultOpen is where the open state starts; the Button opens it too. */
export default function Demo({ visibleRows, virtualize, shortcut, defaultOpen }: CommandDesignProps = {}) {
const [open, setOpen] = useState(defaultOpen ?? false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />Search</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
visibleRows={visibleRows}
virtualize={virtualize}
shortcut={shortcut}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
getKey, getLabel and onSelect are functions, so the palette is configured inside a client file.
Every state
6 statesImport from the package
import { Command } from 'topclassui/command'Install once:npm i topclassui@beta zod
Examples
Commandis the whole palette; its parts are not exported.- Open it with
openandonOpenChangefrom your button, or leave both out and letshortcutopen it. - Before its first open it renders nothing and has loaded no Base UI; it stays mounted after that.
- Every word of the query matches anywhere in
getLabel, in any case, and the order ofitemsis kept. getGroupreturns a heading, andrenderItemdraws a row that stays one line tall.virtualizeis'auto'(windowing above 200 rows),trueorfalse;visibleRowssets how many rows show.
Nothing ran yet.
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'button', label: 'Button' },
{ id: 'button-group', label: 'Button group' },
{ id: 'icon-button', label: 'Icon button' },
{ id: 'dialog', label: 'Dialog' },
{ id: 'drawer', label: 'Drawer' },
{ id: 'popover', label: 'Popover' },
{ id: 'tabs', label: 'Tabs' },
{ id: 'accordion', label: 'Accordion' },
{ id: 'table', label: 'Table' },
{ id: 'pagination', label: 'Pagination' },
{ id: 'breadcrumb', label: 'Breadcrumb' },
{ id: 'tooltip', label: 'Tooltip' },
]
/** The docs sample: a Button opens it, and so does Ctrl+K or Cmd+K. What ran is written below,
* with how many times, so a test can tell that Enter ran it once. */
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState({ label: '', times: 0 })
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />Search</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={(command) => { setRan((before) => ({ label: command.label, times: before.times + 1 })) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran.times === 0 ? 'Nothing ran yet.' : `Ran ${ran.label}, ${String(ran.times)} in all.`}</p>
</div>
)
}
Nothing ran yet.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, group: string }
const COMMANDS: Entry[] = [
{ id: 'new-file', label: 'New file', group: 'Files' },
{ id: 'open-recent', label: 'Open recent', group: 'Files' },
{ id: 'theme', label: 'Change the theme', group: 'Preferences' },
{ id: 'save', label: 'Save all', group: 'Files' },
{ id: 'shortcuts', label: 'Keyboard shortcuts', group: 'Preferences' },
{ id: 'docs', label: 'Read the docs', group: 'Help' },
{ id: 'report', label: 'Report a problem', group: 'Help' },
]
/** Three headings. A group stands where its first command stood, and keeps its commands in order,
* so Save all joins New file and Open recent under Files. */
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Commands by group</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.group}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran === '' ? 'Nothing ran yet.' : `Ran ${ran}.`}</p>
</div>
)
}
'use client'
import { useState } from 'react'
import { downloadSimple } from 'topclassui/icons/download-simple'
import { fileText } from 'topclassui/icons/file-text'
import { gear } from 'topclassui/icons/gear'
import { Button } from 'topclassui/button'
import { Kbd } from 'topclassui/kbd'
import { Svg } from 'topclassui/svg'
import type { IconData } from 'topclassui/icons'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, icon: IconData, keys: string }
const COMMANDS: Entry[] = [
{ id: 'new', label: 'New document', icon: fileText, keys: 'Ctrl N' },
{ id: 'save', label: 'Download a copy', icon: downloadSimple, keys: 'Ctrl S' },
{ id: 'settings', label: 'Settings', icon: gear, keys: 'Ctrl ,' },
]
/** Each row drawn by renderItem: an icon, the label, and the shortcut at the end. The row stays one
* row tall, the label still truncates, and the query still matches the label alone. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Commands with shortcuts</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
renderItem={(command) => (
<>
<Svg icon={command.icon} />
<span className="min-w-0 flex-1 truncate">{command.label}</span>
<Kbd>{command.keys}</Kbd>
</>
)}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'invite', label: 'Invite a teammate' },
{ id: 'billing', label: 'Open billing' },
]
/** A query that matches nothing shows the empty text of the labels, No results, under the input.
* The list itself is gone then, so no empty listbox is left for a reader to land in. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Find a command</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const NONE: Entry[] = []
/** The commands are still on their way: status loading, with no items, says Loading where the
* empty text would be, and the same live region announces it. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Commands that are loading</Button>
<Command
items={NONE}
status="loading"
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
Nothing ran yet.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
const AREAS = ['Billing', 'Contacts', 'Documents', 'Files', 'Invoices', 'Messages', 'Projects', 'Reports', 'Settings', 'Tasks']
/** A thousand commands under ten headings, a hundred each: past the threshold, so the list is
* windowed, and a heading that scrolls away still names the rows of its group. */
const commands = Array.from({ length: 1000 }, (_, index) => {
const area = AREAS[Math.floor(index / 100)] ?? ''
return { id: `t${String(index + 1)}`, area, label: `${area} action ${String((index % 100) + 1)}` }
})
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>A thousand commands</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.area}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran === '' ? 'Nothing ran yet.' : `Ran ${ran}.`}</p>
</div>
)
}
Nothing ran yet.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
const VERBS = ['Open', 'Close', 'Rename', 'Archive', 'Share', 'Export', 'Duplicate', 'Pin']
const NOUNS = ['report', 'invoice', 'project', 'contract', 'draft', 'ticket', 'board', 'note', 'folder', 'page']
/** Ten thousand commands in one flat list. Windowing mounts only the rows in view, so typing and
* scrolling stay quick; the last one is "Pin page 10000". */
const commands = Array.from({ length: 10_000 }, (_, index) => ({
id: `c${String(index + 1)}`,
label: `${VERBS[index % VERBS.length] ?? ''} ${NOUNS[Math.floor(index / VERBS.length) % NOUNS.length] ?? ''} ${String(index + 1)}`,
}))
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Ten thousand commands</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran === '' ? 'Nothing ran yet.' : `Ran ${ran}.`}</p>
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, group: string }
const WORD = 'Unbreakable'.repeat(27).slice(0, 300)
/** Sixty groups of two, a heading of 300 characters with no space in it, a row of the same, and an
* element where the empty text would be. Nothing widens the palette or the page: rows and headings
* truncate, and the fallback wraps anywhere. */
const commands: Entry[] = [
{ id: 'word', label: WORD, group: WORD },
...Array.from({ length: 120 }, (_, index) => ({
id: `row-${String(index + 1)}`,
label: `Row ${String(index + 1)} of group ${String(Math.floor(index / 2) + 1)}`,
group: `Group ${String(Math.floor(index / 2) + 1)}`,
})),
]
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Every hostile case</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.group}
emptyFallback={<span>Nothing matches <strong>{WORD}</strong></span>}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
Section 1. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 2. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 3. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 4. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 5. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 6. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 7. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 8. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 9. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 10. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 11. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 12. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 13. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 14. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 15. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 16. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 17. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 18. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 19. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 20. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 21. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 22. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 23. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 24. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 25. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 26. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 27. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 28. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 29. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 30. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 31. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 32. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 33. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 34. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 35. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 36. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 37. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 38. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 39. The page goes on below the palette, so a scroll that escaped it would move this text.
Section 40. The page goes on below the palette, so a scroll that escaped it would move this text.
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const commands: Entry[] = Array.from({ length: 60 }, (_, index) => ({
id: `section-${String(index + 1)}`,
label: `Go to section ${String(index + 1)}`,
}))
const SECTIONS = Array.from({ length: 40 }, (_, index) => index + 1)
/** Sixty rows over a page far taller than the screen. The list scrolls inside the palette; the
* page behind stays where it was, under a wheel, a finger and the keyboard alike. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Jump to a section</Button>
<Command
items={commands}
getKey={(command) => command.id}
getLabel={(command) => command.label}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
{SECTIONS.map((section) => (
<p key={section} className="text-sm text-muted">
Section {section}. The page goes on below the palette, so a scroll that escaped it would move this text.
</p>
))}
</div>
)
}
'use client'
import { useState } from 'react'
import { Button } from 'topclassui/button'
import { TooltipPopup, TooltipRoot, TooltipTrigger } from 'topclassui/tooltip'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string }
const COMMANDS: Entry[] = [
{ id: 'invite', label: 'Invite a teammate' },
{ id: 'billing', label: 'Open billing' },
]
/** An empty text that holds an action with a tooltip. One Escape closes one thing: while the
* tooltip is open the key closes it and the palette stays; the next one closes the palette. */
export default function Demo() {
const [open, setOpen] = useState(false)
return (
<div className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}>Find or ask</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
emptyFallback={
<span className="grid justify-items-center gap-2">
No command by that name.
<TooltipRoot>
<TooltipTrigger render={<Button variant="outline">Ask for one</Button>} />
<TooltipPopup>Sends the query to the team</TooltipPopup>
</TooltipRoot>
</span>
}
onSelect={() => undefined}
open={open}
onOpenChange={setOpen}
/>
</div>
)
}
'use client'
import { useState } from 'react'
import { magnifyingGlass } from 'topclassui/icons/magnifying-glass'
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import { Svg } from 'topclassui/svg'
import { Command } from 'topclassui/command'
interface Entry { id: string, label: string, group: string }
const COMMANDS: Entry[] = [
{ id: 'new', label: 'ملف جديد', group: 'الملفات' },
{ id: 'recent', label: 'فتح ملف حديث', group: 'الملفات' },
{ id: 'theme', label: 'تغيير المظهر', group: 'التفضيلات' },
{ id: 'language', label: 'تغيير اللغة', group: 'التفضيلات' },
{ id: 'docs', label: 'قراءة الدليل', group: 'المساعدة' },
]
/** Arabic, inside TopClassProvider as the root layout would mount it. The palette is portaled into
* the body, so it takes its direction and its language from the provider: the glass sits at the
* right of the input, the rows are right-aligned and the placeholder is Arabic, on an English page too. */
export default function Demo() {
const [open, setOpen] = useState(false)
const [ran, setRan] = useState('')
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="grid justify-items-start gap-3">
<Button variant="outline" onClick={() => { setOpen(true) }}><Svg icon={magnifyingGlass} />بحث</Button>
<Command
items={COMMANDS}
getKey={(command) => command.id}
getLabel={(command) => command.label}
getGroup={(command) => command.group}
onSelect={(command) => { setRan(command.label) }}
open={open}
onOpenChange={setOpen}
/>
<p data-testid="ran" className="text-sm text-muted">{ran}</p>
</div>
</TopClassProvider>
)
}
Props
Keyboard and accessibility
- The input is a combobox over a listbox, with
aria-activedescendanton the active row. - With no match the list is removed and the empty text shows, so no empty listbox is left to land in.
- Home and End stay with the input, for its caret.
Right to left
The glass sits at the right of the input and the rows align right. Ctrl+K also works under an Arabic keyboard layout, because the key is matched by its place too.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet, lazy.