Command palette
⌘K opens a sheet that pops out of a blur. Type and rows re-sort; the highlight slides from row to row; the letters you typed are inked darker in each match.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// CommandPalette: ⌘K opens a sheet that pops out of a blur. Type and rows re-sort; the highlight slides from row to row;
// the letters you typed are inked darker in each match. The sheet is portalled to the body. By default the block draws
// its own trigger button; pass `trigger={null}` to open it only with ⌘K or the `open` prop.
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
import { Bell, Folder, Home, Search, Settings, Share2, Sparkles, User, Zap } from "lucide-react";
export type Command = {
id: string;
label: string;
group?: string;
icon?: ReactNode;
/** Shortcut hint shown at the right of the row. */
kbd?: string;
/** Small grey note after the label. */
hint?: string;
/** Called when this command is chosen. */
run?: () => void;
};
export type CommandPaletteCue = "unfold" | "fold" | "push" | "pip";
export type CommandPaletteProps = {
/** The commands, grouped by `group` while the query is empty. */
commands?: Command[];
/** Open state (controlled). */
open?: boolean;
/** Called when the sheet opens or closes. */
onOpenChange?: (o: boolean) => void;
/** Called with the command when one is chosen. */
onRun?: (c: Command) => void;
/** ⌘K / Ctrl+K toggles it. */
hotkey?: boolean;
/** Placeholder of the search field. */
placeholder?: string;
/** Text of the built-in trigger button. */
triggerLabel?: string;
/** Replace the built-in trigger with your own node, or pass null for none. */
trigger?: ReactNode;
/** Sound hook: "unfold"/"fold" on open/close, "push" on run, "pip" on arrow keys. */
onSound?: (cue: CommandPaletteCue) => void;
className?: string;
style?: CSSProperties;
};
const DEFAULT_COMMANDS: Command[] = [
{ id: "new", label: "New project", group: "Actions", icon: <Zap />, kbd: "N" },
{ id: "invoice", label: "Send invoice", group: "Actions", icon: <Share2 /> },
{ id: "invite", label: "Invite teammate", group: "Actions", icon: <User /> },
{ id: "home", label: "Go to Home", group: "Navigate", icon: <Home />, kbd: "G H" },
{ id: "projects", label: "Go to Projects", group: "Navigate", icon: <Folder />, kbd: "G P" },
{ id: "settings", label: "Open settings", group: "Navigate", icon: <Settings />, kbd: "," },
{ id: "theme", label: "Toggle theme", group: "Preferences", icon: <Sparkles />, hint: "Light / dark" },
{ id: "sound", label: "Toggle sound", group: "Preferences", icon: <Bell /> },
];
const join = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");
const ROW_HIGHLIGHT = { type: "spring", stiffness: 420, damping: 34, mass: 0.6 } as const;
const SHEET_SPRING = { type: "spring", stiffness: 500, damping: 30, mass: 0.8 } as const;
const EXIT_EASE = [0.4, 0, 1, 1] as const;
const TOCK_GAP_MS = 30;
type Match = { command: Command; matched: number[]; score: number };
/** Subsequence match of `query` in `label`: the matched character positions (for inking) and a score, or null. */
function fuzzyMatch(query: string, label: string): { matched: number[]; score: number } | null {
if (!query) return { matched: [], score: 0 };
const wanted = query.toLowerCase();
const text = label.toLowerCase();
const matched: number[] = [];
let next = 0;
let run = 0;
let score = 0;
for (let at = 0; at < text.length && next < wanted.length; at++) {
if (text[at] === wanted[next]) {
matched.push(at);
run++;
const startsWord = at === 0 || text[at - 1] === " ";
score += run * 2 + (startsWord ? 3 : 0);
next++;
} else {
run = 0;
}
}
return next === wanted.length ? { matched, score: score - text.length * 0.02 } : null;
}
/** The matching commands, best first while searching, otherwise grouped in their given order. */
function filterCommands(commands: Command[], query: string): { name: string; matches: Match[] }[] {
const matches: Match[] = [];
for (const command of commands) {
const found = fuzzyMatch(query, command.label);
if (found) matches.push({ command, ...found });
}
if (query) return [{ name: "", matches: matches.sort((a, b) => b.score - a.score) }];
const groups = new Map<string, Match[]>();
for (const match of matches) {
const name = match.command.group ?? "";
groups.set(name, [...(groups.get(name) ?? []), match]);
}
return [...groups].map(([name, grouped]) => ({ name, matches: grouped }));
}
type SheetProps = {
commands: Command[];
placeholder: string;
onRun: (command: Command) => void;
onClose: () => void;
onSound?: (cue: CommandPaletteCue) => void;
};
/** The sheet itself: search field, grouped results, keyboard highlight. */
function PaletteSheet({ commands, placeholder, onRun, onClose, onSound }: SheetProps) {
const id = useId();
const [query, setQuery] = useState("");
const [hot, setHot] = useState(0);
const input = useRef<HTMLInputElement>(null);
const list = useRef<HTMLDivElement>(null);
const lastTockAt = useRef(0);
const groups = useMemo(() => filterCommands(commands, query), [commands, query]);
const flat = groups.flatMap((group) => group.matches);
const hotCommand = flat[hot]?.command;
const tock = () => {
const now = performance.now();
if (now - lastTockAt.current < TOCK_GAP_MS) return;
lastTockAt.current = now;
onSound?.("pip");
};
const search = (next: string) => {
setQuery(next);
setHot(0);
};
// focus goes into the sheet on open and back to whatever opened it on close
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
input.current?.focus();
return () => opener?.focus?.({ preventScroll: true });
}, []);
useEffect(() => {
list.current?.querySelector<HTMLElement>(`[data-i="${hot}"]`)?.scrollIntoView({ block: "nearest" });
}, [hot]);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setHot((current) => Math.min(flat.length - 1, current + 1));
tock();
break;
case "ArrowUp":
event.preventDefault();
setHot((current) => Math.max(0, current - 1));
tock();
break;
case "Home":
event.preventDefault();
setHot(0);
break;
case "End":
event.preventDefault();
setHot(flat.length - 1);
break;
case "Enter":
if (!hotCommand) break;
event.preventDefault();
onRun(hotCommand);
break;
case "Escape":
event.preventDefault();
onClose();
break;
case "Tab": // the field is the sheet's only tab stop
event.preventDefault();
break;
}
};
return (
<motion.div
className="tcmd-veil"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.14 } }}
transition={{ duration: 0.18 }}
onPointerDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<motion.div
className="tcmd-box"
role="dialog"
aria-modal
aria-label="Command palette"
initial={{ opacity: 0, scale: 0.94, y: -10, filter: "blur(8px)" }}
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }}
exit={{ opacity: 0, scale: 0.96, y: -6, filter: "blur(6px)", transition: { duration: 0.14, ease: EXIT_EASE } }}
transition={{ ...SHEET_SPRING, opacity: { duration: 0.16 }, filter: { duration: 0.22 } }}
onKeyDown={onKeyDown}
>
<div className="tcmd-head">
<Search size={16} strokeWidth={2} aria-hidden />
<input
ref={input}
className="tcmd-in"
value={query}
placeholder={placeholder}
role="combobox"
aria-label={placeholder}
aria-autocomplete="list"
aria-expanded
aria-controls={`${id}-list`}
aria-activedescendant={hotCommand ? `${id}-${hotCommand.id}` : undefined}
autoComplete="off"
spellCheck={false}
onChange={(event) => search(event.target.value)}
/>
<kbd className="tcmd-esc">esc</kbd>
</div>
<div ref={list} id={`${id}-list`} role="listbox" className="tcmd-list">
{flat.length === 0 && <div className="tcmd-none">Nothing matches “{query}”</div>}
{groups.map((group) => (
<div key={group.name || "ungrouped"} className="tcmd-group" role="group" aria-label={group.name || undefined}>
{group.name && <div className="tcmd-gname" aria-hidden>{group.name}</div>}
{group.matches.map((match) => {
const { command } = match;
const index = flat.indexOf(match);
const isHot = index === hot;
return (
<div
key={command.id}
id={`${id}-${command.id}`}
role="option"
aria-selected={isHot}
data-i={index}
data-hot={isHot || undefined}
className="tcmd-row"
onPointerMove={() => setHot(index)}
onClick={() => onRun(command)}
>
{isHot && <motion.span className="tcmd-hl" layoutId={`${id}-highlight`} transition={ROW_HIGHLIGHT} />}
{command.icon && <span className="tcmd-ico">{command.icon}</span>}
<span className="tcmd-label">
{[...command.label].map((character, at) => (
<span key={at} data-ink={match.matched.includes(at) || undefined}>
{character}
</span>
))}
{command.hint && <span className="tcmd-hint">{command.hint}</span>}
</span>
{command.kbd && <kbd className="tcmd-kbd">{command.kbd}</kbd>}
</div>
);
})}
</div>
))}
</div>
</motion.div>
</motion.div>
);
}
export function CommandPalette({
commands = DEFAULT_COMMANDS,
open: controlledOpen,
onOpenChange,
onRun,
hotkey = true,
placeholder = "Type a command…",
triggerLabel = "Command palette",
trigger,
onSound,
className,
style,
}: CommandPaletteProps) {
const [ownOpen, setOwnOpen] = useState(false);
const open = controlledOpen ?? ownOpen;
const setOpen = (next: boolean, quiet = false) => {
if (next === open) return;
if (!quiet) onSound?.(next ? "unfold" : "fold");
if (controlledOpen === undefined) setOwnOpen(next);
onOpenChange?.(next);
};
const toggleRef = useRef(() => setOpen(!open));
toggleRef.current = () => setOpen(!open);
useEffect(() => {
if (!hotkey) return;
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return;
event.preventDefault();
toggleRef.current();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [hotkey]);
const run = (command: Command) => {
onSound?.("push");
setOpen(false, true);
command.run?.();
onRun?.(command);
};
const button =
trigger === undefined ? (
<button
type="button"
className={join("tcmd-trigger", className)}
style={style}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<span>{triggerLabel}</span>
<span className="tcmd-trigger-kbd">
<kbd>⌘K</kbd>
</span>
</button>
) : (
trigger
);
if (typeof document === "undefined") return button;
return (
<>
{button}
{createPortal(
<MotionConfig reducedMotion="user">
<AnimatePresence>
{open && (
<PaletteSheet
key="sheet"
commands={commands}
placeholder={placeholder}
onRun={run}
onClose={() => setOpen(false)}
onSound={onSound}
/>
)}
</AnimatePresence>
</MotionConfig>,
document.body,
)}
</>
);
}
/* basics: the host app may have none */
:where(.tcmd-trigger, .tcmd-veil),
:where(.tcmd-trigger, .tcmd-veil) *,
:where(.tcmd-trigger, .tcmd-veil) *::before,
:where(.tcmd-trigger, .tcmd-veil) *::after {
box-sizing: border-box;
}
.tcmd-trigger, .tcmd-veil {
font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.tcmd-trigger button, .tcmd-veil button) {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
:where(.tcmd-trigger input, .tcmd-veil input) {
font: inherit;
color: inherit;
}
:where(.tcmd-trigger, .tcmd-veil) :focus-visible {
outline: 2px solid var(--ts-focus);
outline-offset: 2px;
}
.tcmd-veil {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 14vh;
background: color-mix(in srgb, var(--ts-page) 40%, transparent);
backdrop-filter: blur(6px);
}
.tcmd-box {
width: min(520px, calc(100vw - 32px));
max-height: 60vh;
display: flex;
flex-direction: column;
border-radius: var(--ts-r-pop);
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
overflow: hidden;
}
.tcmd-head {
flex: none; /* a long list must scroll, not squeeze the field */
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px;
height: 52px;
border-bottom: 1px solid var(--ts-border);
color: var(--ts-fg-muted);
}
.tcmd-in {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
padding: 0;
background: transparent;
color: var(--ts-fg);
font: inherit;
font-size: 14px;
letter-spacing: -0.01em;
outline: 0;
}
.tcmd-in::placeholder {
color: var(--ts-fg-faint);
}
.tcmd-esc,
.tcmd-kbd {
font: inherit;
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 6px;
color: var(--ts-fg-muted);
background: rgba(var(--ts-fg-rgb), 0.06);
}
.tcmd-list {
overflow-y: auto;
padding: 6px;
overscroll-behavior: contain;
}
.tcmd-group + .tcmd-group {
margin-top: 4px;
}
.tcmd-gname {
padding: 8px 10px 4px;
font-size: 12px;
font-weight: 500;
color: var(--ts-fg-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.tcmd-row {
position: relative;
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 10px;
border-radius: calc(var(--ts-r-pop) - 6px);
cursor: pointer;
color: var(--ts-fg-secondary);
transition:
color 0.16s cubic-bezier(.22, 1, .36, 1),
scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcmd-row:active {
scale: 0.985;
}
.tcmd-row[data-hot] {
color: var(--ts-fg);
}
.tcmd-hl {
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(var(--ts-fg-rgb), 0.07);
}
.tcmd-ico,
.tcmd-label,
.tcmd-kbd {
position: relative;
}
.tcmd-ico {
display: inline-grid;
color: var(--ts-fg-muted);
}
.tcmd-ico svg {
width: 16px;
height: 16px;
}
.tcmd-label {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: baseline;
font-size: 14px;
letter-spacing: -0.01em;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.tcmd-label > span {
color: var(--ts-fg-tertiary);
transition: color 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcmd-label > span[data-ink] {
color: var(--ts-fg);
font-weight: 600;
}
.tcmd-row[data-hot] .tcmd-label > span {
color: var(--ts-fg-secondary);
}
.tcmd-row[data-hot] .tcmd-label > span[data-ink] {
color: var(--ts-fg);
}
.tcmd-hint {
margin-left: 8px;
font-size: 12px;
color: var(--ts-fg-faint) !important;
font-weight: 400 !important;
}
.tcmd-none {
padding: 22px 10px;
text-align: center;
font-size: 13px;
color: var(--ts-fg-muted);
}
/* built-in trigger: a filled button with the hotkey hint */
.tcmd-trigger {
position: relative;
height: 40px;
padding: 0 18px 0 20px;
border: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 16px; /* room between the label and the hotkey hint */
font-family: var(--ts-font, system-ui, sans-serif);
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1), box-shadow 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tcmd-trigger:hover {
background: rgba(var(--ts-fg-rgb), 0.05);
}
.tcmd-trigger:active {
scale: 0.96;
}
.tcmd-trigger:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-page), 0 0 0 4px var(--ts-accent);
}
.tcmd-trigger-kbd {
display: grid;
place-items: center;
min-width: 1.15em;
height: 1.15em;
}
.tcmd-trigger-kbd kbd {
font: inherit;
font-size: 11px;
opacity: 0.6;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { CommandPalette } from "./CommandPalette";
import "./CommandPalette.css";
export default function Demo() {
return <CommandPalette />;
}