Command Center
Searchable command palette with grouped actions, keyboard navigation, and announcement-style motion.
Palette
Grouped commands with keyboard navigation and motion.
function CommandCenterDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="secondary" onClick={() => setOpen(true)}>
Open command center
</Button>
<CommandCenter
isOpen={open}
onClose={() => setOpen(false)}
onOpenChange={setOpen}
items={COMMAND_CENTER_ITEMS}
shortcut="k"
onSelect={() => setOpen(false)}
/>
</>
);
}function CommandCenterDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="secondary" onClick={() => setOpen(true)}>
Open command center
</Button>
<CommandCenter
isOpen={open}
onClose={() => setOpen(false)}
onOpenChange={setOpen}
items={COMMAND_CENTER_ITEMS}
shortcut="k"
onSelect={() => setOpen(false)}
/>
</>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/command-center.jsonnpx shadcn@latest add https://boardcn.dev/r/command-center.jsonnpm packages
- @remixicon/react
- motion
- react-aria
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ComponentType,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { FocusScope } from "react-aria";
import { RiSearchLine } from "@remixicon/react";
import { CloseButton } from "@/components/base/buttons/close-button";
import { InputBase, TextField } from "@/components/base/input/input";
import { Kbd } from "@/components/base/kbd/kbd";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx, sortCx } from "@/utils/cx";
/**
* BoardCN command palette — a top-centered, search-first overlay for jumping
* to actions, pages, and docs.
*
* Interaction follows the familiar command-menu pattern (type to filter,
* grouped results, arrow keys, Enter, Escape) without pulling in cmdk.
* Motion matches Announcement: blur + fade + scale from 0.85, rising 15px
* on enter; soft blur + scale-down on exit via `motion/react`.
*
* Controlled like SettingsModal: parent owns `isOpen` / `onClose`. Dismiss
* (close button, Esc, overlay) calls `onClose` immediately; AnimatePresence
* plays the exit while the portal stays mounted. Pass `shortcut="k"` to
* register ⌘/Ctrl+K — closing uses `onClose`, opening uses `onOpenChange(true)`
* when provided.
*
* The leading `icon` defaults to `RiSearchLine` (the same Remix glyph as
* sidebar Quick Search). Pass any Remix Icon component reference
* (`RiShieldStarFill`, not `<RiShieldStarFill />`). `introDelay` (seconds)
* delays the entrance after open — omit for delay 0 (still animates).
*
* Spacing, type, and row chrome follow the dropdown / announcement recipes:
* 20px icons, 8px row padding, 10px panel inset, 12px header inset.
*/
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export type CommandCenterItem = {
id: string;
label: string;
description?: string;
group?: string;
icon?: IconComponent;
shortcut?: string;
href?: string;
disabled?: boolean;
keywords?: string[];
};
export interface CommandCenterProps {
items: CommandCenterItem[];
isOpen: boolean;
onClose: () => void;
/** Needed for `shortcut` to open the palette (toggle). */
onOpenChange?: (open: boolean) => void;
onSelect?: (item: CommandCenterItem) => void;
title?: ReactNode;
placeholder?: string;
emptyMessage?: string;
/** Header icon. Defaults to RiSearchLine. */
icon?: IconComponent;
closeLabel?: string;
/**
* Seconds to wait before playing the blur/fade/scale-up entrance after open.
* Omit for delay 0 — the palette still animates in.
*/
introDelay?: number;
/** Letter key for ⌘/Ctrl+shortcut toggle, e.g. `"k"`. */
shortcut?: string;
}
const styles = sortCx({
panel: [
"relative flex w-full max-w-[480px] flex-col overflow-hidden",
"rounded-3xl border border-border-button-default bg-background-primary-default shadow-dropdown",
].join(" "),
header: "flex items-center gap-2 border-b border-border-button-default px-3 py-3 pr-10",
headerIcon: "size-5 shrink-0 text-foreground-icon-secondary",
headerTitle: "min-w-0 flex-1 truncate text-body-medium text-text-primary",
close: "absolute right-3 top-3",
field: "border-b border-border-button-default px-3 py-3",
list: "flex max-h-[min(52vh,420px)] flex-col gap-1 overflow-y-auto p-2.5",
group: "flex w-full flex-col gap-1.5 pt-1",
groupLabel: "pl-2 text-body-medium text-text-secondary",
groupItems: "flex w-full flex-col gap-1",
item: [
"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
"text-text-primary outline-none transition-colors",
].join(" "),
itemActive: "bg-dropdown-item-hover-background",
itemDisabled: "cursor-not-allowed opacity-50",
itemIcon: "size-5 shrink-0 text-foreground-icon-secondary",
itemText: "flex min-w-0 flex-1 flex-col gap-0.5",
itemLabel: "truncate text-body-medium text-text-primary",
itemDescription: "truncate text-body-2-medium text-text-secondary",
empty: "px-3 py-8 text-center text-body-2-regular text-text-tertiary",
footer: [
"flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border-button-default",
"px-3 py-2 text-caption-1-regular text-text-tertiary",
].join(" "),
footerHint: "inline-flex items-center gap-1.5",
});
function scoreItem(item: CommandCenterItem, query: string): number {
const label = item.label.toLowerCase();
if (label === query) return 0;
if (label.startsWith(query)) return 1;
if (label.includes(query)) return 2;
if (item.group?.toLowerCase().includes(query)) return 3;
if (item.description?.toLowerCase().includes(query)) return 4;
if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(query))) return 5;
return Number.POSITIVE_INFINITY;
}
function filterItems(items: CommandCenterItem[], query: string): CommandCenterItem[] {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return items;
return items
.map((item) => ({ item, rank: scoreItem(item, trimmed) }))
.filter((row) => Number.isFinite(row.rank))
.sort((a, b) => a.rank - b.rank || a.item.label.localeCompare(b.item.label))
.map((row) => row.item);
}
function groupItems(items: CommandCenterItem[]): { label: string; items: CommandCenterItem[] }[] {
const order: string[] = [];
const map = new Map<string, CommandCenterItem[]>();
for (const item of items) {
const label = item.group?.trim() || "Commands";
if (!map.has(label)) {
map.set(label, []);
order.push(label);
}
map.get(label)!.push(item);
}
return order.map((label) => ({ label, items: map.get(label)! }));
}
export function CommandCenter({
items,
isOpen,
onClose,
onOpenChange,
onSelect,
title = "Command",
placeholder = "Search commands…",
emptyMessage = "Nothing matches that search.",
icon: Icon = RiSearchLine,
closeLabel = "Close command center",
introDelay,
shortcut,
}: CommandCenterProps) {
const listId = useId();
const reducedMotion = usePrefersReducedMotion();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [portalReady, setPortalReady] = useState(false);
const results = useMemo(() => filterItems(items, query), [items, query]);
const groups = useMemo(() => groupItems(results), [results]);
const flatIds = useMemo(() => results.map((item) => item.id), [results]);
useEffect(() => {
setPortalReady(true);
}, []);
useEffect(() => {
if (!isOpen) {
setQuery("");
setActive(0);
}
}, [isOpen]);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
if (!isOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (!shortcut) return;
const key = shortcut.toLowerCase();
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return;
if (event.key.toLowerCase() !== key) return;
event.preventDefault();
if (isOpen) {
onClose();
onOpenChange?.(false);
} else {
onOpenChange?.(true);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [shortcut, isOpen, onClose, onOpenChange]);
useEffect(() => {
if (!isOpen) return;
const option = listRef.current?.querySelector<HTMLElement>(
`[data-command-id="${CSS.escape(flatIds[active] ?? "")}"]`,
);
option?.scrollIntoView({ block: "nearest" });
}, [active, flatIds, isOpen]);
const selectActive = () => {
const item = results[active];
if (!item || item.disabled) return;
onSelect?.(item);
};
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
// Document-level listener also handles Escape; keep this for input focus.
event.preventDefault();
onClose();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (results.length === 0) return;
setActive((index) => Math.min(index + 1, results.length - 1));
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (results.length === 0) return;
setActive((index) => Math.max(index - 1, 0));
return;
}
if (event.key === "Home") {
event.preventDefault();
setActive(0);
return;
}
if (event.key === "End") {
event.preventDefault();
if (results.length === 0) return;
setActive(results.length - 1);
return;
}
if (event.key === "Enter") {
event.preventDefault();
selectActive();
}
};
const activeId = flatIds[active] ? `${listId}-${flatIds[active]}` : undefined;
const enterTransition = {
duration: reducedMotion ? 0.12 : 0.25,
ease: "easeOut" as const,
delay: introDelay ?? 0,
};
const exitTransition = {
duration: reducedMotion ? 0.12 : 0.25,
ease: "easeInOut" as const,
};
const panelInitial = reducedMotion
? { opacity: 0, x: "-50%" }
: { opacity: 0, x: "-50%", y: 15, scale: 0.85, filter: "blur(6px)" };
const panelAnimate = reducedMotion
? { opacity: 1, x: "-50%", transition: enterTransition }
: {
opacity: 1,
x: "-50%",
y: 0,
scale: 1,
filter: "blur(0px)",
transition: enterTransition,
};
const panelExit = reducedMotion
? { opacity: 0, x: "-50%", transition: exitTransition }
: {
opacity: 0,
x: "-50%",
scale: 0.85,
filter: "blur(6px)",
transition: exitTransition,
};
if (!portalReady) return null;
const emptyCopy = query.trim()
? emptyMessage.includes("{query}")
? emptyMessage.replace("{query}", query.trim())
: `Nothing matches “${query.trim()}”.`
: emptyMessage;
return createPortal(
<AnimatePresence>
{isOpen ? (
<motion.button
key="command-center-backdrop"
type="button"
aria-label="Dismiss command center"
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: reducedMotion ? 0.12 : 0.2 },
}}
exit={{
opacity: 0,
transition: { duration: reducedMotion ? 0.12 : 0.2, ease: "easeInOut" },
}}
onClick={onClose}
className="fixed inset-0 z-50 cursor-default bg-black/40 backdrop-blur-[2px]"
/>
) : null}
{isOpen ? (
<motion.div
key="command-center-panel"
role="dialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : "Command center"}
initial={panelInitial}
animate={panelAnimate}
exit={panelExit}
className={cx(
styles.panel,
"fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-[480px] sm:top-[14vh]",
)}
>
<FocusScope contain restoreFocus>
<div className="relative flex w-full flex-col overflow-hidden">
<div className={styles.header}>
<Icon className={styles.headerIcon} aria-hidden />
<p className={styles.headerTitle}>{title}</p>
</div>
<div className={styles.field}>
<TextField
aria-label={placeholder}
value={query}
onChange={setQuery}
className="gap-0"
autoFocus
>
<InputBase
ref={inputRef}
leadingIcon={RiSearchLine}
placeholder={placeholder}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeId}
onKeyDown={onInputKeyDown}
/>
</TextField>
</div>
{results.length === 0 ? (
<p className={styles.empty}>{emptyCopy}</p>
) : (
<div
ref={listRef}
id={listId}
role="listbox"
aria-label="Commands"
className={styles.list}
>
{groups.map((group) => (
<div
key={group.label}
className={styles.group}
role="group"
aria-label={group.label}
>
<p className={styles.groupLabel}>{group.label}</p>
<div className={styles.groupItems}>
{group.items.map((item) => {
const index = flatIds.indexOf(item.id);
const isActive = index === active;
const ItemIcon = item.icon;
return (
<button
key={item.id}
id={`${listId}-${item.id}`}
type="button"
role="option"
data-command-id={item.id}
aria-selected={isActive}
disabled={item.disabled}
onMouseEnter={() => {
if (!item.disabled) setActive(index);
}}
onClick={() => {
if (item.disabled) return;
onSelect?.(item);
}}
className={cx(
styles.item,
isActive && styles.itemActive,
item.disabled && styles.itemDisabled,
)}
>
{ItemIcon ? (
<ItemIcon className={styles.itemIcon} aria-hidden />
) : null}
<span className={styles.itemText}>
<span className={styles.itemLabel}>{item.label}</span>
{item.description ? (
<span className={styles.itemDescription}>
{item.description}
</span>
) : null}
</span>
{item.shortcut ? <Kbd>{item.shortcut}</Kbd> : null}
</button>
);
})}
</div>
</div>
))}
</div>
)}
<div className={styles.footer}>
<span className={styles.footerHint}>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
navigate
</span>
<span className={styles.footerHint}>
<Kbd>↵</Kbd>
select
</span>
<span className={styles.footerHint}>
<Kbd>esc</Kbd>
close
</span>
</div>
<CloseButton
size="xs"
aria-label={closeLabel}
onClick={onClose}
className={cx(styles.close, "z-10")}
/>
</div>
</FocusScope>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type ComponentType,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { FocusScope } from "react-aria";
import { RiSearchLine } from "@remixicon/react";
import { CloseButton } from "@/components/base/buttons/close-button";
import { InputBase, TextField } from "@/components/base/input/input";
import { Kbd } from "@/components/base/kbd/kbd";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx, sortCx } from "@/utils/cx";
/**
* BoardCN command palette — a top-centered, search-first overlay for jumping
* to actions, pages, and docs.
*
* Interaction follows the familiar command-menu pattern (type to filter,
* grouped results, arrow keys, Enter, Escape) without pulling in cmdk.
* Motion matches Announcement: blur + fade + scale from 0.85, rising 15px
* on enter; soft blur + scale-down on exit via `motion/react`.
*
* Controlled like SettingsModal: parent owns `isOpen` / `onClose`. Dismiss
* (close button, Esc, overlay) calls `onClose` immediately; AnimatePresence
* plays the exit while the portal stays mounted. Pass `shortcut="k"` to
* register ⌘/Ctrl+K — closing uses `onClose`, opening uses `onOpenChange(true)`
* when provided.
*
* The leading `icon` defaults to `RiSearchLine` (the same Remix glyph as
* sidebar Quick Search). Pass any Remix Icon component reference
* (`RiShieldStarFill`, not `<RiShieldStarFill />`). `introDelay` (seconds)
* delays the entrance after open — omit for delay 0 (still animates).
*
* Spacing, type, and row chrome follow the dropdown / announcement recipes:
* 20px icons, 8px row padding, 10px panel inset, 12px header inset.
*/
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
export type CommandCenterItem = {
id: string;
label: string;
description?: string;
group?: string;
icon?: IconComponent;
shortcut?: string;
href?: string;
disabled?: boolean;
keywords?: string[];
};
export interface CommandCenterProps {
items: CommandCenterItem[];
isOpen: boolean;
onClose: () => void;
/** Needed for `shortcut` to open the palette (toggle). */
onOpenChange?: (open: boolean) => void;
onSelect?: (item: CommandCenterItem) => void;
title?: ReactNode;
placeholder?: string;
emptyMessage?: string;
/** Header icon. Defaults to RiSearchLine. */
icon?: IconComponent;
closeLabel?: string;
/**
* Seconds to wait before playing the blur/fade/scale-up entrance after open.
* Omit for delay 0 — the palette still animates in.
*/
introDelay?: number;
/** Letter key for ⌘/Ctrl+shortcut toggle, e.g. `"k"`. */
shortcut?: string;
}
const styles = sortCx({
panel: [
"relative flex w-full max-w-[480px] flex-col overflow-hidden",
"rounded-3xl border border-border-button-default bg-background-primary-default shadow-dropdown",
].join(" "),
header: "flex items-center gap-2 border-b border-border-button-default px-3 py-3 pr-10",
headerIcon: "size-5 shrink-0 text-foreground-icon-secondary",
headerTitle: "min-w-0 flex-1 truncate text-body-medium text-text-primary",
close: "absolute right-3 top-3",
field: "border-b border-border-button-default px-3 py-3",
list: "flex max-h-[min(52vh,420px)] flex-col gap-1 overflow-y-auto p-2.5",
group: "flex w-full flex-col gap-1.5 pt-1",
groupLabel: "pl-2 text-body-medium text-text-secondary",
groupItems: "flex w-full flex-col gap-1",
item: [
"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
"text-text-primary outline-none transition-colors",
].join(" "),
itemActive: "bg-dropdown-item-hover-background",
itemDisabled: "cursor-not-allowed opacity-50",
itemIcon: "size-5 shrink-0 text-foreground-icon-secondary",
itemText: "flex min-w-0 flex-1 flex-col gap-0.5",
itemLabel: "truncate text-body-medium text-text-primary",
itemDescription: "truncate text-body-2-medium text-text-secondary",
empty: "px-3 py-8 text-center text-body-2-regular text-text-tertiary",
footer: [
"flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border-button-default",
"px-3 py-2 text-caption-1-regular text-text-tertiary",
].join(" "),
footerHint: "inline-flex items-center gap-1.5",
});
function scoreItem(item: CommandCenterItem, query: string): number {
const label = item.label.toLowerCase();
if (label === query) return 0;
if (label.startsWith(query)) return 1;
if (label.includes(query)) return 2;
if (item.group?.toLowerCase().includes(query)) return 3;
if (item.description?.toLowerCase().includes(query)) return 4;
if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(query))) return 5;
return Number.POSITIVE_INFINITY;
}
function filterItems(items: CommandCenterItem[], query: string): CommandCenterItem[] {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return items;
return items
.map((item) => ({ item, rank: scoreItem(item, trimmed) }))
.filter((row) => Number.isFinite(row.rank))
.sort((a, b) => a.rank - b.rank || a.item.label.localeCompare(b.item.label))
.map((row) => row.item);
}
function groupItems(items: CommandCenterItem[]): { label: string; items: CommandCenterItem[] }[] {
const order: string[] = [];
const map = new Map<string, CommandCenterItem[]>();
for (const item of items) {
const label = item.group?.trim() || "Commands";
if (!map.has(label)) {
map.set(label, []);
order.push(label);
}
map.get(label)!.push(item);
}
return order.map((label) => ({ label, items: map.get(label)! }));
}
export function CommandCenter({
items,
isOpen,
onClose,
onOpenChange,
onSelect,
title = "Command",
placeholder = "Search commands…",
emptyMessage = "Nothing matches that search.",
icon: Icon = RiSearchLine,
closeLabel = "Close command center",
introDelay,
shortcut,
}: CommandCenterProps) {
const listId = useId();
const reducedMotion = usePrefersReducedMotion();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [portalReady, setPortalReady] = useState(false);
const results = useMemo(() => filterItems(items, query), [items, query]);
const groups = useMemo(() => groupItems(results), [results]);
const flatIds = useMemo(() => results.map((item) => item.id), [results]);
useEffect(() => {
setPortalReady(true);
}, []);
useEffect(() => {
if (!isOpen) {
setQuery("");
setActive(0);
}
}, [isOpen]);
useEffect(() => {
setActive(0);
}, [query]);
useEffect(() => {
if (!isOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (!shortcut) return;
const key = shortcut.toLowerCase();
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return;
if (event.key.toLowerCase() !== key) return;
event.preventDefault();
if (isOpen) {
onClose();
onOpenChange?.(false);
} else {
onOpenChange?.(true);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [shortcut, isOpen, onClose, onOpenChange]);
useEffect(() => {
if (!isOpen) return;
const option = listRef.current?.querySelector<HTMLElement>(
`[data-command-id="${CSS.escape(flatIds[active] ?? "")}"]`,
);
option?.scrollIntoView({ block: "nearest" });
}, [active, flatIds, isOpen]);
const selectActive = () => {
const item = results[active];
if (!item || item.disabled) return;
onSelect?.(item);
};
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape") {
// Document-level listener also handles Escape; keep this for input focus.
event.preventDefault();
onClose();
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (results.length === 0) return;
setActive((index) => Math.min(index + 1, results.length - 1));
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (results.length === 0) return;
setActive((index) => Math.max(index - 1, 0));
return;
}
if (event.key === "Home") {
event.preventDefault();
setActive(0);
return;
}
if (event.key === "End") {
event.preventDefault();
if (results.length === 0) return;
setActive(results.length - 1);
return;
}
if (event.key === "Enter") {
event.preventDefault();
selectActive();
}
};
const activeId = flatIds[active] ? `${listId}-${flatIds[active]}` : undefined;
const enterTransition = {
duration: reducedMotion ? 0.12 : 0.25,
ease: "easeOut" as const,
delay: introDelay ?? 0,
};
const exitTransition = {
duration: reducedMotion ? 0.12 : 0.25,
ease: "easeInOut" as const,
};
const panelInitial = reducedMotion
? { opacity: 0, x: "-50%" }
: { opacity: 0, x: "-50%", y: 15, scale: 0.85, filter: "blur(6px)" };
const panelAnimate = reducedMotion
? { opacity: 1, x: "-50%", transition: enterTransition }
: {
opacity: 1,
x: "-50%",
y: 0,
scale: 1,
filter: "blur(0px)",
transition: enterTransition,
};
const panelExit = reducedMotion
? { opacity: 0, x: "-50%", transition: exitTransition }
: {
opacity: 0,
x: "-50%",
scale: 0.85,
filter: "blur(6px)",
transition: exitTransition,
};
if (!portalReady) return null;
const emptyCopy = query.trim()
? emptyMessage.includes("{query}")
? emptyMessage.replace("{query}", query.trim())
: `Nothing matches “${query.trim()}”.`
: emptyMessage;
return createPortal(
<AnimatePresence>
{isOpen ? (
<motion.button
key="command-center-backdrop"
type="button"
aria-label="Dismiss command center"
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: reducedMotion ? 0.12 : 0.2 },
}}
exit={{
opacity: 0,
transition: { duration: reducedMotion ? 0.12 : 0.2, ease: "easeInOut" },
}}
onClick={onClose}
className="fixed inset-0 z-50 cursor-default bg-black/40 backdrop-blur-[2px]"
/>
) : null}
{isOpen ? (
<motion.div
key="command-center-panel"
role="dialog"
aria-modal="true"
aria-label={typeof title === "string" ? title : "Command center"}
initial={panelInitial}
animate={panelAnimate}
exit={panelExit}
className={cx(
styles.panel,
"fixed top-[12vh] left-1/2 z-50 w-[calc(100%-2rem)] max-w-[480px] sm:top-[14vh]",
)}
>
<FocusScope contain restoreFocus>
<div className="relative flex w-full flex-col overflow-hidden">
<div className={styles.header}>
<Icon className={styles.headerIcon} aria-hidden />
<p className={styles.headerTitle}>{title}</p>
</div>
<div className={styles.field}>
<TextField
aria-label={placeholder}
value={query}
onChange={setQuery}
className="gap-0"
autoFocus
>
<InputBase
ref={inputRef}
leadingIcon={RiSearchLine}
placeholder={placeholder}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeId}
onKeyDown={onInputKeyDown}
/>
</TextField>
</div>
{results.length === 0 ? (
<p className={styles.empty}>{emptyCopy}</p>
) : (
<div
ref={listRef}
id={listId}
role="listbox"
aria-label="Commands"
className={styles.list}
>
{groups.map((group) => (
<div
key={group.label}
className={styles.group}
role="group"
aria-label={group.label}
>
<p className={styles.groupLabel}>{group.label}</p>
<div className={styles.groupItems}>
{group.items.map((item) => {
const index = flatIds.indexOf(item.id);
const isActive = index === active;
const ItemIcon = item.icon;
return (
<button
key={item.id}
id={`${listId}-${item.id}`}
type="button"
role="option"
data-command-id={item.id}
aria-selected={isActive}
disabled={item.disabled}
onMouseEnter={() => {
if (!item.disabled) setActive(index);
}}
onClick={() => {
if (item.disabled) return;
onSelect?.(item);
}}
className={cx(
styles.item,
isActive && styles.itemActive,
item.disabled && styles.itemDisabled,
)}
>
{ItemIcon ? (
<ItemIcon className={styles.itemIcon} aria-hidden />
) : null}
<span className={styles.itemText}>
<span className={styles.itemLabel}>{item.label}</span>
{item.description ? (
<span className={styles.itemDescription}>
{item.description}
</span>
) : null}
</span>
{item.shortcut ? <Kbd>{item.shortcut}</Kbd> : null}
</button>
);
})}
</div>
</div>
))}
</div>
)}
<div className={styles.footer}>
<span className={styles.footerHint}>
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
navigate
</span>
<span className={styles.footerHint}>
<Kbd>↵</Kbd>
select
</span>
<span className={styles.footerHint}>
<Kbd>esc</Kbd>
close
</span>
</div>
<CloseButton
size="xs"
aria-label={closeLabel}
onClick={onClose}
className={cx(styles.close, "z-10")}
/>
</div>
</FocusScope>
</motion.div>
) : null}
</AnimatePresence>,
document.body,
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
CommandCenter
| Prop | Type | Default | Description |
|---|---|---|---|
| isOpenrequired | boolean | — | — |
| itemsrequired | CommandCenterItem[] | — | — |
| onCloserequired | () => void | — | — |
| closeLabel | string | Close command center | — |
| emptyMessage | string | Nothing matches that search. | — |
| icon | IconComponent | — | Header icon. Defaults to RiSearchLine. |
| introDelay | number | — | Seconds to wait before playing the blur/fade/scale-up entrance after open. Omit for delay 0 — the palette still animates in. |
| onOpenChange | (open: boolean) => void | — | Needed for `shortcut` to open the palette (toggle). |
| onSelect | (item: CommandCenterItem) => void | — | — |
| placeholder | string | Search commands… | — |
| shortcut | string | — | Letter key for ⌘/Ctrl+shortcut toggle, e.g. `"k"`. |
| title | ReactNode | Command | — |