Command menu
A modal command palette: fuzzy-ranked results, grouped output, active-descendant browsing and a keyboard-first footer.
Live preview
Source
The exact file rendered in the preview above.
'use client'
import { useState } from 'react'
import { Blocks, Component, FileText, Layers, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CommandMenu, type CommandItem } from '@/components/ui/command-menu'
import { EmptyState } from '@/components/ui/empty-state'
import { DemoRow, DemoStage } from './_kit'
const items: CommandItem[] = [
{
id: 'button',
label: 'Button',
description: 'Actions',
group: 'Components',
icon: <Component className="size-4" />,
keywords: ['cta', 'action'],
onSelect: () => {},
},
{
id: 'dialog',
label: 'Dialog',
description: 'Overlay',
group: 'Components',
icon: <Component className="size-4" />,
keywords: ['modal'],
onSelect: () => {},
},
{
id: 'table',
label: 'Table',
description: 'Data',
group: 'Components',
icon: <Component className="size-4" />,
onSelect: () => {},
},
{
id: 'hero',
label: 'Editorial hero',
description: 'Marketing',
group: 'Sections',
icon: <Layers className="size-4" />,
onSelect: () => {},
},
{
id: 'pricing',
label: 'Three-tier pricing',
description: 'Commerce',
group: 'Sections',
icon: <Layers className="size-4" />,
onSelect: () => {},
},
{
id: 'saas',
label: 'SaaS starter',
description: '12 routes',
group: 'Starters',
icon: <Blocks className="size-4" />,
onSelect: () => {},
},
{
id: 'tokens',
label: 'Design tokens',
description: 'Documentation',
group: 'Docs',
icon: <FileText className="size-4" />,
meta: 'Docs',
onSelect: () => {},
},
]
export default function CommandMenuDemo() {
const [open, setOpen] = useState(false)
return (
<DemoStage>
<DemoRow
label="Trigger"
description="Focus stays in the input; arrow keys move the active descendant."
>
<Button
variant="outline"
leadingIcon={<Search className="size-4" />}
onClick={() => setOpen(true)}
>
Open command menu
</Button>
</DemoRow>
<CommandMenu
open={open}
onClose={() => setOpen(false)}
items={items}
placeholder="Search components, sections and docs…"
emptyState={
<EmptyState
appearance="bare"
size="sm"
icon={<Search className="size-5" />}
title="No matches"
description="Try a broader term — search covers names, descriptions and categories."
/>
}
/>
</DemoStage>
)
}
components/demos/command-menu.tsx
'use client'
import { useState } from 'react'
import { Blocks, Component, FileText, Layers, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CommandMenu, type CommandItem } from '@/components/ui/command-menu'
import { EmptyState } from '@/components/ui/empty-state'
import { DemoRow, DemoStage } from './_kit'
const items: CommandItem[] = [
{
id: 'button',
label: 'Button',
description: 'Actions',
group: 'Components',
icon: <Component className="size-4" />,
keywords: ['cta', 'action'],
onSelect: () => {},
},
{
id: 'dialog',
label: 'Dialog',
description: 'Overlay',
group: 'Components',
icon: <Component className="size-4" />,
keywords: ['modal'],
onSelect: () => {},
},
{
id: 'table',
label: 'Table',
description: 'Data',
group: 'Components',
icon: <Component className="size-4" />,
onSelect: () => {},
},
{
id: 'hero',
label: 'Editorial hero',
description: 'Marketing',
group: 'Sections',
icon: <Layers className="size-4" />,
onSelect: () => {},
},
{
id: 'pricing',
label: 'Three-tier pricing',
description: 'Commerce',
group: 'Sections',
icon: <Layers className="size-4" />,
onSelect: () => {},
},
{
id: 'saas',
label: 'SaaS starter',
description: '12 routes',
group: 'Starters',
icon: <Blocks className="size-4" />,
onSelect: () => {},
},
{
id: 'tokens',
label: 'Design tokens',
description: 'Documentation',
group: 'Docs',
icon: <FileText className="size-4" />,
meta: 'Docs',
onSelect: () => {},
},
]
export default function CommandMenuDemo() {
const [open, setOpen] = useState(false)
return (
<DemoStage>
<DemoRow
label="Trigger"
description="Focus stays in the input; arrow keys move the active descendant."
>
<Button
variant="outline"
leadingIcon={<Search className="size-4" />}
onClick={() => setOpen(true)}
>
Open command menu
</Button>
</DemoRow>
<CommandMenu
open={open}
onClose={() => setOpen(false)}
items={items}
placeholder="Search components, sections and docs…"
emptyState={
<EmptyState
appearance="bare"
size="sm"
icon={<Search className="size-5" />}
title="No matches"
description="Try a broader term — search covers names, descriptions and categories."
/>
}
/>
</DemoStage>
)
}
components/ui/command-menu.tsx
'use client'
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { Search, CornerDownLeft, ArrowUp, ArrowDown } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useFocusTrap } from '@/hooks/use-focus-trap'
import { useDismiss, useScrollLock } from '@/hooks/use-dismiss'
import { Portal } from './portal'
/**
* CommandMenu
*
* A modal command palette implementing the combobox-in-a-dialog pattern: the
* dialog owns the modality, the input owns `role="combobox"` and
* `aria-activedescendant`, and the results are a real listbox.
*
* Focus stays in the input for the whole interaction — arrow keys move the
* *active descendant*, not DOM focus — so typing never gets interrupted.
* Every item is selectable with Enter, and the active option is scrolled into
* view without moving the page behind the overlay.
*/
export interface CommandItem {
id: string
label: string
description?: string
group: string
icon?: ReactNode
meta?: string
keywords?: string[]
onSelect: () => void
}
export interface CommandMenuProps {
open: boolean
onClose: () => void
items: CommandItem[]
placeholder?: string
/** Rendered when the query has no matches. */
emptyState?: ReactNode
/** Shown while the query is empty — typically recent or suggested items. */
initialItems?: CommandItem[]
label?: string
footer?: ReactNode
onQueryChange?: (query: string) => void
}
function score(item: CommandItem, query: string): number {
const q = query.toLowerCase()
const label = item.label.toLowerCase()
if (label === q) return 100
if (label.startsWith(q)) return 80
if (label.includes(q)) return 60
if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(q))) return 40
if (item.description?.toLowerCase().includes(q)) return 20
if (item.group.toLowerCase().includes(q)) return 10
return 0
}
export function CommandMenu({
open,
onClose,
items,
placeholder = 'Search…',
emptyState,
initialItems,
label = 'Command menu',
footer,
onQueryChange,
}: CommandMenuProps) {
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const [lastOpen, setLastOpen] = useState(open)
// Resetting during render rather than in an effect avoids a second render
// pass every time the palette opens.
if (lastOpen !== open) {
setLastOpen(open)
setQuery('')
setActiveIndex(0)
}
const panelRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const uid = useId()
useFocusTrap(panelRef, open, { initialFocus: inputRef })
useScrollLock(open)
useDismiss([panelRef], open, onClose)
const results = useMemo(() => {
const trimmed = query.trim()
if (!trimmed) return initialItems ?? items.slice(0, 8)
return items
.map((item) => ({ item, value: score(item, trimmed) }))
.filter((entry) => entry.value > 0)
.sort((a, b) => b.value - a.value || a.item.label.localeCompare(b.item.label))
.slice(0, 40)
.map((entry) => entry.item)
}, [items, initialItems, query])
const grouped = useMemo(() => {
const map = new Map<string, CommandItem[]>()
for (const item of results) {
const bucket = map.get(item.group)
if (bucket) bucket.push(item)
else map.set(item.group, [item])
}
return Array.from(map.entries())
}, [results])
useEffect(() => {
if (!open) return
const active = listRef.current?.querySelector('[data-active="true"]')
active?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, open])
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((current) => (results.length === 0 ? 0 : (current + 1) % results.length))
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((current) =>
results.length === 0 ? 0 : (current - 1 + results.length) % results.length,
)
} else if (event.key === 'Home') {
event.preventDefault()
setActiveIndex(0)
} else if (event.key === 'End') {
event.preventDefault()
setActiveIndex(Math.max(0, results.length - 1))
} else if (event.key === 'Enter') {
event.preventDefault()
const item = results[activeIndex]
if (item) {
item.onSelect()
onClose()
}
}
}
if (!open) return null
let cursor = -1
return (
<Portal>
<div className="fixed inset-0 z-[120] flex items-start justify-center p-4 pt-[10vh]">
<div className="animate-fade-in absolute inset-0 bg-scrim" aria-hidden="true" />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={label}
className="animate-scale-in relative flex max-h-[70vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-line bg-surface-raised shadow-overlay"
>
<div className="flex items-center gap-3 border-b border-line px-4">
<Search className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
<input
ref={inputRef}
type="text"
role="combobox"
autoComplete="off"
spellCheck={false}
aria-expanded
aria-controls={`${uid}-list`}
aria-autocomplete="list"
aria-activedescendant={
results[activeIndex] ? `${uid}-option-${results[activeIndex]?.id}` : undefined
}
value={query}
placeholder={placeholder}
onChange={(event) => {
setQuery(event.target.value)
setActiveIndex(0)
onQueryChange?.(event.target.value)
}}
onKeyDown={onKeyDown}
className="h-12 w-full min-w-0 border-0 bg-transparent text-sm text-ink outline-none placeholder:text-ink-subtle"
/>
<kbd className="hidden shrink-0 rounded-sm border border-line bg-surface-sunken px-1.5 py-0.5 font-mono text-2xs text-ink-subtle sm:block">
Esc
</kbd>
</div>
<div
ref={listRef}
id={`${uid}-list`}
role="listbox"
aria-label="Results"
className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-2"
>
{results.length === 0 ? (
<div className="px-3 py-10">{emptyState}</div>
) : (
grouped.map(([group, groupItems]) => (
<div key={group} className="mb-2 last:mb-0">
<p className="label-caps px-2 py-1.5 text-ink-subtle">{group}</p>
{groupItems.map((item) => {
cursor += 1
const index = cursor
const active = index === activeIndex
return (
<div
key={item.id}
id={`${uid}-option-${item.id}`}
role="option"
aria-selected={active}
data-active={active}
onMouseMove={() => setActiveIndex(index)}
onClick={() => {
item.onSelect()
onClose()
}}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md px-2.5 py-2 text-sm',
active && 'bg-accent-soft text-accent-soft-ink',
)}
>
{item.icon ? (
<span className={cn('shrink-0', active ? '' : 'text-ink-subtle')}>
{item.icon}
</span>
) : null}
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{item.label}</span>
{item.description ? (
<span
className={cn(
'block truncate text-xs',
active ? 'opacity-80' : 'text-ink-muted',
)}
>
{item.description}
</span>
) : null}
</span>
{item.meta ? (
<span
className={cn(
'shrink-0 font-mono text-2xs',
active ? 'opacity-80' : 'text-ink-subtle',
)}
>
{item.meta}
</span>
) : null}
</div>
)
})}
</div>
))
)}
</div>
<div className="flex items-center justify-between gap-3 border-t border-line bg-surface-sunken px-4 py-2">
<div className="flex items-center gap-3 text-2xs text-ink-subtle">
<span className="flex items-center gap-1">
<ArrowUp className="size-3" aria-hidden="true" />
<ArrowDown className="size-3" aria-hidden="true" />
Navigate
</span>
<span className="flex items-center gap-1">
<CornerDownLeft className="size-3" aria-hidden="true" />
Open
</span>
</div>
{footer}
</div>
</div>
</div>
</Portal>
)
}
hooks/use-focus-trap.ts
'use client'
import { useEffect, type RefObject } from 'react'
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable="true"]',
].join(',')
export function getFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el.getClientRects().length > 0,
)
}
/**
* Traps Tab focus inside `containerRef` while `active`, and returns focus to
* whatever was focused before activation on teardown.
*
* Both halves matter: trapping without restoring strands keyboard users at the
* top of the document every time a dialog closes.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
options: { initialFocus?: RefObject<HTMLElement | null>; returnFocus?: boolean } = {},
): void {
const { initialFocus, returnFocus = true } = options
useEffect(() => {
if (!active) return
const container = containerRef.current
if (!container) return
const previouslyFocused = document.activeElement as HTMLElement | null
const focusFirst = () => {
const target = initialFocus?.current ?? getFocusable(container)[0] ?? container
target.focus({ preventScroll: true })
}
// Defer one frame so the element is painted and measurable before focusing.
const raf = requestAnimationFrame(focusFirst)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') return
const focusable = getFocusable(container)
if (focusable.length === 0) {
event.preventDefault()
container.focus({ preventScroll: true })
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (!first || !last) return
const activeEl = document.activeElement
if (event.shiftKey && (activeEl === first || !container.contains(activeEl))) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && activeEl === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown, true)
return () => {
cancelAnimationFrame(raf)
document.removeEventListener('keydown', onKeyDown, true)
if (returnFocus && previouslyFocused && document.contains(previouslyFocused)) {
previouslyFocused.focus({ preventScroll: true })
}
}
}, [active, containerRef, initialFocus, returnFocus])
}
hooks/use-dismiss.ts
'use client'
import { useEffect, type RefObject } from 'react'
/**
* Closes a layer on Escape or on a pointer press outside it.
*
* `pointerdown` is used rather than `click` so that dragging a text selection
* out of a popover does not dismiss it, and Escape is captured on the document
* so it works even when focus has moved into a nested portal.
*/
export function useDismiss(
refs: Array<RefObject<HTMLElement | null>>,
active: boolean,
onDismiss: () => void,
options: { escape?: boolean; outside?: boolean } = {},
): void {
const { escape = true, outside = true } = options
useEffect(() => {
if (!active) return
const onKeyDown = (event: KeyboardEvent) => {
if (!escape) return
if (event.key === 'Escape') {
event.stopPropagation()
onDismiss()
}
}
const onPointerDown = (event: PointerEvent) => {
if (!outside) return
const target = event.target as Node | null
if (!target) return
const inside = refs.some((ref) => ref.current?.contains(target))
if (!inside) onDismiss()
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('pointerdown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('pointerdown', onPointerDown)
}
// `refs` is a stable-length array supplied by the caller at each layer.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, escape, outside, onDismiss])
}
/** Locks body scroll while a modal layer is open, without layout shift. */
export function useScrollLock(active: boolean): void {
useEffect(() => {
if (!active) return
const { body, documentElement } = document
const scrollBarWidth = window.innerWidth - documentElement.clientWidth
const previousOverflow = body.style.overflow
const previousPadding = body.style.paddingRight
body.style.overflow = 'hidden'
if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`
return () => {
body.style.overflow = previousOverflow
body.style.paddingRight = previousPadding
}
}, [active])
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The keyboard entry point to the whole catalogue. Foundry’s global search is this component wired to the catalogue index — see `/search` for the full-page equivalent.
- Ranking is deterministic: exact match, prefix, substring, keyword, description, then group.
- Results are grouped by family so a query spanning components and starters stays readable.
- The active option is scrolled into view with `block: "nearest"`, which never moves the page behind the overlay.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Query results grouped by family
- Initial suggestions when empty
- Custom empty state
- Keyboard footer
Accessibility
- Combobox in a dialog
- The dialog owns modality; the input owns `role="combobox"` and `aria-activedescendant`; results are a real listbox.
- Focus
- Focus stays in the input for the whole interaction and returns to the trigger on close.
- Keyboard
- Arrows browse, Home and End jump, Enter opens, Escape closes.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All componentsCombobox
A filterable single-select implementing the ARIA 1.2 combobox pattern, with active-descendant browsing that never moves focus out of the input.
advancedFeatured5 variantsDialog
A modal with focus trapping, scroll locking, Escape dismissal and focus return — in four sizes.
advancedFeatured5 variantsSearch field
A search input with a keyboard-reachable clear button and an optional shortcut hint.
starter4 variants