Command
A filterable list of commands on cmdk, inline or in a dialog.
Vibra keeps the input's aria-activedescendant on the option painted as selected — cmdk 1.1.1 computes it too early, so the option a reader opens on goes unannounced until an arrow key moves it — and moves CommandDialog's sr-only title inside the popup, so a closed palette leaves no stray heading on the page. Group headings are the eyebrow register, separators --rule — drawn by the kit as a presentational rule rather than cmdk's role="separator", which is not a child a listbox may have, and stepping aside while a search is typed unless alwaysRender — and useCommandState is re-exported so a palette built on it never imports cmdk itself — read filtered.count for a count that is shown and spoken, since cmdk's own empty state is silent to a screen reader. CommandShortcut holds a plain glyph or keycaps: with a kbd inside it drops its wide tracking and spaces the keys, so KbdShortcut reads right there, in words to a screen reader. Its sides are logical, so a right-to-left palette mirrors whole.
Install
npx shadcn@latest add @vibra/commandNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { ChartLineIcon, SettingsIcon, UserPlusIcon, UsersIcon } from "lucide-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
export default function CommandDemo() {
return (
<Command label="Search pages and actions" className="w-full max-w-sm rounded-lg ring-1 ring-border">
<CommandInput placeholder="Search pages and actions" />
<CommandList>
<CommandEmpty>Nothing matches that.</CommandEmpty>
<CommandGroup heading="Pages">
<CommandItem>
<ChartLineIcon aria-hidden="true" />
Revenue
</CommandItem>
<CommandItem>
<UsersIcon aria-hidden="true" />
Customers
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem value="Invite a teammate">
<UserPlusIcon aria-hidden="true" />
Invite a teammate{" "}
<CommandShortcut>
<KbdShortcut keys="mod+i" size="sm" />
</CommandShortcut>
</CommandItem>
<CommandItem value="Workspace settings">
<SettingsIcon aria-hidden="true" />
Workspace settings{" "}
<CommandShortcut>
<KbdShortcut keys="mod+," size="sm" />
</CommandShortcut>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)
}In a dialog
The ⌘K palette over pages and customers: opened by a button or the chord with the focus in the search, the count shown and spoken, and the focus handed back on close.
"use client"
import * as React from "react"
import { ChartColumnIcon, LayoutDashboardIcon, ReceiptTextIcon, SearchIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
useCommandState,
} from "@/components/ui/command"
import { Kbd } from "@/components/ui/kbd"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
const PAGES = [
{ label: "Overview", icon: LayoutDashboardIcon, shortcut: "g o" },
{ label: "Reports", icon: ChartColumnIcon, shortcut: "g r" },
{ label: "Invoices", icon: ReceiptTextIcon, shortcut: "g i" },
]
const CUSTOMERS = [
{ name: "Saoirse Byrne", company: "Harbour Logistics" },
{ name: "Tomás Ortega", company: "Keystone Dental" },
{ name: "Amara Okafor", company: "Riverside Florists" },
{ name: "Lior Keller", company: "Lumen Studio" },
]
/** "4 results", read from cmdk's store, and said aloud as it changes. */
function ResultCount() {
const count = useCommandState((state) => state.filtered.count)
return (
<p role="status" className="tabular-nums">
{count === 1 ? "1 result" : `${count} results`}
</p>
)
}
// The ⌘K palette over pages and customers. It opens from the button or the
// chord, with the focus in the search; the count of what matches is shown
// and spoken, shortcuts are keycaps, and closing hands the focus back.
export default function CommandDialogExample() {
const [open, setOpen] = React.useState(false)
React.useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
setOpen((current) => !current)
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [])
return (
<>
<Button variant="outline" className="w-60 justify-between text-muted-foreground" aria-keyshortcuts="Meta+K Control+K" onClick={() => setOpen(true)}>
<span className="flex items-center gap-2">
<SearchIcon aria-hidden="true" />
Search Northwind
</span>
<KbdShortcut keys="mod+k" size="sm" aria-hidden="true" />
</Button>
<CommandDialog open={open} onOpenChange={setOpen} title="Search Northwind" description="Pages and customers" className="sm:max-w-lg">
<Command label="Search Northwind" loop>
<CommandInput placeholder="Search pages and customers" />
<CommandList>
<CommandEmpty>No page or customer matches that.</CommandEmpty>
<CommandGroup heading="Pages">
{PAGES.map(({ label, icon: Icon, shortcut }) => (
<CommandItem key={label} value={label} onSelect={() => setOpen(false)}>
<Icon aria-hidden="true" />
{label}
<CommandShortcut>
{shortcut.split(" ").map((key) => (
<KbdShortcut key={key} keys={key} size="sm" />
))}
</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Customers">
{CUSTOMERS.map(({ name, company }) => (
<CommandItem key={name} value={name} keywords={[company]} onSelect={() => setOpen(false)}>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
{name}{" "}
<span className="text-xs text-muted-foreground">{company}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<div className="flex items-center justify-between gap-3 border-t border-rule px-3 py-2 text-xs text-muted-foreground">
<ResultCount />
<span aria-hidden="true" className="flex items-center gap-2">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd> move <Kbd>↵</Kbd> open <Kbd>esc</Kbd> close
</span>
</div>
</Command>
</CommandDialog>
</>
)
}When nothing matches
The empty state repeats what was typed and offers the one useful next step — creating it — while the count says there were no matches.
"use client"
import * as React from "react"
import { BuildingIcon, PlusIcon } from "lucide-react"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, useCommandState } from "@/components/ui/command"
const COMPANIES = ["Harbour Logistics", "Keystone Dental", "Riverside Florists", "Lumen Studio", "Alder & Co."]
/** Offered only when nothing matches: the query becomes the new company's name. */
function CreateItem({ query, onCreate }: { query: string; onCreate: (name: string) => void }) {
const count = useCommandState((state) => state.filtered.count)
if (count > 0 || !query.trim()) return null
return (
<CommandGroup heading="Create" forceMount>
<CommandItem value={`create ${query}`} forceMount onSelect={() => onCreate(query.trim())}>
<PlusIcon aria-hidden="true" />
Create “{query.trim()}”
</CommandItem>
</CommandGroup>
)
}
// When nothing matches, the empty state says so in the reader's own words and
// offers the one useful next step: creating the company they were looking
// for. The count of matches is spoken as it changes, including none.
export default function CommandEmptyExample() {
const [query, setQuery] = React.useState("Northgate")
const [companies, setCompanies] = React.useState(COMPANIES)
return (
<Command label="Find a company" className="w-full max-w-sm rounded-lg ring-1 ring-border">
<CommandInput placeholder="Find a company" value={query} onValueChange={setQuery} />
<Count />
<CommandList>
<CommandEmpty className="pt-4 pb-2">No company called “{query.trim()}” yet.</CommandEmpty>
<CommandGroup heading="Companies">
{companies.map((name) => (
<CommandItem key={name} value={name}>
<BuildingIcon aria-hidden="true" />
{name}
</CommandItem>
))}
</CommandGroup>
<CreateItem
query={query}
onCreate={(name) => {
setCompanies((current) => [...current, name])
setQuery(name)
}}
/>
</CommandList>
</Command>
)
}
function Count() {
const count = useCommandState((state) => state.filtered.count)
return (
<p role="status" className="sr-only">
{count === 0 ? "No companies match." : count === 1 ? "1 company matches." : `${count} companies match.`}
</p>
)
}Scoped search
Each scope counts its matches before it is chosen; the list is filtered here (shouldFilter off) so the chips and the rows always agree.
"use client"
import * as React from "react"
import { ChartColumnIcon, ReceiptTextIcon, UserIcon } from "lucide-react"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
const ENTRIES = [
{ scope: "pages", label: "Revenue report", icon: ChartColumnIcon },
{ scope: "pages", label: "Refunds", icon: ChartColumnIcon },
{ scope: "customers", label: "Rhea Kapoor", icon: UserIcon },
{ scope: "customers", label: "Tomás Ortega", icon: UserIcon },
{ scope: "invoices", label: "INV-2041, Harbour Logistics", icon: ReceiptTextIcon },
{ scope: "invoices", label: "INV-2037, Riverside Florists", icon: ReceiptTextIcon },
] as const
const SCOPES = [
{ value: "all", label: "All" },
{ value: "pages", label: "Pages" },
{ value: "customers", label: "Customers" },
{ value: "invoices", label: "Invoices" },
]
// The query runs across everything, and each scope says how many of its
// entries match before it is chosen, so the reader narrows with the numbers
// in view. The matching is done here (shouldFilter off), so the counts on
// the chips and the rows in the list always agree.
export default function CommandScopes() {
const [query, setQuery] = React.useState("r")
const [scope, setScope] = React.useState("all")
const matches = ENTRIES.filter((entry) => entry.label.toLowerCase().includes(query.trim().toLowerCase()))
const shown = matches.filter((entry) => scope === "all" || entry.scope === scope)
const count = (value: string) => matches.filter((entry) => value === "all" || entry.scope === value).length
return (
<Command label="Search" shouldFilter={false} className="w-full max-w-md rounded-lg ring-1 ring-border">
<CommandInput placeholder="Search pages, customers and invoices" value={query} onValueChange={setQuery} />
<ToggleGroup
aria-label="Search in"
value={[scope]}
onValueChange={(value) => value[0] && setScope(String(value[0]))}
variant="outline"
size="sm"
className="flex-wrap px-2 pt-2"
>
{SCOPES.map((option) => (
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
{option.label}{" "}
<span className="text-muted-foreground tabular-nums">{count(option.value)}</span>
</ToggleGroupItem>
))}
</ToggleGroup>
<p role="status" className="sr-only">
{shown.length === 1 ? "1 result" : `${shown.length} results`}
</p>
<CommandList className="max-h-36">
<CommandEmpty>Nothing in {SCOPES.find((option) => option.value === scope)!.label.toLowerCase()} matches that.</CommandEmpty>
<CommandGroup>
{shown.map(({ label, icon: Icon }) => (
<CommandItem key={label} value={label}>
<Icon aria-hidden="true" />
{label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)
}Results from the server
The query waits for a pause, the list says it is searching, a late answer to an old query is dropped, and the count arrives with the rows.
"use client"
import * as React from "react"
import { ReceiptTextIcon } from "lucide-react"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Spinner } from "@/components/ui/spinner"
const INVOICES = [
{ id: "INV-2041", customer: "Harbour Logistics", amount: "$4,820.00" },
{ id: "INV-2040", customer: "Keystone Dental", amount: "$1,290.50" },
{ id: "INV-2039", customer: "Riverside Florists", amount: "$12,460.25" },
{ id: "INV-1987", customer: "Harbour Logistics", amount: "$4,820.00" },
{ id: "INV-1902", customer: "Lumen Studio", amount: "$640.00" },
]
/** Stands in for the server: answers a query after 600ms. */
function search(query: string) {
const q = query.trim().toLowerCase()
return new Promise<typeof INVOICES>((resolve) =>
setTimeout(() => resolve(INVOICES.filter((row) => `${row.id} ${row.customer}`.toLowerCase().includes(q))), 600)
)
}
// 48,000 invoices don't come to the browser: the server searches them. The
// query waits for a pause in typing, the list says it is searching while it
// waits, a late answer to an old query is dropped, and the count arrives with
// the rows. cmdk's own filter is off — the server has already filtered.
export default function CommandAsync() {
const [query, setQuery] = React.useState("harbour")
const settled = useDebouncedValue(query, 250)
const [result, setResult] = React.useState<{ query: string; rows: typeof INVOICES } | null>(null)
const loading = result?.query !== settled
React.useEffect(() => {
let current = true
search(settled).then((rows) => current && setResult({ query: settled, rows }))
return () => {
current = false
}
}, [settled])
const rows = result?.rows ?? []
return (
<Command label="Search invoices" shouldFilter={false} className="w-full max-w-md rounded-lg ring-1 ring-border">
<CommandInput placeholder="Search invoices by number or customer" value={query} onValueChange={setQuery} />
<p role="status" className="flex items-center gap-2 px-3 pt-2 text-xs text-muted-foreground tabular-nums">
{loading ? (
<>
<Spinner className="size-3.5" aria-hidden="true" role={undefined} />
Searching 48,000 invoices…
</>
) : rows.length === 1 ? (
"1 invoice"
) : (
`${rows.length} invoices`
)}
</p>
<CommandList aria-busy={loading} className={loading ? "opacity-60" : undefined}>
{!loading ? <CommandEmpty>No invoice matches “{settled}”.</CommandEmpty> : null}
<CommandGroup>
{rows.map((row) => (
<CommandItem key={row.id} value={row.id}>
<ReceiptTextIcon aria-hidden="true" />
<span className="font-mono">{row.id}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{row.customer}</span>
<span className="tabular-nums">{row.amount}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)
}A command with its own list
"Change plan…" leads to the plans; a chip shows the step, and Backspace in an empty search or the chip's button steps back.
"use client"
import * as React from "react"
import { ArrowLeftIcon, CreditCardIcon, LayersIcon, UserPlusIcon } from "lucide-react"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
const PLANS = [
{ value: "starter", label: "Starter", price: "$29 a month" },
{ value: "growth", label: "Growth", price: "$99 a month" },
{ value: "scale", label: "Scale", price: "$249 a month" },
]
// A command can open a list of its own. "Change plan…" leads to the plans;
// the step shows as a chip before the search, Backspace in an empty search
// steps back, and so does the chip's own button. Each step clears the query
// and puts the focus back in the search.
export default function CommandPages() {
const [page, setPage] = React.useState<"root" | "plans">("root")
const [query, setQuery] = React.useState("")
const [plan, setPlan] = React.useState("growth")
const input = React.useRef<HTMLInputElement>(null)
function go(next: "root" | "plans") {
setPage(next)
setQuery("")
input.current?.focus()
}
return (
<Command
label="Workspace commands"
className="w-full max-w-sm rounded-lg ring-1 ring-border"
onKeyDown={(event) => {
if (event.key === "Backspace" && !query && page !== "root") {
event.preventDefault()
go("root")
}
}}
>
{page === "plans" ? (
<div className="flex items-center gap-2 px-2 pt-2 text-xs">
<button type="button" onClick={() => go("root")} className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 focus-ring" aria-label="Back to all commands">
<ArrowLeftIcon aria-hidden="true" className="size-3 rtl:rotate-180" />
Change plan
</button>
</div>
) : null}
<CommandInput ref={input} placeholder={page === "plans" ? "Choose a plan" : "Type a command"} value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>Nothing matches that.</CommandEmpty>
{page === "root" ? (
<CommandGroup heading="Workspace">
<CommandItem value="Change plan" onSelect={() => go("plans")}>
<LayersIcon aria-hidden="true" />
Change plan…
</CommandItem>
<CommandItem value="Update the card">
<CreditCardIcon aria-hidden="true" />
Update the card
</CommandItem>
<CommandItem value="Invite a teammate">
<UserPlusIcon aria-hidden="true" />
Invite a teammate
</CommandItem>
</CommandGroup>
) : (
<CommandGroup heading="Plans">
{PLANS.map((option) => (
<CommandItem key={option.value} value={option.label} data-checked={option.value === plan} onSelect={() => setPlan(option.value)}>
{option.label}{" "}
<span className="text-xs text-muted-foreground tabular-nums">{option.price}</span>
{option.value === plan ? <span className="sr-only">, the current plan</span> : null}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
<p role="status" className="sr-only">
{page === "plans" ? `Change plan. The current plan is ${PLANS.find((option) => option.value === plan)!.label}.` : ""}
</p>
</Command>
)
}With a preview
Beside the list, the invoice the arrow keys are on, read out politely as it changes; on a phone it steps aside for the list.
"use client"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, useCommandState } from "@/components/ui/command"
import { StatusBadge } from "@/components/ui/status-badge"
const INVOICES = [
{ id: "INV-2041", customer: "Harbour Logistics", amount: "$4,820.00", status: "overdue", due: "Sep 20" },
{ id: "INV-2040", customer: "Keystone Dental", amount: "$1,290.50", status: "paid", due: "Sep 18" },
{ id: "INV-2039", customer: "Riverside Florists", amount: "$12,460.25", status: "pending", due: "Oct 1" },
{ id: "INV-2038", customer: "Lumen Studio", amount: "$640.00", status: "paid", due: "Sep 12" },
]
/** The highlighted invoice, read from cmdk's store, in full. */
function Preview() {
const value = useCommandState((state) => state.value)
const invoice = INVOICES.find((row) => row.id.toLowerCase() === value?.toLowerCase())
if (!invoice) return null
return (
<div aria-live="polite" className="flex flex-col gap-3 p-4 text-sm max-sm:hidden">
<div className="flex items-center justify-between gap-2">
<span className="font-mono">{invoice.id}</span>
<StatusBadge status={invoice.status} size="sm" />
</div>
<p className="type-numeral text-2xl">{invoice.amount}</p>
<dl className="grid grid-cols-[4rem_1fr] gap-y-1 text-xs">
<dt className="text-muted-foreground">Customer</dt>
<dd>{invoice.customer}</dd>
<dt className="text-muted-foreground">Due</dt>
<dd className="tabular-nums">{invoice.due}</dd>
</dl>
</div>
)
}
// The list and, beside it, the invoice the arrow keys are on — enough to
// tell two similar ones apart before opening either. The preview follows the
// highlight and is read out politely; on a phone it steps aside for the list.
export default function CommandPreview() {
return (
<Command label="Find an invoice" className="w-full max-w-lg rounded-lg ring-1 ring-border">
<CommandInput placeholder="Find an invoice" />
<div className="grid sm:grid-cols-[1fr_14rem] sm:divide-x sm:divide-border">
<CommandList>
<CommandEmpty>No invoice matches that.</CommandEmpty>
<CommandGroup>
{INVOICES.map((row) => (
<CommandItem key={row.id} value={row.id} keywords={[row.customer]}>
<span className="font-mono text-xs">{row.id}</span>{" "}
<span className="truncate text-muted-foreground">{row.customer}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
<Preview />
</div>
</Command>
)
}Recent first
Before anything is typed, where the reader was last; once they type, everything, and the recents step aside. Actions show their chords as keycaps.
"use client"
import * as React from "react"
import { ClockIcon, DownloadIcon, FileTextIcon, UserPlusIcon } from "lucide-react"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
const RECENT = ["Q3 revenue", "Harbour Logistics", "Churn by plan"]
const ACTIONS = [
{ label: "New report", icon: FileTextIcon, shortcut: "mod+n" },
{ label: "Invite a teammate", icon: UserPlusIcon, shortcut: "mod+shift+i" },
{ label: "Export this view", icon: DownloadIcon, shortcut: "mod+e" },
]
// Before anything is typed the list offers where the reader was last; once
// they type, it searches everything instead and the recents step aside, so
// nothing is listed twice. Each action shows its chord as keycaps, read out
// as words ("Command + N"), so the palette teaches the shortcuts it runs.
export default function CommandRecent() {
const [query, setQuery] = React.useState("")
return (
<Command label="Go to or do" className="w-full max-w-sm rounded-lg ring-1 ring-border">
<CommandInput placeholder="Go to, or do…" value={query} onValueChange={setQuery} />
<CommandList className="max-h-52">
<CommandEmpty>Nothing matches that.</CommandEmpty>
{query ? null : (
<>
<CommandGroup heading="Recent">
{RECENT.map((name) => (
<CommandItem key={name} value={`recent ${name}`}>
<ClockIcon aria-hidden="true" />
{name}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup heading="Actions">
{ACTIONS.map(({ label, icon: Icon, shortcut }) => (
<CommandItem key={label} value={label}>
<Icon aria-hidden="true" />
{label}{" "}
<CommandShortcut>
<KbdShortcut keys={shortcut} size="sm" />
</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| CommandDialog.title / description | string | "Command Palette" / "Search for a command to run..." | The dialog's accessible name and description; rendered for screen readers only. |
| CommandDialog.showCloseButton | boolean | false | Draws the dialog's close button; Escape closes it either way. |
| Command.label | string | — | Names the search — the combobox a screen reader lands on. |
| Command.shouldFilter | boolean | true | Turn it off when the list is already filtered — by the server, or by your own scopes — so the counts and the rows agree. |
| CommandItem.value / keywords | string / string[] | — | What the search matches: pass value when the item holds more than its name (keycaps, a second line), keywords for words it should also answer to. |
| CommandItem.forceMount | boolean | — | Keeps an item whatever the query — a "Create …" beside the empty state. |
| useCommandState | <T>(selector: (state: State) => T) => T | — | cmdk's store hook — the filtered count, the selected value — re-exported from here. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { Command as CommandPrimitive, useCommandState } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{/* Inside the popup, so the name and the description exist only
while the dialog does: as a sibling they were an sr-only h2 on
every page, ahead of the page's own h1, with the palette shut.
Base UI wires aria-labelledby and -describedby from in here. */}
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{children}
</DialogContent>
</Dialog>
)
}
/**
* Keeps the combobox's `aria-activedescendant` on the option that is painted
* as selected.
*
* cmdk 1.1.1 computes that id inside its own scheduler flush: the first item
* selects itself while the flush is running, so the lookup for
* `[aria-selected="true"]` runs before the item has re-rendered and finds
* nothing — the selection a reader opens on is painted but never announced
* until an arrow key moves it, and a selection the filter moves can be
* announced as the row it left. (Controlling `value` does not help: that path
* never computes the id at all.) So after each commit the id is read off the
* DOM, where the painted selection is, and written only when it disagrees.
* React rewrites the attribute only when cmdk's own value changes, and this
* runs after every such commit, so the two never fight.
*/
function useActiveDescendant(wrapper: React.RefObject<HTMLDivElement | null>) {
const value = useCommandState((state) => state.value)
const selectedItemId = useCommandState((state) => state.selectedItemId)
React.useEffect(() => {
const input = wrapper.current?.querySelector("[cmdk-input]")
const root = wrapper.current?.closest("[cmdk-root]")
if (!input || !root) return
const selected = root.querySelector('[cmdk-item][aria-selected="true"]')
const id = selected?.id || null
if (input.getAttribute("aria-activedescendant") === id) return
if (id) input.setAttribute("aria-activedescendant", id)
else input.removeAttribute("aria-activedescendant")
}, [value, selectedItemId, wrapper])
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
const wrapper = React.useRef<HTMLDivElement | null>(null)
useActiveDescendant(wrapper)
return (
<div ref={wrapper} data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:ps-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:type-eyebrow",
className
)}
{...props}
/>
)
}
// The rule sits inside cmdk's listbox, where a separator is not a child a
// listbox may have (axe aria-required-children, critical) — and cmdk's own
// Separator writes role="separator" over any role it is handed. So the kit
// draws the rule itself, presentational: the groups on either side already
// carry the structure. Like cmdk's, it steps aside while a search is typed,
// unless alwaysRender says otherwise.
function CommandSeparator({
className,
alwaysRender = false,
...props
}: React.ComponentProps<"div"> & { alwaysRender?: boolean }) {
const searching = useCommandState((state) => state.search !== "")
if (searching && !alwaysRender) return null
return <div data-slot="command-separator" role="none" className={cn("-mx-1 h-px bg-rule", className)} {...props} />
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-label outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ms-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
// Tracked wide for a plain "⌘D"; keycaps (KbdShortcut, Kbd) keep
// their own letters together and sit a gap apart instead.
"ms-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground has-[kbd]:flex has-[kbd]:shrink-0 has-[kbd]:items-center has-[kbd]:gap-1 has-[kbd]:tracking-normal",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
// cmdk's own store hook, re-exported so a palette built on this item reads
// the result count from here rather than depending on cmdk itself.
useCommandState,
}