Command
A searchable list of actions, inline or as a ⌘K palette, with pages, shortcuts and highlighted matches.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/command.jsonInstall manually
Install the dependencies:
npm install @base-ui/react @tabler/icons-react cmdk cnIt builds on these, so copy them first:
Copy this into
components/ui/command.tsx: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, }
Examples
Async results
Basic
Dialog
Long content
Pages
Right to left
Scrollable
Feedback
Questions, bugs or ideas for Command? Post them here, or reply to someone else. Sign in with GitHub to comment.