Command-driven navigation
Almost no chrome — a brand, a visible ⌘K affordance, and a palette that is the real navigation.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Command, Blocks, Component, FileText, Layers } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { CommandMenu, type CommandItem } from '@/components/ui/command-menu'
import { EmptyState } from '@/components/ui/empty-state'
import { Search } from 'lucide-react'
/**
* Command-driven navigation
*
* Almost no chrome: a brand, one keyboard affordance, and a palette that is
* the real navigation. Suited to tools whose users live on the keyboard.
*
* The visible ⌘K button matters — a keyboard-only entry point is undiscoverable
* for everyone who does not already know the shortcut exists.
*/
const destinations: CommandItem[] = [
{
id: 'components',
label: 'Components',
description: '45 primitives',
group: 'Catalogue',
icon: <Component className="size-4" />,
onSelect: () => {},
},
{
id: 'sections',
label: 'Sections',
description: 'Composable page blocks',
group: 'Catalogue',
icon: <Layers className="size-4" />,
onSelect: () => {},
},
{
id: 'starters',
label: 'Starters',
description: 'Complete products',
group: 'Catalogue',
icon: <Blocks className="size-4" />,
onSelect: () => {},
},
{
id: 'tokens',
label: 'Design tokens',
description: 'The full manifest',
group: 'Documentation',
icon: <FileText className="size-4" />,
onSelect: () => {},
},
{
id: 'a11y',
label: 'Accessibility',
description: 'Contracts and testing',
group: 'Documentation',
icon: <FileText className="size-4" />,
onSelect: () => {},
},
]
export default function CommandDrivenNavigation() {
const [open, setOpen] = useState(false)
return (
<div className="w-full">
<div className="border-b border-line bg-surface">
<div className="mx-auto flex h-14 w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2 text-ink-strong">
<BrandMark className="size-5 text-accent" />
<span className="display-type text-md font-semibold">Foundry</span>
</Link>
<button
type="button"
onClick={() => setOpen(true)}
aria-keyshortcuts="Meta+K Control+K"
className="ml-auto flex h-8 items-center gap-2 rounded-md border border-line bg-surface-sunken px-2.5 text-sm text-ink-subtle transition-colors hover:border-line-strong"
>
<Command className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">Jump to…</span>
<kbd className="rounded-sm border border-line bg-surface px-1.5 py-0.5 font-mono text-2xs">
⌘K
</kbd>
</button>
</div>
</div>
<div className="mx-auto w-full max-w-5xl px-4 py-12 sm:px-6">
<p className="label-caps text-ink-subtle">Command-driven</p>
<h2 className="display-type mt-2 text-2xl font-semibold text-ink-strong">
The palette is the navigation.
</h2>
<p className="mt-3 max-w-prose text-sm text-ink-muted">
Keep a visible trigger even when the shortcut is the point — an entry point nobody can see
is an entry point most people never use.
</p>
</div>
<CommandMenu
open={open}
onClose={() => setOpen(false)}
items={destinations}
label="Jump to"
placeholder="Jump to a destination…"
emptyState={
<EmptyState
appearance="bare"
size="sm"
icon={<Search className="size-5" />}
title="Nothing matches"
description="Try a section name such as “tokens” or “starters”."
/>
}
/>
</div>
)
}
components/blocks/navigation/command-driven.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Command, Blocks, Component, FileText, Layers } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { CommandMenu, type CommandItem } from '@/components/ui/command-menu'
import { EmptyState } from '@/components/ui/empty-state'
import { Search } from 'lucide-react'
/**
* Command-driven navigation
*
* Almost no chrome: a brand, one keyboard affordance, and a palette that is
* the real navigation. Suited to tools whose users live on the keyboard.
*
* The visible ⌘K button matters — a keyboard-only entry point is undiscoverable
* for everyone who does not already know the shortcut exists.
*/
const destinations: CommandItem[] = [
{
id: 'components',
label: 'Components',
description: '45 primitives',
group: 'Catalogue',
icon: <Component className="size-4" />,
onSelect: () => {},
},
{
id: 'sections',
label: 'Sections',
description: 'Composable page blocks',
group: 'Catalogue',
icon: <Layers className="size-4" />,
onSelect: () => {},
},
{
id: 'starters',
label: 'Starters',
description: 'Complete products',
group: 'Catalogue',
icon: <Blocks className="size-4" />,
onSelect: () => {},
},
{
id: 'tokens',
label: 'Design tokens',
description: 'The full manifest',
group: 'Documentation',
icon: <FileText className="size-4" />,
onSelect: () => {},
},
{
id: 'a11y',
label: 'Accessibility',
description: 'Contracts and testing',
group: 'Documentation',
icon: <FileText className="size-4" />,
onSelect: () => {},
},
]
export default function CommandDrivenNavigation() {
const [open, setOpen] = useState(false)
return (
<div className="w-full">
<div className="border-b border-line bg-surface">
<div className="mx-auto flex h-14 w-full max-w-5xl items-center gap-4 px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2 text-ink-strong">
<BrandMark className="size-5 text-accent" />
<span className="display-type text-md font-semibold">Foundry</span>
</Link>
<button
type="button"
onClick={() => setOpen(true)}
aria-keyshortcuts="Meta+K Control+K"
className="ml-auto flex h-8 items-center gap-2 rounded-md border border-line bg-surface-sunken px-2.5 text-sm text-ink-subtle transition-colors hover:border-line-strong"
>
<Command className="size-3.5" aria-hidden="true" />
<span className="hidden sm:inline">Jump to…</span>
<kbd className="rounded-sm border border-line bg-surface px-1.5 py-0.5 font-mono text-2xs">
⌘K
</kbd>
</button>
</div>
</div>
<div className="mx-auto w-full max-w-5xl px-4 py-12 sm:px-6">
<p className="label-caps text-ink-subtle">Command-driven</p>
<h2 className="display-type mt-2 text-2xl font-semibold text-ink-strong">
The palette is the navigation.
</h2>
<p className="mt-3 max-w-prose text-sm text-ink-muted">
Keep a visible trigger even when the shortcut is the point — an entry point nobody can see
is an entry point most people never use.
</p>
</div>
<CommandMenu
open={open}
onClose={() => setOpen(false)}
items={destinations}
label="Jump to"
placeholder="Jump to a destination…"
emptyState={
<EmptyState
appearance="bare"
size="sm"
icon={<Search className="size-5" />}
title="Nothing matches"
description="Try a section name such as “tokens” or “starters”."
/>
}
/>
</div>
)
}
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>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
For tools whose users live on the keyboard. The visible trigger is the part that matters: a keyboard-only entry point is undiscoverable to everyone who does not already know it exists.
- `aria-keyshortcuts` on the trigger advertises the binding to assistive tech.
- Group destinations so the palette stays readable once the catalogue grows.
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.
- Visible trigger
- Grouped destinations
- Empty state
Accessibility
- Discoverability
- A visible button duplicates the shortcut, so the feature is not keyboard-expert-only.
- Palette contract
- Modality, focus and dismissal are handled by the CommandMenu primitive.
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 navigationCommand menu
A modal command palette: fuzzy-ranked results, grouped output, active-descendant browsing and a keyboard-first footer.
advancedFeatured4 variantsMinimal navbar
Wordmark, four links, one action — with an in-flow mobile disclosure rather than a drawer.
starterFeatured3 variantsSearch field
A search input with a keyboard-reachable clear button and an optional shortcut hint.
starter4 variants