A searchable list of actions, inline or as a ⌘K palette, with pages, shortcuts and highlighted matches.
import {
IconCalculator,
IconCalendar,
IconCreditCard,
IconMoodSmile,
IconSettings,
IconUser,
} from "@tabler/icons-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
export function CommandDemo() {
return (
<Command highlight className="w-full max-w-sm">
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>
<IconCalendar />
Calendar
</CommandItem>
<CommandItem>
<IconMoodSmile />
Search Emoji
</CommandItem>
<CommandItem disabled>
<IconCalculator />
Calculator
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem shortcut="mod+p">
<IconUser />
Profile
</CommandItem>
<CommandItem shortcut="mod+b">
<IconCreditCard />
Billing
</CommandItem>
<CommandItem shortcut="mod+,">
<IconSettings />
Settings
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/command.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cmdk cn - 3
Copy and paste the following code into your project.
components/ui/command.tsx "use client" import * as React from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { IconChevronLeft, IconSearch, IconX } from "@tabler/icons-react" import { Command as CommandPrimitive, useCommandState } from "cmdk" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { formatHotkey, matchesHotkey, useIsApple } from "@/lib/hotkey" import { Button } from "@/components/ui/button" import { easeOut, prefersReducedMotion } from "@/lib/motion" type CommandPageEntry = { id: string; title: string } type CommandContextValue = { search: string setSearch: (search: string) => void pages: CommandPageEntry[] direction: 1 | -1 pushPage: (id: string, title?: string) => void popPage: (count?: number) => void registerShortcut: (hotkey: string, run: () => void) => () => void takeKeyboardSelect: () => { fromKeyboard: boolean; newTab: boolean } } const CommandContext = React.createContext<CommandContextValue | null>(null) const CommandPageContext = React.createContext<string | null>(null) function useCommandContext(part: string) { const context = React.useContext(CommandContext) if (!context) { throw new Error(`<${part}> must be used within <Command>.`) } return context } function useCommandPages() { const { pages, pushPage, popPage } = useCommandContext("useCommandPages") return { pages, page: pages.at(-1)?.id ?? null, push: pushPage, pop: popPage, reset: () => popPage(pages.length), } } function usePageVisible() { const context = React.useContext(CommandContext) const page = React.useContext(CommandPageContext) const current = context?.pages.at(-1)?.id ?? null return page === current } function subscribeNothing() { return () => {} } function isEditable(target: EventTarget | null) { return ( target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select, [contenteditable='true']") !== null) ) } const internalEvents = new WeakSet<Event>() const highlightName = "command-match" const highlightRanges = new Map<string, Range[]>() let highlightSheet: CSSStyleSheet | null = null function adoptHighlightStyle() { if ( highlightSheet || typeof CSSStyleSheet === "undefined" || !("adoptedStyleSheets" in document) ) { return } highlightSheet = new CSSStyleSheet() highlightSheet.replaceSync( `::highlight(${highlightName}) { color: var(--foreground); }` ) document.adoptedStyleSheets = [...document.adoptedStyleSheets, highlightSheet] } function publishHighlights() { if (typeof CSS === "undefined" || !("highlights" in CSS)) { return } adoptHighlightStyle() const ranges = [...highlightRanges.values()].flat() if (ranges.length === 0) { CSS.highlights.delete(highlightName) } else { CSS.highlights.set(highlightName, new Highlight(...ranges)) } } function matchIndices(text: string, query: string) { const haystack = text.toLowerCase() const needle = query.toLowerCase() const start = haystack.indexOf(needle) if (start !== -1) { return Array.from({ length: needle.length }, (_, index) => start + index) } const indices: number[] = [] let from = 0 for (const char of needle) { if (char === " ") { continue } const found = haystack.indexOf(char, from) if (found === -1) { return [] } indices.push(found) from = found + 1 } return indices } function highlightItem(item: HTMLElement, query: string) { const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT, { acceptNode: (node) => node.parentElement?.closest( "[data-slot=command-shortcut], svg, [aria-hidden=true]" ) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, }) const nodes: { node: Text; start: number }[] = [] let text = "" for (let node = walker.nextNode(); node; node = walker.nextNode()) { nodes.push({ node: node as Text, start: text.length }) text += node.textContent ?? "" } const ranges: Range[] = [] for (const index of matchIndices(text, query.trim())) { const owner = nodes.findLast((entry) => entry.start <= index) if (!owner) { continue } const range = document.createRange() range.setStart(owner.node, index - owner.start) range.setEnd(owner.node, index - owner.start + 1) ranges.push(range) } return ranges } function CommandHighlighter() { const id = React.useId() const search = useCommandState((state) => state.search) const count = useCommandState((state) => state.filtered.count) const anchorRef = React.useRef<HTMLSpanElement>(null) React.useLayoutEffect(() => { const root = anchorRef.current?.closest<HTMLElement>("[cmdk-root]") if (!root) { return } const query = search.trim() root.toggleAttribute("data-highlighting", query !== "") highlightRanges.set( id, query ? Array.from(root.querySelectorAll<HTMLElement>("[cmdk-item]")).flatMap( (item) => highlightItem(item, query) ) : [] ) publishHighlights() }, [count, id, search]) React.useEffect( () => () => { highlightRanges.delete(id) publishHighlights() }, [id] ) return <span ref={anchorRef} hidden /> } function defaultFormatResults(count: number) { if (count === 0) { return "No results" } return count === 1 ? "1 result" : `${count} results` } function CommandAnnouncer({ format, pageTitle, }: { format: (count: number) => string pageTitle: string }) { const search = useCommandState((state) => state.search) const count = useCommandState((state) => state.filtered.count) const [message, setMessage] = React.useState("") const [previousPage, setPreviousPage] = React.useState(pageTitle) const [pageMessage, setPageMessage] = React.useState("") if (pageTitle !== previousPage) { setPreviousPage(pageTitle) setPageMessage(pageTitle) } React.useEffect(() => { const timer = setTimeout( () => setMessage(search ? format(count) : ""), search ? 500 : 0 ) return () => clearTimeout(timer) }, [count, format, search]) return ( <span data-slot="command-announcer" role="status" aria-live="polite" aria-atomic className="sr-only" > {message || pageMessage} </span> ) } type CommandProps = React.ComponentProps<typeof CommandPrimitive> & { formatResults?: (count: number) => string highlight?: boolean rootTitle?: string } function Command({ className, children, label = "Command menu", formatResults = defaultFormatResults, highlight = false, rootTitle = "All commands", onKeyDown, ...props }: CommandProps) { const [search, setSearch] = React.useState("") const [pages, setPages] = React.useState<CommandPageEntry[]>([]) const [direction, setDirection] = React.useState<1 | -1>(1) const shortcutsRef = React.useRef( new Map<symbol, { hotkey: string; run: () => void }>() ) const enterRef = React.useRef({ at: -Infinity, newTab: false }) const pushPage = React.useCallback((id: string, title?: string) => { setDirection(1) setPages((current) => [...current, { id, title: title ?? id }]) setSearch("") }, []) const popPage = React.useCallback((count = 1) => { setDirection(-1) setPages((current) => current.slice(0, Math.max(0, current.length - count))) setSearch("") }, []) const registerShortcut = React.useCallback( (hotkey: string, run: () => void) => { const key = Symbol(hotkey) shortcutsRef.current.set(key, { hotkey, run }) return () => { shortcutsRef.current.delete(key) } }, [] ) const takeKeyboardSelect = React.useCallback(() => { const entry = enterRef.current const fromKeyboard = performance.now() - entry.at < 100 enterRef.current = { at: -Infinity, newTab: false } return { fromKeyboard, newTab: fromKeyboard && entry.newTab } }, []) const context = React.useMemo( () => ({ search, setSearch, pages, direction, pushPage, popPage, registerShortcut, takeKeyboardSelect, }), [ direction, pages, popPage, pushPage, registerShortcut, search, takeKeyboardSelect, ] ) return ( <CommandContext.Provider value={context}> <CommandPrimitive data-slot="command" label={label} onKeyDown={(event) => { if (internalEvents.has(event.nativeEvent)) { return } onKeyDown?.(event) if (event.defaultPrevented || event.nativeEvent.isComposing) { return } if (event.key === "Enter") { enterRef.current = { at: performance.now(), newTab: event.metaKey || event.ctrlKey, } return } if (event.repeat) { return } for (const { hotkey, run } of shortcutsRef.current.values()) { if (matchesHotkey(event.nativeEvent, hotkey)) { event.preventDefault() run() return } } }} className={cn( "flex size-full min-w-0 flex-col overflow-hidden rounded-(--command-radius) bg-popover text-popover-foreground ring-(length:--hairline) ring-foreground/10 outline-none [--command-inset:--spacing(1.5)] [--command-radius:var(--radius-xl)] focus-visible:outline-hidden in-data-[slot=command-dialog]:rounded-none in-data-[slot=command-dialog]:ring-0 forced-colors:border", className )} {...props} > {children} {highlight ? <CommandHighlighter /> : null} <CommandAnnouncer format={formatResults} pageTitle={pages.at(-1)?.title ?? (pages.length ? "" : rootTitle)} /> </CommandPrimitive> </CommandContext.Provider> ) } function CommandPage({ id, children, }: { id: string children?: React.ReactNode }) { return ( <CommandPageContext.Provider value={id}> {children} </CommandPageContext.Provider> ) } type CommandInputProps = Omit< React.ComponentProps<typeof CommandPrimitive.Input>, "value" | "onValueChange" > & { value?: string onValueChange?: (search: string) => void clearLabel?: string backLabel?: (title: string) => string } function CommandInput({ className, value, onValueChange, onKeyDown, clearLabel = "Clear search", backLabel = (title) => `Back from ${title}`, ref, ...props }: CommandInputProps) { const context = useCommandContext("CommandInput") const search = value ?? context.search const inputRef = React.useRef<HTMLInputElement | null>(null) const page = context.pages.at(-1) const setInput = React.useCallback( (node: HTMLInputElement | null) => { inputRef.current = node if (typeof ref === "function") { ref(node) } else if (ref) { ref.current = node } }, [ref] ) const change = (next: string) => { if (value === undefined) { context.setSearch(next) } onValueChange?.(next) } return ( <div data-slot="command-input-wrapper" className="flex shrink-0 items-center gap-2.5 border-b ps-3.5 pe-2" > {page ? ( <button type="button" data-slot="command-page-chip" aria-label={backLabel(page.title)} onClick={() => { context.popPage() inputRef.current?.focus() }} className="-ms-1.5 inline-flex h-6 max-w-40 min-w-0 shrink-0 items-center gap-0.5 rounded-[calc(var(--radius-sm)*0.8)] bg-muted ps-0.5 pe-2 text-xs font-medium text-foreground transition-[background-color,scale] duration-150 ease-out-quint outline-none hover:bg-foreground/10 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden motion-safe:animate-in motion-safe:animation-duration-150 motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:active:scale-95 pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5" > <IconChevronLeft aria-hidden className="size-3.5 shrink-0 rtl:-scale-x-100" /> <span className="truncate">{page.title}</span> </button> ) : ( <IconSearch aria-hidden className="pointer-events-none size-4 shrink-0 text-muted-foreground" /> )} <CommandPrimitive.Input ref={setInput} data-slot="command-input" value={search} onValueChange={change} onKeyDown={(event) => { onKeyDown?.(event) if (event.defaultPrevented || event.nativeEvent.isComposing) { return } if (event.key === "Escape") { if (search !== "") { event.preventDefault() change("") } else if (context.pages.length > 0) { event.preventDefault() context.popPage() } return } if ( event.key === "Backspace" && search === "" && context.pages.length > 0 && !event.repeat ) { event.preventDefault() context.popPage() } }} className={cn( "h-11 w-full min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:h-12 pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:hidden", className )} {...props} /> <Button type="button" variant="ghost" size="icon-xs" tabIndex={-1} aria-label={clearLabel} aria-hidden={search === "" || undefined} data-slot="command-clear" onClick={() => { change("") inputRef.current?.focus() }} className={cn( "shrink-0 rounded-full text-muted-foreground transition-[opacity,scale,color,background-color] duration-150 hover:text-foreground", search === "" ? "pointer-events-none opacity-0 motion-safe:scale-75" : "opacity-100" )} > <IconX /> </Button> </div> ) } function scrollIntoList(element: HTMLElement) { const list = element.closest<HTMLElement>("[cmdk-list]") if (!list) { return } const style = getComputedStyle(list) const top = parseFloat(style.scrollPaddingTop) || 0 const bottom = parseFloat(style.scrollPaddingBottom) || 0 const listRect = list.getBoundingClientRect() const rect = element.getBoundingClientRect() if (rect.top < listRect.top + top) { list.scrollTop -= listRect.top + top - rect.top } else if (rect.bottom > listRect.bottom - bottom) { list.scrollTop += rect.bottom - (listRect.bottom - bottom) } } function useScrollWithinList( ref: React.RefObject<HTMLElement | null>, selector?: string ) { React.useLayoutEffect(() => { const root = ref.current const target = selector ? root?.querySelector<HTMLElement>(selector) : root if (!target) { return } target.scrollIntoView = () => scrollIntoList(target) return () => { delete (target as Partial<HTMLElement>).scrollIntoView } }) } function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) { const context = useCommandContext("CommandList") const listRef = React.useRef<HTMLDivElement>(null) const depth = context.pages.length const [previousDepth, setPreviousDepth] = React.useState(depth) const [pageTurn, setPageTurn] = React.useState(0) if (depth !== previousDepth) { setPreviousDepth(depth) setPageTurn((turn) => turn + 1) } React.useLayoutEffect(() => { const list = listRef.current if (!pageTurn || !list) { return } const home = new KeyboardEvent("keydown", { key: "Home", bubbles: true, cancelable: true, }) internalEvents.add(home) list.closest("[cmdk-root]")?.dispatchEvent(home) list.scrollTop = 0 const sizer = list.querySelector<HTMLElement>(":scope > [cmdk-list-sizer]") if (!sizer || typeof sizer.animate !== "function") { return } const rtl = getComputedStyle(sizer).direction === "rtl" const offset = 16 * context.direction * (rtl ? -1 : 1) const animation = sizer.animate( prefersReducedMotion() ? [{ opacity: 0 }, { opacity: 1 }] : [ { translate: `${offset}px 0`, opacity: 0 }, { translate: "0 0", opacity: 1 }, ], { duration: 180, easing: easeOut } ) return () => animation.cancel() }, [context.direction, pageTurn]) React.useLayoutEffect(() => { const list = listRef.current if (!list || typeof ResizeObserver !== "function") { return } let frame = 0 const settle = () => { cancelAnimationFrame(frame) frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { list.setAttribute("data-settled", "") }) }) } const observer = new ResizeObserver(() => { if (list.getClientRects().length === 0) { cancelAnimationFrame(frame) list.removeAttribute("data-settled") } else if (!list.hasAttribute("data-settled")) { settle() } }) observer.observe(list) return () => { cancelAnimationFrame(frame) observer.disconnect() list.removeAttribute("data-settled") } }, []) return ( <CommandPrimitive.List ref={listRef} data-slot="command-list" className={cn( "max-h-[min(24rem,45dvh)] scroll-py-(--command-inset) [scrollbar-gutter:stable] overflow-x-hidden overflow-y-auto overscroll-none duration-150 ease-out-quint outline-none focus-visible:outline-hidden data-settled:h-(--cmdk-list-height) motion-safe:data-settled:transition-[height] motion-reduce:transition-none [&>[cmdk-list-sizer]]:p-(--command-inset)", className )} {...props} /> ) } type CommandEmptyProps = Omit< React.ComponentProps<typeof CommandPrimitive.Empty>, "children" > & { children?: React.ReactNode | ((search: string) => React.ReactNode) } function CommandEmpty({ className, children, ...props }: CommandEmptyProps) { const search = useCommandState((state) => state.search) const hydrating = React.useSyncExternalStore( subscribeNothing, () => false, () => true ) if (hydrating) { return null } return ( <CommandPrimitive.Empty data-slot="command-empty" className={cn( "px-3 py-8 text-center text-sm text-balance text-muted-foreground in-[[cmdk-list]:has([cmdk-loading])]:hidden", className )} {...props} > {typeof children === "function" ? children(search) : children} </CommandPrimitive.Empty> ) } function useCommandLoading( loading: boolean, { delay = 150, minDuration = 300, }: { delay?: number; minDuration?: number } = {} ) { const [visible, setVisible] = React.useState(false) const shownAtRef = React.useRef(0) React.useEffect(() => { if (loading && !visible) { const timer = setTimeout(() => { shownAtRef.current = performance.now() setVisible(true) }, delay) return () => clearTimeout(timer) } if (!loading && visible) { const remaining = Math.max( 0, minDuration - (performance.now() - shownAtRef.current) ) const timer = setTimeout(() => setVisible(false), remaining) return () => clearTimeout(timer) } }, [delay, loading, minDuration, visible]) return visible } type CommandLoadingProps = React.ComponentProps< typeof CommandPrimitive.Loading > & { loading?: boolean delay?: number minDuration?: number } function CommandLoading({ className, children, label, loading = true, delay, minDuration, ...props }: CommandLoadingProps) { const visible = useCommandLoading(loading, { delay, minDuration }) if (!loading && !visible) { return null } return ( <CommandPrimitive.Loading data-slot="command-loading" data-pending={visible ? undefined : ""} label={label ?? (typeof children === "string" ? children : undefined)} className={cn( "flex min-h-9 items-center justify-center px-3 py-4 text-sm text-muted-foreground data-pending:sr-only motion-safe:animate-in motion-safe:animation-duration-150 motion-safe:fade-in-0 [&>div]:flex [&>div]:items-center [&>div]:gap-2", className )} {...props} > <Spinner aria-hidden /> {children} </CommandPrimitive.Loading> ) } function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) { const groupRef = React.useRef<HTMLDivElement>(null) useScrollWithinList(groupRef, ":scope > [cmdk-group-heading]") if (!usePageVisible()) { return null } return ( <CommandPrimitive.Group ref={groupRef} data-slot="command-group" className={cn( "overflow-hidden text-foreground not-first:mt-1 [&>[cmdk-group-heading]]:px-2.5 [&>[cmdk-group-heading]]:pt-2 [&>[cmdk-group-heading]]:pb-1.5 [&>[cmdk-group-heading]]:text-xs [&>[cmdk-group-heading]]:font-medium [&>[cmdk-group-heading]]:text-muted-foreground [&>[cmdk-group-heading]]:select-none", className )} {...props} /> ) } function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) { if (!usePageVisible()) { return null } return ( <CommandPrimitive.Separator data-slot="command-separator" aria-hidden className={cn( "-mx-(--command-inset) my-(--command-inset) h-(length:--hairline) bg-border", className )} {...props} /> ) } function textOf(node: React.ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node) } if (Array.isArray(node)) { return node.map(textOf).join("") } if (React.isValidElement<{ children?: React.ReactNode }>(node)) { return node.type === CommandShortcut ? "" : textOf(node.props.children) } return "" } const confirmBlink = 60 const confirmDelay = 120 type CommandItemProps = Omit< React.ComponentProps<typeof CommandPrimitive.Item>, "asChild" > & { shortcut?: string page?: string pageTitle?: string href?: string render?: React.ReactElement<{ href?: string }> confirm?: boolean } function CommandItem({ className, children, shortcut, page, pageTitle, href, render, confirm = true, onSelect, disabled, value, ...props }: CommandItemProps) { const context = useCommandContext("CommandItem") const visible = usePageVisible() const apple = useIsApple() const itemRef = React.useRef<HTMLDivElement>(null) useScrollWithinList(itemRef) const [confirming, setConfirming] = React.useState(false) const busyRef = React.useRef(false) const timersRef = React.useRef<ReturnType<typeof setTimeout>[]>([]) const linkHref = href ?? render?.props.href React.useEffect(() => { const timers = timersRef return () => timers.current.forEach(clearTimeout) }, []) const act = ( value: string, keyboard: { fromKeyboard: boolean; newTab: boolean } ) => { busyRef.current = false if (page) { context.pushPage(page, pageTitle ?? value) } else if (linkHref && keyboard.fromKeyboard) { if (keyboard.newTab) { window.open(linkHref, "_blank", "noopener") } else { itemRef.current?.click() } } onSelect?.(value) } const select = (value: string) => { if (busyRef.current) { return } const keyboard = context.takeKeyboardSelect() if (!confirm || prefersReducedMotion()) { act(value, keyboard) return } busyRef.current = true setConfirming(true) timersRef.current.push( setTimeout(() => setConfirming(false), confirmBlink), setTimeout(() => act(value, keyboard), confirmDelay) ) } const selectRef = React.useRef(select) React.useLayoutEffect(() => { selectRef.current = select }) React.useEffect(() => { if (!shortcut || disabled) { return } return context.registerShortcut(shortcut, () => { const value = itemRef.current?.getAttribute("data-value") ?? itemRef.current?.textContent ?? "" selectRef.current(value) }) }, [context, disabled, shortcut]) if (!visible) { return null } const content = ( <> {children} {page ? ( <IconChevronLeft aria-hidden className="ms-auto size-4 shrink-0 rotate-180 text-muted-foreground rtl:rotate-0" /> ) : null} {shortcut ? ( <CommandShortcut aria-hidden> {formatHotkey(shortcut, apple)} </CommandShortcut> ) : null} </> ) const link = render ?? (href ? <a href={href} /> : null) return ( <CommandPrimitive.Item ref={itemRef} data-slot="command-item" data-confirming={confirming ? "" : undefined} data-page={page ? "" : undefined} aria-keyshortcuts={ shortcut ? shortcut .split("+") .map((part) => part === "mod" ? (apple ? "Meta" : "Control") : part ) .join("+") : undefined } value={value ?? (textOf(children).trim() || undefined)} disabled={disabled} onSelect={select} asChild={Boolean(link)} className={cn( "relative flex min-h-9 min-w-0 cursor-default items-center gap-2.5 rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--command-radius)-var(--command-inset)))] px-2.5 py-1.5 text-sm text-foreground outline-none select-none focus-visible:outline-hidden in-data-highlighting:text-muted-foreground data-[confirming]:bg-transparent data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-muted forced-colors:data-[selected=true]:outline-2 forced-colors:data-[selected=true]:-outline-offset-2 forced-colors:data-[selected=true]:outline-solid pointer-coarse:min-h-11 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[selected=true]:[&_svg:not([class*='text-'])]:text-foreground [&:is(a)]:cursor-pointer", className )} {...props} > {link ? React.cloneElement(link, undefined, content) : content} </CommandPrimitive.Item> ) } type CommandShortcutProps = React.ComponentProps<"kbd"> & { hotkey?: string } function CommandShortcut({ className, dir = "ltr", hotkey, children, ...props }: CommandShortcutProps) { const apple = useIsApple() return ( <kbd data-slot="command-shortcut" dir={dir} className={cn( "ms-auto shrink-0 font-sans text-xs tracking-widest text-muted-foreground [unicode-bidi:isolate]", className )} {...props} > {children ?? (hotkey ? formatHotkey(hotkey, apple) : null)} </kbd> ) } function CommandKey({ children }: { children: React.ReactNode }) { return ( <kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded-[calc(var(--radius-sm)*0.7)] bg-muted px-1 font-sans text-[0.6875rem] font-medium text-muted-foreground"> {children} </kbd> ) } function CommandFooter({ className, children, ...props }: React.ComponentProps<"div">) { const context = React.useContext(CommandContext) const nested = (context?.pages.length ?? 0) > 0 return ( <div data-slot="command-footer" className={cn( "flex shrink-0 items-center gap-4 border-t px-3.5 py-2 text-xs text-muted-foreground select-none pointer-coarse:hidden", className )} {...props} > {children ?? ( <> <span className="inline-flex items-center gap-1.5"> <CommandKey>↑</CommandKey> <CommandKey>↓</CommandKey> Navigate </span> <span className="inline-flex items-center gap-1.5"> <CommandKey>↵</CommandKey> Open </span> {nested ? ( <span className="inline-flex items-center gap-1.5"> <CommandKey>⌫</CommandKey> Back </span> ) : null} <span className="ms-auto inline-flex items-center gap-1.5"> <CommandKey>esc</CommandKey> {nested ? "Back" : "Close"} </span> </> )} </div> ) } type CommandDialogProps = DialogPrimitive.Root.Props & { title?: string description?: string className?: string showCloseButton?: boolean preserveSearch?: boolean children?: React.ReactNode } function CommandDialog({ title = "Command menu", description = "Search for a command to run.", className, showCloseButton = false, preserveSearch = false, children, open, defaultOpen = false, onOpenChange, ...props }: CommandDialogProps) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen) const isOpen = open ?? uncontrolledOpen const popupRef = React.useRef<HTMLDivElement>(null) React.useEffect(() => { if (!isOpen || !preserveSearch) { return } const frame = requestAnimationFrame(() => { popupRef.current ?.querySelector<HTMLInputElement>("[data-slot=command-input]") ?.select() }) return () => cancelAnimationFrame(frame) }, [isOpen, preserveSearch]) return ( <DialogPrimitive.Root open={isOpen} onOpenChange={(next, details) => { if ( !next && details.reason === "escape-key" && details.event.defaultPrevented ) { details.cancel() return } onOpenChange?.(next, details) if (!details.isCanceled && open === undefined) { setUncontrolledOpen(next) } }} {...props} > <DialogPrimitive.Portal keepMounted={preserveSearch}> <DialogPrimitive.Backdrop data-slot="command-dialog-overlay" className="fixed inset-0 z-50 min-h-dvh bg-black/40 supports-backdrop-filter:backdrop-blur-xs dark:bg-black/60" /> <DialogPrimitive.Popup ref={popupRef} data-slot="command-dialog" className={cn( "fixed inset-x-4 top-[max(env(safe-area-inset-top),1rem)] z-50 mx-auto flex max-w-lg flex-col overflow-hidden rounded-(--command-radius) bg-popover text-popover-foreground shadow-2xl ring-(length:--hairline) ring-foreground/10 outline-none [--command-radius:var(--radius-xl)] focus-visible:outline-hidden sm:top-[12dvh] forced-colors:border", className )} > <DialogPrimitive.Title className="sr-only"> {title} </DialogPrimitive.Title> <DialogPrimitive.Description className="sr-only"> {description} </DialogPrimitive.Description> {children} {showCloseButton ? ( <DialogPrimitive.Close data-slot="command-dialog-close" render={ <Button variant="ghost" size="icon-sm" className="absolute end-2 top-2" /> } > <IconX /> <span className="sr-only">Close</span> </DialogPrimitive.Close> ) : null} </DialogPrimitive.Popup> </DialogPrimitive.Portal> </DialogPrimitive.Root> ) } function useCommandHotkey( hotkey: string, callback: (event: KeyboardEvent) => void, { enabled = true }: { enabled?: boolean } = {} ) { const callbackRef = React.useRef(callback) React.useLayoutEffect(() => { callbackRef.current = callback }) React.useEffect(() => { if (!enabled) { return } const bare = !/(^|\+)(mod|meta|ctrl|alt)\+/i.test(hotkey) const onKeyDown = (event: KeyboardEvent) => { if ( event.defaultPrevented || event.repeat || event.isComposing || !matchesHotkey(event, hotkey) || (bare && isEditable(event.target)) ) { return } event.preventDefault() callbackRef.current(event) } document.addEventListener("keydown", onKeyDown) return () => document.removeEventListener("keydown", onKeyDown) }, [enabled, hotkey]) } function useHotkeyLabel(hotkey: string) { return formatHotkey(hotkey, useIsApple()) } export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandLoading, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, CommandPage, CommandFooter, useCommandHotkey, useCommandLoading, useCommandPages, useCommandState, useHotkeyLabel, }components/ui/button.tsx "use client" import * as React from "react" import { Button as ButtonPrimitive } from "@base-ui/react/button" import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "cn" import { Spinner } from "@/components/ui/spinner" import { useButtonFeedback, type ButtonFeedbackOptions, type ButtonStatus, } from "@/hooks/use-button-feedback" const buttonVariants = cva( "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70", link: "text-primary underline-offset-4 hover:underline forced-colors:border-0", success: "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40", error: "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake", }, size: { default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2", icon: "size-9 shrink-0", "icon-xs": "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden", "icon-lg": "size-10 shrink-0", }, }, defaultVariants: { variant: "default", size: "default", }, } ) type ButtonVariant = Exclude< NonNullable<VariantProps<typeof buttonVariants>["variant"]>, "success" | "error" > type ButtonClickEvent = Parameters< NonNullable<ButtonPrimitive.Props["onClick"]> >[0] type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> & Omit<VariantProps<typeof buttonVariants>, "variant"> & ButtonFeedbackOptions & { variant?: ButtonVariant | null onClick?: (event: ButtonClickEvent) => unknown feedback?: boolean loading?: boolean status?: ButtonStatus loadingLabel?: React.ReactNode successLabel?: React.ReactNode errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode) } const stateTransitionTime = 220 const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"] const quietVariants: ButtonVariant[] = ["ghost", "link"] const quietStatusClasses: Partial<Record<ButtonStatus, string>> = { success: "text-success hover:text-success", error: "text-destructive hover:text-destructive motion-safe:animate-button-shake", } function isThenable(value: unknown): value is PromiseLike<unknown> { return ( typeof value === "object" && value !== null && typeof (value as PromiseLike<unknown>).then === "function" ) } function useStatusTransition(status: ButtonStatus) { const [shown, setShown] = React.useState(status) const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null) const [transitionKey, setTransitionKey] = React.useState(0) if (status !== shown) { setLeaving(shown) setShown(status) setTransitionKey((key) => key + 1) } React.useEffect(() => { if (leaving === null) { return } const timer = setTimeout(() => setLeaving(null), stateTransitionTime) return () => clearTimeout(timer) }, [leaving, transitionKey]) return { leaving, transitionKey } } function useLayerWidth(status: ButtonStatus, active: boolean) { const contentRef = React.useRef<HTMLSpanElement>(null) const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>()) const observerRef = React.useRef<ResizeObserver | null>(null) const statusRef = React.useRef(status) const applyWidth = React.useCallback(() => { const content = contentRef.current const layer = layersRef.current.get(statusRef.current) if (!content || !layer) { return } const natural = parseFloat(getComputedStyle(layer).width) if (!Number.isFinite(natural)) { return } content.style.width = `${natural}px` if (!content.hasAttribute("data-measured")) { requestAnimationFrame(() => content.setAttribute("data-measured", "")) } }, []) React.useLayoutEffect(() => { statusRef.current = status if (active) { applyWidth() } }, [active, applyWidth, status]) React.useLayoutEffect(() => { if (!active || typeof ResizeObserver === "undefined") { return } const observer = new ResizeObserver(applyWidth) observerRef.current = observer layersRef.current.forEach((layer) => observer.observe(layer)) document.fonts?.addEventListener?.("loadingdone", applyWidth) return () => { observer.disconnect() observerRef.current = null document.fonts?.removeEventListener?.("loadingdone", applyWidth) } }, [active, applyWidth]) const layerRef = React.useCallback((node: HTMLElement | null) => { if (!node) { return } const layer = node.dataset.layer as ButtonStatus layersRef.current.set(layer, node) observerRef.current?.observe(node) return () => { observerRef.current?.unobserve(node) if (layersRef.current.get(layer) === node) { layersRef.current.delete(layer) } } }, []) return { contentRef, layerRef } } function composeHandlers<Event>( first: ((event: Event) => void) | undefined, second: (event: Event) => void ) { return (event: Event) => { first?.(event) second(event) } } function Button({ className, variant = "default", size = "default", children, onClick, disabled, focusableWhenDisabled, feedback, loading, status: statusProp, onStatusChange, onError, resetAfter, loadingLabel, successLabel = "Done", errorLabel = "Failed", onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: ButtonProps) { const internal = useButtonFeedback({ resetAfter, onStatusChange, onError }) const status = statusProp ?? (loading ? "loading" : internal.status) const requested = Boolean(feedback) || loading !== undefined || statusProp !== undefined const [enabled, setEnabled] = React.useState(requested) if (requested && !enabled) { setEnabled(true) } const { leaving, transitionKey } = useStatusTransition(status) const iconOnly = size?.startsWith("icon") ?? false const widthStatus = status === "loading" && loadingLabel === undefined ? "idle" : status const { contentRef, layerRef } = useLayerWidth( widthStatus, enabled && !iconOnly ) const isLoading = status === "loading" const quiet = quietVariants.includes(variant ?? "default") const resolvedVariant = !quiet && status === "success" ? "success" : !quiet && status === "error" ? "error" : variant const resolvedErrorLabel = typeof errorLabel !== "function" ? errorLabel : internal.error !== undefined ? errorLabel(internal.error) : "Failed" const handleClick = (event: ButtonClickEvent) => { if (feedback && internal.isPending()) { return } const result = onClick?.(event) if (feedback && isThenable(result)) { internal.track(result) } } const layerContent = (layer: ButtonStatus, animated: boolean) => { if (layer === "idle") { return children } if (layer === "loading") { return ( <> <Spinner aria-hidden size={null} animated={animated} /> {iconOnly ? null : loadingLabel} </> ) } if (layer === "success") { return ( <> <IconCircleCheck className={cn( animated && "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]" )} /> {iconOnly ? null : successLabel} </> ) } return ( <> <IconAlertCircle /> {iconOnly ? null : resolvedErrorLabel} </> ) } const button = ( <ButtonPrimitive data-slot="button" data-status={enabled ? status : undefined} aria-busy={isLoading || undefined} disabled={disabled || isLoading} focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)} className={cn( buttonVariants({ variant: resolvedVariant, size }), quiet && quietStatusClasses[status], className )} onClick={handleClick} onPointerEnter={composeHandlers( onPointerEnter, internal.buttonProps.onPointerEnter )} onPointerLeave={composeHandlers( onPointerLeave, internal.buttonProps.onPointerLeave )} onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)} onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)} {...props} > {enabled ? ( <span ref={contentRef} data-slot="button-content" className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]" > {statuses.map((layer) => { const active = layer === status const exiting = layer === leaving const namesButton = layer === "idle" && isLoading && loadingLabel === undefined return ( <span key={active ? `${layer}-${transitionKey}` : layer} ref={layerRef} data-slot="button-layer" data-layer={layer} aria-hidden={active || namesButton ? undefined : true} className={cn( "inline-flex items-center justify-center gap-[inherit] backface-hidden", !active && !exiting && "pointer-events-none absolute opacity-0", active && transitionKey > 0 && "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0", exiting && "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out" )} > {layerContent(layer, active || exiting)} </span> ) })} </span> ) : ( children )} </ButtonPrimitive> ) if (!enabled) { return button } const announcement = status === "loading" ? typeof loadingLabel === "string" ? loadingLabel : "Loading" : status === "success" ? typeof successLabel === "string" ? successLabel : "Done" : status === "error" ? typeof resolvedErrorLabel === "string" ? resolvedErrorLabel : "Failed" : "" return ( <> {button} <span data-slot="button-status" role="status" className="sr-only"> {announcement} </span> </> ) } export { Button, buttonVariants, useButtonFeedback } export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }lib/motion.ts import * as React from "react" const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)" const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)" const easeSpring = "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)" const duration = { press: 100, release: 200, hover: 150, enter: 200, exit: 150, morph: 300, } as const function prefersReducedMotion() { return ( typeof window === "undefined" || typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches ) } type SizeAxis = "width" | "height" type SizeMorphOptions = { axis: SizeAxis enabled?: boolean duration?: number easing?: string } function readSize(element: HTMLElement, axis: SizeAxis) { const value = parseFloat(getComputedStyle(element)[axis]) if (Number.isFinite(value)) { return value } const rect = element.getBoundingClientRect() return axis === "width" ? rect.width : rect.height } function attachSizeMorph( element: HTMLElement, axis: SizeAxis, time: number, easing: string ) { if ( typeof MutationObserver === "undefined" || typeof element.animate !== "function" ) { return undefined } let settled = readSize(element, axis) let animation: Animation | null = null const morph = () => { const from = animation ? readSize(element, axis) : settled animation?.cancel() animation = null const to = readSize(element, axis) settled = to if ( Math.abs(from - to) < 0.5 || prefersReducedMotion() || !element.isConnected || element.getClientRects().length === 0 ) { element.removeAttribute("data-morphing") return } element.setAttribute("data-morphing", "") const running = element.animate( [{ [axis]: `${from}px` }, { [axis]: `${to}px` }], { duration: time, easing } ) animation = running running.onfinish = () => { if (animation === running) { animation = null element.removeAttribute("data-morphing") settled = readSize(element, axis) } } } const mutations = new MutationObserver(morph) mutations.observe(element, { childList: true, subtree: true, characterData: true, }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (!animation) { settled = readSize(element, axis) } }) resize?.observe(element) return () => { mutations.disconnect() resize?.disconnect() animation?.cancel() element.removeAttribute("data-morphing") } } function useSizeMorph<T extends HTMLElement>({ axis, enabled = true, duration: time = duration.morph, easing = easeOut, }: SizeMorphOptions): React.RefCallback<T> { return React.useCallback( (element: T | null) => { if (!element || !enabled) { return undefined } return attachSizeMorph(element, axis, time, easing) }, [axis, enabled, time, easing] ) } function useSlidingHighlight( barRef: React.RefObject<HTMLElement | null>, highlightRef: React.RefObject<HTMLElement | null>, selector: string, attribute = "data-popup-open" ) { React.useLayoutEffect(() => { const bar = barRef.current const highlight = highlightRef.current if (!bar || !highlight) { return } let current: HTMLElement | null = null const place = (trigger: HTMLElement, instant: boolean) => { if (instant || prefersReducedMotion()) { highlight.setAttribute("data-instant", "") } else { highlight.removeAttribute("data-instant") } const frame = bar.getBoundingClientRect() const box = trigger.getBoundingClientRect() const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1 highlight.style.left = "0px" highlight.style.width = `${box.width / scale}px` highlight.style.height = `${box.height / scale}px` highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)` } const sync = () => { const trigger = bar.querySelector<HTMLElement>(selector) if (trigger === current) { if (trigger) { place(trigger, true) } return } const appearing = current === null current = trigger if (!trigger) { highlight.removeAttribute("data-visible") return } place(trigger, appearing) if (appearing) { void highlight.offsetWidth } highlight.setAttribute("data-visible", "") } sync() const mutations = new MutationObserver(sync) mutations.observe(bar, { subtree: true, childList: true, attributes: true, attributeFilter: [attribute], }) const resize = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(() => { if (current) { place(current, true) } }) resize?.observe(bar) const onScroll = () => { if (current) { place(current, true) } } bar.addEventListener("scroll", onScroll, { capture: true, passive: true }) return () => { mutations.disconnect() resize?.disconnect() bar.removeEventListener("scroll", onScroll, { capture: true }) } }, [barRef, highlightRef, selector, attribute]) } export { duration, easeInOut, easeOut, easeSpring, prefersReducedMotion, useSizeMorph, useSlidingHighlight, } export type { SizeMorphOptions } - 4
Update the import paths to match your project setup.
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"<Command>
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem onSelect={() => openCalendar()}>Calendar</CommandItem>
<CommandItem shortcut="mod+p">Profile</CommandItem>
</CommandGroup>
</CommandList>
</Command>Hotkeys use mod for ⌘ on Apple devices and Ctrl everywhere else. Labels are formatted per platform for you.
Command
├── CommandInput
├── CommandList
│ ├── CommandEmpty
│ ├── CommandLoading
│ ├── CommandGroup
│ │ └── CommandItem
│ │ └── CommandShortcut
│ ├── CommandSeparator
│ └── CommandPage
│ └── CommandGroup
└── CommandFooter
CommandDialog
└── CommandBasic#
Typing filters and ranks items as you go. Groups with no matches disappear, and the list height animates to fit what is left.
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"
export function CommandBasic() {
return (
<Command className="w-full max-w-sm">
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem>Calendar</CommandItem>
<CommandItem>Search Emoji</CommandItem>
<CommandItem>Calculator</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem>Profile</CommandItem>
<CommandItem>Billing</CommandItem>
<CommandItem>Settings</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)
}Dialog#
Put a <Command /> inside <CommandDialog /> and toggle it with useCommandHotkey. Press ⌘K or Ctrl K. Item shortcuts run while it is open, matches are highlighted, and preserveSearch keeps the query and selection for the next time it opens.
Nothing run yet. Theme: System.
"use client"
import * as React from "react"
import {
IconCalculator,
IconCalendar,
IconCheck,
IconCreditCard,
IconExternalLink,
IconMoodSmile,
IconPalette,
IconPoint,
IconSettings,
IconUser,
} from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandDialog,
CommandEmpty,
CommandFooter,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandPage,
CommandSeparator,
CommandShortcut,
useCommandHotkey,
} from "@/components/ui/command"
export function CommandDialogDemo() {
const [open, setOpen] = React.useState(false)
const [last, setLast] = React.useState<string>()
const [theme, setTheme] = React.useState("System")
useCommandHotkey("mod+k", () => setOpen((value) => !value))
const run = (action: string) => {
setLast(action)
setOpen(false)
}
return (
<div className="flex flex-col items-center gap-3">
<Button variant="outline" onClick={() => setOpen(true)}>
Open palette
<CommandShortcut hotkey="mod+k" />
</Button>
<p className="text-sm text-muted-foreground">
{last ? `Ran “${last}”.` : "Nothing run yet."} Theme: {theme}.
</p>
<CommandDialog open={open} onOpenChange={setOpen} preserveSearch>
<Command highlight>
<CommandInput placeholder="Type a command or search…" />
<CommandList>
<CommandEmpty>
{(search) => `No results for “${search}”.`}
</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem
shortcut="mod+shift+c"
onSelect={() => run("Calendar")}
>
<IconCalendar />
Calendar
</CommandItem>
<CommandItem onSelect={() => run("Search Emoji")}>
<IconMoodSmile />
Search Emoji
</CommandItem>
<CommandItem disabled>
<IconCalculator />
Calculator
</CommandItem>
<CommandItem page="theme" pageTitle="Theme">
<IconPalette />
Change theme…
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem shortcut="mod+p" onSelect={() => run("Profile")}>
<IconUser />
Profile
</CommandItem>
<CommandItem shortcut="mod+b" onSelect={() => run("Billing")}>
<IconCreditCard />
Billing
</CommandItem>
<CommandItem
shortcut="mod+,"
keywords={["preferences", "options"]}
onSelect={() => run("Settings")}
>
<IconSettings />
Settings
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Links">
<CommandItem href="/docs" onSelect={() => setOpen(false)}>
<IconExternalLink />
All components
</CommandItem>
</CommandGroup>
<CommandPage id="theme">
<CommandGroup heading="Theme">
{["Light", "Dark", "System"].map((option) => (
<CommandItem
key={option}
onSelect={() => {
setTheme(option)
run(`Theme: ${option}`)
}}
>
{option === theme ? <IconCheck /> : <IconPoint />}
{option}
</CommandItem>
))}
</CommandGroup>
</CommandPage>
</CommandList>
<CommandFooter />
</Command>
</CommandDialog>
</div>
)
}Pages#
An item with page opens the matching <CommandPage />. The page title appears as a chip in the input, the list slides in from the side, and Backspace on an empty search or Escape goes back.
Nothing selected yet.
"use client"
import * as React from "react"
import {
IconBrandGithub,
IconFolder,
IconFolderPlus,
IconUsers,
} from "@tabler/icons-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandPage,
} from "@/components/ui/command"
const projects = ["hextaui", "marketing-site", "design-tokens"]
const members = ["Ada Lovelace", "Grace Hopper", "Alan Turing"]
export function CommandPages() {
const [last, setLast] = React.useState<string>()
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Command>
<CommandInput placeholder="Search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Workspace">
<CommandItem page="projects" pageTitle="Projects">
<IconFolder />
Projects
</CommandItem>
<CommandItem page="members" pageTitle="Members">
<IconUsers />
Members
</CommandItem>
<CommandItem onSelect={() => setLast("New project")}>
<IconFolderPlus />
New project
</CommandItem>
</CommandGroup>
<CommandPage id="projects">
<CommandGroup heading="Projects">
{projects.map((project) => (
<CommandItem key={project} onSelect={setLast}>
<IconBrandGithub />
{project}
</CommandItem>
))}
</CommandGroup>
</CommandPage>
<CommandPage id="members">
<CommandGroup heading="Members">
{members.map((member) => (
<CommandItem key={member} onSelect={setLast}>
<IconUsers />
{member}
</CommandItem>
))}
</CommandGroup>
</CommandPage>
</CommandList>
</Command>
<p className="text-sm text-muted-foreground">
{last ? `Selected “${last}”.` : "Nothing selected yet."}
</p>
</div>
)
}Scrollable#
Long lists scroll inside a capped height. The selected item is always kept in view while you move with the keyboard.
import { IconFileText } from "@tabler/icons-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
const documents = Array.from({ length: 60 }, (_, index) => ({
id: index + 1,
title: `Document ${String(index + 1).padStart(2, "0")}`,
}))
export function CommandScrollable() {
return (
<Command className="w-full max-w-sm">
<CommandInput placeholder="Search 60 documents…" />
<CommandList>
<CommandEmpty>No documents match.</CommandEmpty>
<CommandGroup heading="Documents">
{documents.map((doc) => (
<CommandItem key={doc.id}>
<IconFileText />
{doc.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)
}Async results#
Set shouldFilter={false} and render the results you fetch. <CommandLoading /> waits 150 ms before it appears and then stays at least 300 ms, so fast responses never flash a spinner. Try both latencies.
"use client"
import * as React from "react"
import { IconUser } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandLoading,
useCommandLoading,
} from "@/components/ui/command"
const people = [
"Ada Lovelace",
"Alan Turing",
"Grace Hopper",
"Katherine Johnson",
"Linus Torvalds",
"Margaret Hamilton",
"Tim Berners-Lee",
]
export function CommandAsync() {
const [query, setQuery] = React.useState("")
const [results, setResults] = React.useState(people)
const [loading, setLoading] = React.useState(false)
const [latency, setLatency] = React.useState(700)
const pending = useCommandLoading(loading)
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)
React.useEffect(() => () => clearTimeout(timerRef.current), [])
const search = (nextQuery: string, nextLatency: number) => {
clearTimeout(timerRef.current)
setLoading(true)
timerRef.current = setTimeout(() => {
setResults(
people.filter((person) =>
person.toLowerCase().includes(nextQuery.toLowerCase())
)
)
setLoading(false)
}, nextLatency)
}
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex gap-2">
{[80, 700].map((ms) => (
<Button
key={ms}
size="sm"
variant={latency === ms ? "secondary" : "outline"}
onClick={() => {
setLatency(ms)
search(query, ms)
}}
>
{ms} ms
</Button>
))}
</div>
<Command shouldFilter={false} highlight>
<CommandInput
placeholder="Search people…"
value={query}
onValueChange={(next) => {
setQuery(next)
search(next, latency)
}}
/>
<CommandList>
<CommandLoading loading={loading}>Searching…</CommandLoading>
<CommandEmpty>
{(value) => `No people match “${value}”.`}
</CommandEmpty>
{pending ? null : (
<CommandGroup heading="People">
{results.map((person) => (
<CommandItem key={person}>
<IconUser />
{person}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</div>
)
}Long content#
Headings wrap, long names truncate or wrap as you choose, and shortcuts never get pushed out.
import { IconFileText } from "@tabler/icons-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command"
export function CommandLongContent() {
return (
<Command className="w-full max-w-72">
<CommandInput placeholder="Search…" />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="A group heading that is long enough to wrap onto two lines">
<CommandItem>
<IconFileText />
<span className="min-w-0 truncate">
quarterly-planning-final-final-v2-reviewed-by-legal.pdf
</span>
<CommandShortcut>⌘⇧O</CommandShortcut>
</CommandItem>
<CommandItem>
<IconFileText />
<span className="min-w-0 wrap-anywhere">
averyveryverylongunbrokenfilenamethatshouldwrapinsteadofescaping.txt
</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)
}Right to left#
Icons, shortcuts, the page chip and the page slide all follow the reading direction.
import { IconCalendar, IconSettings } from "@tabler/icons-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command"
export function CommandRtl() {
return (
<div dir="rtl" className="w-full max-w-sm">
<Command dir="rtl">
<CommandInput placeholder="ابحث عن أمر…" />
<CommandList>
<CommandEmpty>لا توجد نتائج.</CommandEmpty>
<CommandGroup heading="اقتراحات">
<CommandItem>
<IconCalendar />
التقويم
<CommandShortcut>⌘T</CommandShortcut>
</CommandItem>
<CommandItem>
<IconSettings />
الإعدادات
<CommandShortcut>⌘S</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</div>
)
}| Key | Action |
|---|---|
| ↓ | Selects the next item. |
| ↑ | Selects the previous item. |
| Alt↓ | Jumps to the first item of the next group. |
| Alt↑ | Jumps to the first item of the previous group. |
| Home | Selects the first item. |
| End | Selects the last item. |
| CtrlN | Selects the next item. Ctrl J works too. Turn off with vimBindings. |
| CtrlP | Selects the previous item. Ctrl K works too. Turn off with vimBindings. |
| Enter | Runs the selected item. On a link item, ⌘ Enter or Ctrl Enter opens it in a new tab. |
| Esc | Clears the search first, then goes back one page, then closes the dialog. |
| Backspace | Goes back one page when the search is empty. |
| ⌘P | Any item shortcut runs its item while focus is inside the command menu. |
- The input is a combobox that points at the selected item, so screen readers announce each item as you move.
- A polite live region announces the number of results shortly after you stop typing, and announces the page title when you open or leave a page. Change the wording with
formatResultsandrootTitle. <CommandDialog />has a hidden title and description, traps focus while open and returns it to the trigger when it closes.- Item shortcuts are exposed with
aria-keyshortcuts. - With reduced motion on, items run without the confirm blink and pages fade instead of sliding.
Built on cmdk, with <CommandDialog /> on the Base UI dialog. Parts accept the props of the cmdk part they wrap.
| Prop | Type | Default |
|---|---|---|
labelAccessible name of the menu. | string | "Command menu" |
highlightHighlights the matched letters in each item and dims the rest. | boolean | false |
shouldFilterSet to false to filter and sort the items yourself, for example when results come from a server. | boolean | true |
filterReturns a score from 0 (hidden) to 1 (best match). | (value: string, search: string, keywords?: string[]) => number | – |
valueThe selected item’s value. | string | – |
defaultValue | string | – |
onValueChange | (value: string) => void | – |
loopWrap around at the ends of the list. | boolean | false |
vimBindingsCtrl N, J, P and K navigation. | boolean | true |
disablePointerSelection | boolean | false |
formatResultsText announced to screen readers after typing. | (count: number) => string | "3 results" |
rootTitleAnnounced when you leave the last page and return to the root. | string | "All commands" |
| Attribute | Description |
|---|---|
data-slot="command" | Target the root in CSS. |
data-highlighting | Present while highlight is on and the search is not empty. |
--command-radius | Outer radius. Items derive a concentric radius from it. |
--command-inset | Padding between the list edge and its items. |
| Prop | Type | Default |
|---|---|---|
open | boolean | – |
defaultOpen | boolean | false |
onOpenChange | (open: boolean, details) => void | – |
preserveSearchKeep the dialog mounted so the query, page and selection survive closing. The query is selected when it reopens. | boolean | false |
titleVisually hidden dialog title. | string | "Command menu" |
descriptionVisually hidden dialog description. | string | "Search for a command to run." |
showCloseButton | boolean | false |
classNameApplied to the dialog popup. | string | – |
| Attribute | Description |
|---|---|
data-slot="command-dialog" | The dialog popup. |
data-slot="command-dialog-overlay" | The backdrop. |
data-open | Present on the popup while it is open. |
| Prop | Type | Default |
|---|---|---|
valueControlled search text. | string | – |
onValueChange | (search: string) => void | – |
placeholder | string | – |
clearLabelAccessible name of the clear button. | string | "Clear search" |
backLabelAccessible name of the page chip. | (title: string) => string | (title) => `Back from ${title}` |
| Attribute | Description |
|---|---|
data-slot="command-input" | The input. |
data-slot="command-input-wrapper" | The row holding the icon, input and clear button. |
data-slot="command-clear" | The clear button, shown once you type. |
data-slot="command-page-chip" | The back chip shown on a page. |
| Prop | Type | Default |
|---|---|---|
labelAccessible name of the list. | string | – |
| Attribute | Description |
|---|---|
data-slot="command-list" | The list. |
data-settled | Present once the list has measured itself. The height transition only runs while it is set. |
--cmdk-list-height | Height of the visible items, used to animate the list. |
| Prop | Type | Default |
|---|---|---|
childrenUse the function form to echo the query. | ReactNode | (search: string) => ReactNode | – |
| Attribute | Description |
|---|---|
data-slot="command-empty" | Hidden while a CommandLoading is in the list. |
| Prop | Type | Default |
|---|---|---|
loading | boolean | true |
delayMilliseconds to wait before the spinner shows. | number | 150 |
minDurationMinimum milliseconds the spinner stays once shown. | number | 300 |
labelAccessible label. Defaults to string children. | string | – |
progress | number | – |
| Attribute | Description |
|---|---|
data-slot="command-loading" | The loading row. |
data-pending | Present during the delay, while the row is announced but not yet visible. |
| Prop | Type | Default |
|---|---|---|
heading | ReactNode | – |
valueRequired when there is no heading. | string | – |
forceMountKeep the group visible while filtering. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="command-group" | The group. |
[cmdk-group-heading] | The heading element. |
| Prop | Type | Default |
|---|---|---|
onSelectRuns on click, Enter or the item’s shortcut, after the confirm blink. | (value: string) => void | – |
valueUsed for filtering. Defaults to the item’s text, without the shortcut. | string | – |
keywordsExtra words that match this item. | string[] | – |
disabled | boolean | false |
shortcutA hotkey like "mod+shift+c". Shown on the item and runs it while focus is in the menu. | string | – |
pageOpens the CommandPage with this id instead of running. | string | – |
pageTitleTitle shown in the page chip. Defaults to the value. | string | – |
hrefRenders the item as a link. Enter follows it, ⌘ or Ctrl Enter opens a new tab. | string | – |
renderA link element to render instead, like Next.js <Link />. | ReactElement | – |
confirmBlink the item briefly before running it, so the choice registers. | boolean | true |
forceMountKeep the item visible while filtering. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="command-item" | The item. |
data-selected="true" | Present on the selected item. |
data-disabled="true" | Present on disabled items. |
data-value | The value used for filtering. |
data-confirming | Present during the confirm blink. |
data-page | Present on items that open a page. |
| Prop | Type | Default |
|---|---|---|
idMatches the page prop of the item that opens it. Its groups and items only render while it is the current page. | string | – |
| Prop | Type | Default |
|---|---|---|
hotkeyFormats a hotkey like "mod+k" for the current platform. Children override it. | string | – |
| Attribute | Description |
|---|---|
data-slot="command-shortcut" | The shortcut label. |
| Prop | Type | Default |
|---|---|---|
alwaysRenderKeep it visible while searching. | boolean | false |
| Attribute | Description |
|---|---|
data-slot="command-separator" | The separator. |
| Prop | Type | Default |
|---|---|---|
childrenDefaults to key hints that update on a page. Hidden on touch screens. | ReactNode | – |
| Attribute | Description |
|---|---|
data-slot="command-footer" | The footer. |
const [open, setOpen] = React.useState(false)
useCommandHotkey("mod+k", () => setOpen((value) => !value))| Prop | Type | Default |
|---|---|---|
hotkeyListened for on the whole document. Hotkeys without a modifier are ignored while typing in a field. | string | – |
callback | (event: KeyboardEvent) => void | – |
options.enabled | boolean | true |
Returns whether a loading indicator should be visible, with the same delay and minimum duration as <CommandLoading />. Use it to hide stale results while a request is in flight.
| Prop | Type | Default |
|---|---|---|
loading | boolean | – |
options.delay | number | 150 |
options.minDuration | number | 300 |
useCommandPages()returns{ pages, page, push, pop, reset }to drive pages from your own code.useCommandState(selector)reads cmdk state, such as the search or the filtered count.useHotkeyLabel(hotkey)formats a hotkey for the current platform, like ⌘K or Ctrl+K.