BackCommand
components

Command

Accessible command palette with keyboard navigation and search functionality.

components/ui/command.tsx
"use client"

import React from "react"
import { createPortal } from "react-dom"

let stylesInjected = false
const injectStyles = () => {
	if (stylesInjected || typeof document === "undefined") return

	const styleEl = document.createElement("style")
	styleEl.textContent = `
		@keyframes fade-in {
			from { opacity: 0; }
			to { opacity: 1; }
		}

		@keyframes scale-in {
			from { opacity: 0; transform: scale(0.97); }
			to { opacity: 1; transform: scale(1); }
		}

		@keyframes fade-out {
			from { opacity: 1; }
			to { opacity: 0; }
		}

		@keyframes scale-out {
			from { opacity: 1; transform: scale(1); }
			to { opacity: 0; transform: scale(0.97); }
		}

		.animate-in {
			animation-duration: 200ms;
			animation-fill-mode: both;
		}

		.fade-in {
			animation-name: fade-in;
		}

		.scale-in {
			animation-name: scale-in;
		}

		.animate-out {
			animation-duration: 150ms;
			animation-fill-mode: both;
		}

		.fade-out {
			animation-name: fade-out;
		}

		.scale-out {
			animation-name: scale-out;
		}
	`
	document.head.appendChild(styleEl)
	stylesInjected = true
}

interface CommandContextType {
	query: string
	setQuery: (query: string) => void
	activeIndex: number
	setActiveIndex: (index: number) => void
	registerItem: (value: string, el: HTMLElement | null) => () => void
	visibleItems: Array<{ value: string; el: HTMLElement | null }>
	onSelect?: (value: string) => void
	handleKeyDown: (e: React.KeyboardEvent) => void
}

interface DialogContextType {
	open: boolean
	setOpen: (open: boolean) => void
}

const CommandContext = React.createContext<CommandContextType | null>(null)
const useCommand = () => React.useContext(CommandContext)

const DialogContext = React.createContext<DialogContextType | null>(null)
export const useCommandDialog = () => React.useContext(DialogContext)

export interface CommandDialogProps extends React.ComponentProps<"div"> {
	children: React.ReactNode
	open?: boolean
	onOpenChange?: (open: boolean) => void
	className?: string
}

export function CommandDialog({
	children,
	open: controlledOpen,
	onOpenChange,
	className = "",
	...props
}: CommandDialogProps) {
	const isControlled = controlledOpen !== undefined
	const [internalOpen, setInternalOpen] = React.useState(false)
	const [isClosing, setIsClosing] = React.useState(false)
	const open = isControlled ? controlledOpen : internalOpen
	const shouldShow = open || isClosing

	// Inject animation styles on component mount
	React.useEffect(injectStyles, [])

	const setOpen = React.useCallback(
		(val: boolean | ((prev: boolean) => boolean)) => {
			const newValue = typeof val === "function" ? val(open) : val
			if (!newValue && open) setIsClosing(true)
			if (!isControlled) setInternalOpen(newValue)
			onOpenChange?.(newValue)
		},
		[isControlled, onOpenChange, open]
	)

	// global cmd+k / ctrl+k
	React.useEffect(() => {
		const handler = (e: globalThis.KeyboardEvent) => {
			if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
				e.preventDefault()
				setOpen((prev) => !prev)
			}
		}
		document.addEventListener("keydown", handler)
		return () => document.removeEventListener("keydown", handler)
	}, [setOpen])

	// Escape closes
	React.useEffect(() => {
		if (!open) return
		const handler = (e: globalThis.KeyboardEvent) => {
			if (e.key === "Escape") setOpen(false)
		}
		document.addEventListener("keydown", handler)
		return () => document.removeEventListener("keydown", handler)
	}, [open, setOpen])

	// separate trigger children (rendered always) from dialog children
	const triggerChildren = React.Children.toArray(children).filter(
		(child) => React.isValidElement(child) && child.type === CommandTrigger
	)
	const dialogChildren = React.Children.toArray(children).filter(
		(child) => !(React.isValidElement(child) && child.type === CommandTrigger)
	)

	const ctx = { open, setOpen }

	const modal = shouldShow
		? createPortal(
				<div
					className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] backdrop-blur-sm"
					aria-modal="true"
					role="dialog"
					aria-label="Command palette"
					onAnimationEnd={() => !open && setIsClosing(false)}
					{...props}
				>
					{/* backdrop */}
					<div
						className={`bg-background/80 absolute inset-0 ${
							open ? "animate-in fade-in duration-200" : "animate-out fade-out duration-150"
						}`}
						onClick={() => setOpen(false)}
					/>
					{/* panel */}
					<div
						className={`relative z-10 w-full max-w-lg ${
							open ? "animate-in fade-in scale-in duration-200" : "animate-out fade-out scale-out duration-150"
						} ${className}`}
						onClick={(e) => e.stopPropagation()}
					>
						{dialogChildren}
					</div>
				</div>,
				document.body
			)
		: null

	return (
		<DialogContext.Provider value={ctx}>
			{triggerChildren}
			{modal}
		</DialogContext.Provider>
	)
}

export function CommandTrigger({
	children,
	className = "",
	asChild = false,
}: {
	children: React.ReactNode
	className?: string
	asChild?: boolean
}) {
	const ctx = React.useContext(DialogContext)

	const handleClick = (e: React.MouseEvent) => {
		e.stopPropagation()
		ctx?.setOpen(true)
	}

	if (asChild && React.isValidElement(children)) {
		const childElement = children as React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }>
		const original = childElement.props.onClick
		return React.cloneElement(childElement, {
			onClick: (e: React.MouseEvent) => {
				original?.(e)
				handleClick(e)
			},
		})
	}

	return (
		<button type="button" onClick={handleClick} className={className} aria-label="Open command palette">
			{children}
		</button>
	)
}

export function CommandBody({
	children,
	className = "",
	onSelect,
	...props
}: {
	children: React.ReactNode
	className?: string
	onSelect?: (value: string) => void
	[key: string]: unknown
}) {
	const [query, setQuery] = React.useState("")
	const [activeIndex, setActiveIndex] = React.useState(-1)
	const [items, setItems] = React.useState<Array<{ value: string; el: HTMLElement | null }>>([])

	const registerItem = React.useCallback((value: string, el: HTMLElement | null) => {
		setItems((prev) => {
			if (el && !prev.find((i) => i.value === value)) {
				return [...prev, { value, el }]
			}
			return prev
		})
		return () => {
			setItems((prev) => prev.filter((i) => i.value !== value))
		}
	}, [])

	const visibleItems = React.useMemo(() => {
		if (!query) return items
		return items.filter((i) => i.value.toLowerCase().includes(query.toLowerCase()))
	}, [query, items])

	const handleKeyDown = React.useCallback(
		(e: React.KeyboardEvent) => {
			if (e.key === "ArrowDown") {
				e.preventDefault()
				setActiveIndex((prev) => (prev < visibleItems.length - 1 ? prev + 1 : 0))
			} else if (e.key === "ArrowUp") {
				e.preventDefault()
				setActiveIndex((prev) => (prev > 0 ? prev - 1 : visibleItems.length - 1))
			} else if (e.key === "Enter") {
				e.preventDefault()
				const item = visibleItems[activeIndex]
				if (item) onSelect?.(item.value)
			}
		},
		[visibleItems, activeIndex, onSelect]
	)

	return (
		<CommandContext.Provider
			value={{
				query,
				setQuery,
				activeIndex,
				setActiveIndex,
				registerItem,
				visibleItems,
				onSelect,
				handleKeyDown,
			}}
		>
			<div
				role="combobox"
				aria-haspopup="listbox"
				aria-expanded="true"
				onKeyDown={handleKeyDown}
				className={`bg-background overflow-hidden rounded-lg border ${className}`}
				{...props}
			>
				{children}
				<CommandFooter />
			</div>
		</CommandContext.Provider>
	)
}

export function CommandInput({
	placeholder = "",
	className = "",
	...props
}: {
	placeholder?: string
	className?: string
	[key: string]: unknown
}) {
	const ctx = useCommand()
	if (!ctx) return null
	const { query, setQuery, setActiveIndex } = ctx
	const inputRef = React.useRef<HTMLInputElement>(null)

	// auto-focus when mounted inside the dialog
	React.useEffect(() => {
		const t = setTimeout(() => inputRef.current?.focus(), 0)
		return () => clearTimeout(t)
	}, [])

	return (
		<div className="flex items-center gap-2 border-b px-3">
			<svg
				className="text-muted-foreground h-4 w-4 shrink-0"
				xmlns="http://www.w3.org/2000/svg"
				viewBox="0 0 24 24"
				fill="none"
				stroke="currentColor"
				strokeWidth="2"
				strokeLinecap="round"
				strokeLinejoin="round"
				aria-hidden="true"
			>
				<circle cx="11" cy="11" r="8" />
				<path d="m21 21-4.3-4.3" />
			</svg>
			<input
				ref={inputRef}
				autoComplete="off"
				autoCorrect="off"
				spellCheck={false}
				role="combobox"
				aria-autocomplete="list"
				placeholder={placeholder}
				value={query}
				onChange={(e) => {
					setQuery(e.target.value)
					setActiveIndex(-1)
				}}
				className={`text-foreground placeholder:text-muted-foreground flex h-11 w-full bg-transparent py-3 text-sm focus:outline-none ${className}`}
				{...props}
			/>
		</div>
	)
}

export function CommandList({ children, className = "", ...props }: React.ComponentProps<"div">) {
	return (
		<div
			role="listbox"
			className={`custom-scrollbar max-h-100 overflow-x-hidden overflow-y-auto ${className}`}
			{...props}
		>
			{children}
		</div>
	)
}

export function CommandEmpty({ children, className = "", ...props }: React.ComponentProps<"div">) {
	const ctx = useCommand()
	if (!ctx) return null
	const { visibleItems } = ctx
	if (visibleItems.length > 0) return null
	return (
		<div className={`text-muted-foreground py-6 text-center text-sm ${className}`} role="presentation" {...props}>
			{children}
		</div>
	)
}

export interface CommandGroupProps extends React.ComponentProps<"div"> {
	heading?: string
}

export function CommandGroup({ heading, children, className = "", ...props }: CommandGroupProps) {
	const ctx = useCommand()
	if (!ctx) return null
	const { query } = ctx

	const values = React.useMemo(() => {
		const vals: string[] = []
		React.Children.forEach(children, (child) => {
			if (!React.isValidElement(child)) return
			const childProps = child.props as { value?: string; children?: React.ReactNode }
			if (childProps.value != null) vals.push(String(childProps.value))
			else if (typeof childProps.children === "string") vals.push(childProps.children)
		})
		return vals
	}, [children])

	const hasVisible = React.useMemo(() => {
		if (!query) return true
		return values.some((v) => v.toLowerCase().includes(query.toLowerCase()))
	}, [query, values])

	if (!hasVisible) return null

	return (
		<div role="group" aria-label={heading} className={`space-y-1 overflow-hidden p-2 ${className}`} {...props}>
			{heading && <div className="text-muted-foreground px-2 py-1.5 text-xs font-medium">{heading}</div>}
			{children}
		</div>
	)
}

export function CommandSeparator({ className = "", ...props }: React.ComponentProps<"hr">) {
	const ctx = useCommand()
	if (!ctx) return null
	const { visibleItems } = ctx
	if (visibleItems.length === 0) return null
	return <hr className={`bg-border -mx-1 h-px border-0 ${className}`} {...props} />
}

export function CommandItem({
	children,
	value,
	onSelect,
	disabled = false,
	className = "",
	...props
}: {
	children: React.ReactNode
	value?: string
	onSelect?: (value: string) => void
	disabled?: boolean
	className?: string
	[key: string]: unknown
}) {
	const ctx = useCommand()
	if (!ctx) return null
	const resolvedValue = value ?? (typeof children === "string" ? children : "")
	const elRef = React.useRef<HTMLDivElement>(null)

	React.useEffect(() => {
		return ctx.registerItem(resolvedValue, elRef.current)
	}, [resolvedValue])

	const { query, visibleItems, activeIndex, setActiveIndex, onSelect: ctxSelect } = ctx

	const visible = !query || resolvedValue.toLowerCase().includes(query.toLowerCase())
	if (!visible) return null

	const myIndex = visibleItems.findIndex((i) => i.value === resolvedValue)
	const isActive = myIndex !== -1 && myIndex === activeIndex

	return (
		<div
			ref={elRef}
			role="option"
			aria-selected={isActive}
			aria-disabled={disabled}
			className={[
				"relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors outline-none select-none",
				isActive ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent",
				disabled ? "pointer-events-none opacity-40" : "",
				className,
			]
				.filter(Boolean)
				.join(" ")}
			onMouseEnter={() => !disabled && setActiveIndex(myIndex)}
			onMouseLeave={() => setActiveIndex(-1)}
			onClick={() => {
				if (disabled) return
				onSelect?.(resolvedValue)
				ctxSelect?.(resolvedValue)
			}}
			{...props}
		>
			{children}
		</div>
	)
}

export function CommandShortcut({ children, className = "", ...props }: React.ComponentProps<"div">) {
	return (
		<span className={`text-muted-foreground ml-auto text-xs tracking-widest ${className}`} {...props}>
			{children}
		</span>
	)
}

export function CommandFooter({ className = "", ...props }: React.ComponentProps<"footer">) {
	const [isMac, setIsMac] = React.useState(false)

	const shortcuts = isMac
		? [
				{ keys: ["", ""], description: "Navigate" },
				{ keys: ["Enter"], description: "Select" },
				{ keys: ["", "K"], description: "Open menu" },
			]
		: [
				{ keys: ["", ""], description: "Navigate" },
				{ keys: ["Enter"], description: "Select" },
				{ keys: ["Ctrl", "K"], description: "Open menu" },
			]

	React.useEffect(() => {
		setIsMac(navigator.userAgent.toLowerCase().includes("mac"))
	}, [])

	return (
		<footer className={`border-t px-3 py-2 ${className}`} {...props}>
			<div className="text-muted-foreground flex gap-5 text-xs">
				{shortcuts.map((shortcut, index) => (
					<div key={index} className="flex items-center gap-2">
						<span>{shortcut.description}</span>
						<span className="flex items-center gap-1">
							{shortcut.keys.map((key, keyIndex) => (
								<span
									key={keyIndex}
									className={`grid min-h-6 min-w-6 place-items-center rounded-md border font-medium select-none ${key.length > 1 ? "px-2" : ""}`}
								>
									{key}
								</span>
							))}
						</span>
					</div>
				))}
			</div>
		</footer>
	)
}
components/examples/command.tsx
"use client"

import {
	CommandBody,
	CommandDialog,
	CommandEmpty,
	CommandGroup,
	CommandInput,
	CommandItem,
	CommandList,
	CommandSeparator,
	CommandShortcut,
	CommandTrigger,
} from "@/components/ui/command"
import React from "react"

export function CommandExample() {
	const [isMac, setIsMac] = React.useState(false)

	React.useEffect(() => {
		setIsMac(navigator.userAgent.toLowerCase().includes("mac"))
	}, [])

	const shortcutKey = isMac ? "⌘K" : "Ctrl + K"
	const shortcuts = {
		calendar: isMac ? "⌘C" : "Ctrl + C",
		search: isMac ? "⌘E" : "Ctrl + E",
		calculator: isMac ? "⌘ + ⌘" : "Ctrl + +",
		profile: isMac ? "⌘P" : "Ctrl + P",
		billing: isMac ? "⌘B" : "Ctrl + B",
		settings: isMac ? "⌘S" : "Ctrl + S",
	}

	return (
		<div className="grid place-items-center gap-4">
			<CommandDialog>
				<CommandTrigger asChild>
					<button className="bg-primary text-primary-foreground hover:bg-primary/90 h-9 cursor-pointer rounded-md px-4 py-2 text-sm font-medium">
						Open{" "}
						<span className="text-muted-foreground text-xs">
							<span className="relative bottom-0.25">|</span> {shortcutKey}
						</span>
					</button>
				</CommandTrigger>

				<CommandBody>
					<CommandInput placeholder="Type a command or search..." />
					<CommandList>
						<CommandEmpty>No results found.</CommandEmpty>

						<CommandGroup heading="Suggestions">
							<CommandItem value="calendar">
								Calendar
								<CommandShortcut>{shortcuts.calendar}</CommandShortcut>
							</CommandItem>
							<CommandItem value="search-emoji">
								Search Emoji
								<CommandShortcut>{shortcuts.search}</CommandShortcut>
							</CommandItem>
							<CommandItem value="calculator">
								Calculator
								<CommandShortcut>{shortcuts.calculator}</CommandShortcut>
							</CommandItem>
						</CommandGroup>

						<CommandSeparator />

						<CommandGroup heading="Settings">
							<CommandItem value="profile">
								Profile
								<CommandShortcut>{shortcuts.profile}</CommandShortcut>
							</CommandItem>
							<CommandItem value="billing">
								Billing
								<CommandShortcut>{shortcuts.billing}</CommandShortcut>
							</CommandItem>
							<CommandItem value="settings">
								Settings
								<CommandShortcut>{shortcuts.settings}</CommandShortcut>
							</CommandItem>
						</CommandGroup>
					</CommandList>
				</CommandBody>
			</CommandDialog>
		</div>
	)
}