Command
A command palette that allows users to quickly search and execute commands using keyboard shortcuts.
Custom Trigger
Source
"use client";
import type { ComponentType, ReactNode } from "react";
import { useEffect, useState } from "react";
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger as AriaDialogTrigger,
Modal as AriaModal,
Autocomplete,
Input,
Menu,
MenuItem,
type MenuItemProps,
ModalOverlay,
type ModalOverlayProps,
TextField,
useFilter,
} from "react-aria-components";
import { tv } from "tailwind-variants";
import { Search } from "lucide-react";
const command = tv({
slots: {
trigger:
"flex w-full items-center justify-between rounded-full bg-secondary px-4 py-2 font-semibold text-fg outline-none ring-primary ring-offset-2 ring-offset-surface transition-colors data-[hovered]:bg-secondary/75 data-[focus-visible]:ring-2",
overlay:
"data-[entering]:fade-in data-[exiting]:fade-out fixed inset-0 z-50 flex min-h-full items-start justify-center bg-zinc-500/25 p-4 text-center data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:duration-300 data-[exiting]:duration-200 sm:items-center",
modal:
"data-[entering]:zoom-in-95 data-[exiting]:zoom-out-95 data-[entering]:animate-in data-[exiting]:animate-out data-[entering]:duration-300 data-[exiting]:duration-200",
dialog:
"flex min-h-96 min-w-80 max-w-full flex-col gap-1 rounded-2xl bg-surface p-2 shadow-lg outline-none md:w-lg",
input:
"rounded-lg border-b-2 border-none bg-transparent px-3 py-2 text-base text-fg leading-5 outline-none placeholder:text-fg-muted",
menu: "mt-2 h-80 overflow-auto",
item: "group flex min-h-12 w-full cursor-default items-center rounded-lg px-3 py-2 text-fg outline-none data-[focused]:bg-secondary data-[pressed]:bg-surface-3 data-[focused]:text-focus-fg",
kbd: "ml-auto rounded border border-border bg-surface-2 px-2 py-1 font-semibold text-fg-muted text-xs",
},
});
const styles = command();
interface CommandItem {
id: string;
label: string;
shortcut?: string;
icon?: ComponentType<{ className?: string }>;
onSelect?: () => void;
}
interface CommandProps extends Omit<ModalOverlayProps, "className"> {
className?: string;
trigger?: ReactNode;
commands: CommandItem[];
placeholder?: string;
triggerKey?: string;
onCommandSelect?: (command: CommandItem) => void;
onSearchChange?: (search: string) => void;
}
const Command = ({
className,
trigger,
commands,
placeholder = "Search commands…",
triggerKey = "k",
onCommandSelect,
onSearchChange,
...props
}: CommandProps) => {
const [isOpen, setOpen] = useState(false);
const [isMac, setIsMac] = useState(true);
const { contains } = useFilter({ sensitivity: "base" });
useEffect(() => {
setIsMac(/Mac/.test(navigator?.platform || ""));
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.key.toLowerCase() === triggerKey.toLowerCase() &&
(isMac ? e.metaKey : e.ctrlKey)
) {
e.preventDefault();
setOpen((prev) => !prev);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isMac, triggerKey]);
const handleCommandSelect = (command: CommandItem) => {
command.onSelect?.();
onCommandSelect?.(command);
setOpen(false);
};
return (
<AriaDialogTrigger isOpen={isOpen} onOpenChange={setOpen}>
{trigger || (
<AriaButton className={styles.trigger({ className })}>
<div className="flex items-center gap-2 text-fg-muted">
<Search className="size-4 text-fg-muted" />
Search
</div>
<kbd className="rounded-md border border-border px-2 py-1 font-semibold text-fg-muted text-xs">
{isMac ? "⌘" : "Ctrl"} {triggerKey.toUpperCase()}
</kbd>
</AriaButton>
)}
<ModalOverlay {...props} isDismissable className={styles.overlay()}>
<AriaModal className={styles.modal()}>
<AriaDialog className={styles.dialog()}>
<Autocomplete filter={onSearchChange ? () => true : contains}>
<TextField
aria-label="Search commands"
className="flex flex-col border-border border-b px-3 py-2 outline-none"
onChange={onSearchChange}
>
<Input
autoFocus
placeholder={placeholder}
className={styles.input()}
/>
</TextField>
<Menu
items={commands}
className={styles.menu()}
selectionMode="none"
>
{({ label, shortcut, icon: Icon, ...command }) => (
<CommandMenuItem
{...command}
textValue={label}
onAction={() =>
handleCommandSelect({
label,
shortcut,
icon: Icon,
...command,
})
}
>
<div className="flex min-w-0 items-center gap-3">
{Icon && (
<Icon className="size-4 shrink-0 text-fg-muted" />
)}
<span className="truncate font-medium text-sm leading-tight">
{label}
</span>
</div>
{shortcut && (
<span className={styles.kbd()}>{shortcut}</span>
)}
</CommandMenuItem>
)}
</Menu>
</Autocomplete>
</AriaDialog>
</AriaModal>
</ModalOverlay>
</AriaDialogTrigger>
);
};
interface CommandMenuItemProps extends Omit<MenuItemProps, "className"> {
className?: string;
children: ReactNode;
}
const CommandMenuItem = ({
className,
children,
...props
}: CommandMenuItemProps) => (
<MenuItem {...props} className={styles.item({ className })}>
{children}
</MenuItem>
);
const CommandTrigger = AriaDialogTrigger;
export { Command, CommandMenuItem, CommandTrigger };
export type { CommandProps, CommandMenuItemProps, CommandItem };