Skip to content

Command menu

A modal command palette: fuzzy-ranked results, grouped output, active-descendant browsing and a keyboard-first footer.

NavigationadvancedFeaturedsearchpalettecmd-knavigationkeyboard

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.