Command Palette
⌘K palette with fuzzy filter, spring-animated active row and glass surface.
Preview
Press ⌘ J (or Ctrl J) to open.
TSXcomponents/previews/blocks/command-palette.preview.tsx
"use client";
import { FileText, Home, Plus, Settings, User } from "lucide-react";
import { useState } from "react";
import { CommandPalette } from "@/components/motion/command-palette";
export function CommandPalettePreview() {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col items-start gap-3">
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-10 items-center rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground press hover:border-(--color-border-strong)"
>
Open command palette
</button>
<p className="text-sm text-muted-foreground">
Press{" "}
<kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-xs text-foreground">
⌘ J
</kbd>{" "}
(or <kbd className="rounded border border-border bg-card px-1.5 py-0.5 text-xs text-foreground">Ctrl J</kbd>) to open.
</p>
<CommandPalette
open={open}
onOpenChange={setOpen}
shortcut="j"
items={[
{ id: "home", label: "Go to Home", group: "Navigation", icon: Home, hint: "G H", onSelect: () => {} },
{ id: "profile", label: "Open profile", group: "Navigation", icon: User, hint: "G P", onSelect: () => {} },
{ id: "settings", label: "Settings", group: "Navigation", icon: Settings, onSelect: () => {} },
{ id: "new-doc", label: "Create document", group: "Actions", icon: FileText, hint: "⌘ N", onSelect: () => {} },
{ id: "new-project", label: "New project", group: "Actions", icon: Plus, hint: "⌘ ⇧ N", onSelect: () => {} },
]}
/>
</div>
);
}
TSXcomponents/motion/command-palette.tsx
"use client";
// beui.dev/components/blocks/command-palette
import { motion, useReducedMotion } from "motion/react";
import { Search, type LucideIcon } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type CommandItem = {
id: string;
label: string;
group?: string;
hint?: string;
keywords?: string[];
icon?: LucideIcon;
badge?: ReactNode;
onSelect: () => void;
};
export interface CommandPaletteProps {
items: CommandItem[];
/** Opens with Cmd/Ctrl + this key. Default: "k" */
shortcut?: string;
placeholder?: string;
emptyMessage?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function fuzzyMatch(needle: string, hay: string) {
if (!needle) return true;
needle = needle.toLowerCase();
hay = hay.toLowerCase();
let i = 0;
for (const ch of hay) {
if (ch === needle[i]) i++;
if (i === needle.length) return true;
}
return false;
}
// Opened via a keyboard shortcut many times a day — entrance must read as
// instant. Tight spring, even faster exit.
const PANEL_SPRING = {
type: "spring",
stiffness: 560,
damping: 40,
mass: 0.5,
} as const;
export function CommandPalette({
items,
shortcut = "k",
placeholder = "Type a command or search…",
emptyMessage = "No results found.",
open: controlledOpen,
onOpenChange,
}: CommandPaletteProps) {
const [internalOpen, setInternalOpen] = useState(false);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(v: boolean) => {
if (!controlled) setInternalOpen(v);
onOpenChange?.(v);
},
[controlled, onOpenChange],
);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
// Portal target only exists client-side; render nothing during SSR/hydration.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const uid = useId();
const reduce = useReducedMotion();
const updateQuery = useCallback((value: string) => {
setQuery(value);
setActive(0);
}, []);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === shortcut.toLowerCase()
) {
e.preventDefault();
setOpen(!open);
return;
}
if (e.key === "Escape" && open) {
e.preventDefault();
setOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, shortcut, setOpen]);
useEffect(() => {
if (open) {
updateQuery("");
setActive(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open, updateQuery]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const filtered = useMemo(() => {
if (!query) return items;
return items.filter((it) => {
const haystacks = [it.label, it.group ?? "", ...(it.keywords ?? [])];
return haystacks.some((h) => fuzzyMatch(query, h));
});
}, [items, query]);
// Reserve the icon column only when at least one item brings an icon, so
// icon-less lists don't render a dead gap before every label.
const hasIcons = useMemo(() => items.some((it) => it.icon), [items]);
const grouped = useMemo(() => {
const map = new Map<string, CommandItem[]>();
filtered.forEach((it) => {
const g = it.group ?? "Results";
const groupItems = map.get(g) ?? [];
groupItems.push(it);
map.set(g, groupItems);
});
return Array.from(map.entries());
}, [filtered]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(filtered.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const it = filtered[active];
if (it) {
it.onSelect();
setOpen(false);
}
}
};
useEffect(() => {
if (!open) return;
const el = listRef.current?.querySelector<HTMLButtonElement>(
`[data-index="${active}"]`,
);
el?.scrollIntoView({ block: "nearest" });
}, [active, open]);
let cursor = 0;
if (!mounted) return null;
// Always-mounted container; pointer events fully disabled when closed so clicks
// pass through to the page. Portaled to <body> so ancestors with transforms,
// filters, or fixed positioning can't trap the overlay in their stacking context.
return createPortal(
<div
aria-hidden={!open}
inert={!open}
className={cn(
"fixed inset-0 z-[100]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.button
type="button"
aria-label="Close command palette"
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{ duration: open ? 0.18 : 0.12, ease: EASE_OUT }}
onClick={() => setOpen(false)}
className={cn(
"absolute inset-0 bg-background/5 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-start justify-center p-4 pt-[18vh]">
<motion.div
role="dialog"
aria-modal="true"
aria-label="Command palette"
initial={false}
animate={{
opacity: open ? 1 : 0,
y: open || reduce ? 0 : -8,
scale: open || reduce ? 1 : 0.97,
}}
transition={
reduce
? { duration: 0.1 }
: open
? PANEL_SPRING
: { duration: 0.12, ease: EASE_OUT }
}
onKeyDown={onKeyDown}
className={cn(
"w-full max-w-xl overflow-hidden rounded-2xl border border-border bg-card shadow-2xl will-change-transform",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<div className="flex items-center gap-3 border-b border-border px-4">
<Search className="h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={placeholder}
tabIndex={open ? 0 : -1}
role="combobox"
aria-expanded={open}
aria-controls={`${uid}-list`}
aria-activedescendant={
filtered.length > 0 ? `${uid}-opt-${active}` : undefined
}
aria-autocomplete="list"
className="h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
<kbd className="hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground sm:inline-block">
ESC
</kbd>
</div>
<div
ref={listRef}
id={`${uid}-list`}
role="listbox"
aria-label="Commands"
className="max-h-[60vh] overflow-y-auto p-2"
>
{filtered.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
grouped.map(([group, list]) => (
<div key={group} className="mb-1 last:mb-0">
<div
aria-hidden
className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
{group}
</div>
{list.map((it) => {
const idx = cursor++;
const isActive = idx === active;
const Icon = it.icon;
return (
<button
key={it.id}
type="button"
id={`${uid}-opt-${idx}`}
role="option"
aria-selected={isActive}
data-index={idx}
onMouseEnter={() => setActive(idx)}
onClick={() => {
it.onSelect();
setOpen(false);
}}
tabIndex={open ? 0 : -1}
className={cn(
"relative isolate flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm transition-colors",
isActive
? "text-foreground"
: "text-muted-foreground",
)}
>
{isActive ? (
<motion.span
layoutId={`${uid}-active`}
className="absolute inset-0 z-0 rounded-md bg-primary/[0.05]"
transition={
reduce
? { duration: 0 }
: // Tracks rapid arrow-key navigation — keep it tighter
// than SPRING_LAYOUT so it never lags the active row.
{
type: "spring",
stiffness: 480,
damping: 38,
}
}
/>
) : null}
{Icon ? (
<Icon className="relative z-10 h-4 w-4" />
) : hasIcons ? (
<span className="relative z-10 h-4 w-4" />
) : null}
<span className="relative z-10 flex-1 truncate">
{it.label}
</span>
{it.badge ? (
<span className="relative z-10 shrink-0">
{it.badge}
</span>
) : null}
{it.hint ? (
<kbd className="relative z-10 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
{it.hint}
</kbd>
) : null}
</button>
);
})}
</div>
))
)}
</div>
</motion.div>
</div>
</div>,
document.body,
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/command-palette
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/command-palette.tsx
"use client";
// beui.dev/components/blocks/command-palette
import { motion, useReducedMotion } from "motion/react";
import { Search, type LucideIcon } from "lucide-react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type CommandItem = {
id: string;
label: string;
group?: string;
hint?: string;
keywords?: string[];
icon?: LucideIcon;
badge?: ReactNode;
onSelect: () => void;
};
export interface CommandPaletteProps {
items: CommandItem[];
/** Opens with Cmd/Ctrl + this key. Default: "k" */
shortcut?: string;
placeholder?: string;
emptyMessage?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function fuzzyMatch(needle: string, hay: string) {
if (!needle) return true;
needle = needle.toLowerCase();
hay = hay.toLowerCase();
let i = 0;
for (const ch of hay) {
if (ch === needle[i]) i++;
if (i === needle.length) return true;
}
return false;
}
// Opened via a keyboard shortcut many times a day — entrance must read as
// instant. Tight spring, even faster exit.
const PANEL_SPRING = {
type: "spring",
stiffness: 560,
damping: 40,
mass: 0.5,
} as const;
export function CommandPalette({
items,
shortcut = "k",
placeholder = "Type a command or search…",
emptyMessage = "No results found.",
open: controlledOpen,
onOpenChange,
}: CommandPaletteProps) {
const [internalOpen, setInternalOpen] = useState(false);
const controlled = controlledOpen !== undefined;
const open = controlled ? controlledOpen : internalOpen;
const setOpen = useCallback(
(v: boolean) => {
if (!controlled) setInternalOpen(v);
onOpenChange?.(v);
},
[controlled, onOpenChange],
);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
// Portal target only exists client-side; render nothing during SSR/hydration.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const uid = useId();
const reduce = useReducedMotion();
const updateQuery = useCallback((value: string) => {
setQuery(value);
setActive(0);
}, []);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === shortcut.toLowerCase()
) {
e.preventDefault();
setOpen(!open);
return;
}
if (e.key === "Escape" && open) {
e.preventDefault();
setOpen(false);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, shortcut, setOpen]);
useEffect(() => {
if (open) {
updateQuery("");
setActive(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open, updateQuery]);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const filtered = useMemo(() => {
if (!query) return items;
return items.filter((it) => {
const haystacks = [it.label, it.group ?? "", ...(it.keywords ?? [])];
return haystacks.some((h) => fuzzyMatch(query, h));
});
}, [items, query]);
// Reserve the icon column only when at least one item brings an icon, so
// icon-less lists don't render a dead gap before every label.
const hasIcons = useMemo(() => items.some((it) => it.icon), [items]);
const grouped = useMemo(() => {
const map = new Map<string, CommandItem[]>();
filtered.forEach((it) => {
const g = it.group ?? "Results";
const groupItems = map.get(g) ?? [];
groupItems.push(it);
map.set(g, groupItems);
});
return Array.from(map.entries());
}, [filtered]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(filtered.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
e.preventDefault();
const it = filtered[active];
if (it) {
it.onSelect();
setOpen(false);
}
}
};
useEffect(() => {
if (!open) return;
const el = listRef.current?.querySelector<HTMLButtonElement>(
`[data-index="${active}"]`,
);
el?.scrollIntoView({ block: "nearest" });
}, [active, open]);
let cursor = 0;
if (!mounted) return null;
// Always-mounted container; pointer events fully disabled when closed so clicks
// pass through to the page. Portaled to <body> so ancestors with transforms,
// filters, or fixed positioning can't trap the overlay in their stacking context.
return createPortal(
<div
aria-hidden={!open}
inert={!open}
className={cn(
"fixed inset-0 z-[100]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.button
type="button"
aria-label="Close command palette"
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{ duration: open ? 0.18 : 0.12, ease: EASE_OUT }}
onClick={() => setOpen(false)}
className={cn(
"absolute inset-0 bg-background/5 [backdrop-filter:blur(12px)_saturate(140%)] [-webkit-backdrop-filter:blur(12px)_saturate(140%)]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-start justify-center p-4 pt-[18vh]">
<motion.div
role="dialog"
aria-modal="true"
aria-label="Command palette"
initial={false}
animate={{
opacity: open ? 1 : 0,
y: open || reduce ? 0 : -8,
scale: open || reduce ? 1 : 0.97,
}}
transition={
reduce
? { duration: 0.1 }
: open
? PANEL_SPRING
: { duration: 0.12, ease: EASE_OUT }
}
onKeyDown={onKeyDown}
className={cn(
"w-full max-w-xl overflow-hidden rounded-2xl border border-border bg-card shadow-2xl will-change-transform",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<div className="flex items-center gap-3 border-b border-border px-4">
<Search className="h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
value={query}
onChange={(e) => updateQuery(e.target.value)}
placeholder={placeholder}
tabIndex={open ? 0 : -1}
role="combobox"
aria-expanded={open}
aria-controls={`${uid}-list`}
aria-activedescendant={
filtered.length > 0 ? `${uid}-opt-${active}` : undefined
}
aria-autocomplete="list"
className="h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
/>
<kbd className="hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground sm:inline-block">
ESC
</kbd>
</div>
<div
ref={listRef}
id={`${uid}-list`}
role="listbox"
aria-label="Commands"
className="max-h-[60vh] overflow-y-auto p-2"
>
{filtered.length === 0 ? (
<div className="p-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
grouped.map(([group, list]) => (
<div key={group} className="mb-1 last:mb-0">
<div
aria-hidden
className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
>
{group}
</div>
{list.map((it) => {
const idx = cursor++;
const isActive = idx === active;
const Icon = it.icon;
return (
<button
key={it.id}
type="button"
id={`${uid}-opt-${idx}`}
role="option"
aria-selected={isActive}
data-index={idx}
onMouseEnter={() => setActive(idx)}
onClick={() => {
it.onSelect();
setOpen(false);
}}
tabIndex={open ? 0 : -1}
className={cn(
"relative isolate flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm transition-colors",
isActive
? "text-foreground"
: "text-muted-foreground",
)}
>
{isActive ? (
<motion.span
layoutId={`${uid}-active`}
className="absolute inset-0 z-0 rounded-md bg-primary/[0.05]"
transition={
reduce
? { duration: 0 }
: // Tracks rapid arrow-key navigation — keep it tighter
// than SPRING_LAYOUT so it never lags the active row.
{
type: "spring",
stiffness: 480,
damping: 38,
}
}
/>
) : null}
{Icon ? (
<Icon className="relative z-10 h-4 w-4" />
) : hasIcons ? (
<span className="relative z-10 h-4 w-4" />
) : null}
<span className="relative z-10 flex-1 truncate">
{it.label}
</span>
{it.badge ? (
<span className="relative z-10 shrink-0">
{it.badge}
</span>
) : null}
{it.hint ? (
<kbd className="relative z-10 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
{it.hint}
</kbd>
) : null}
</button>
);
})}
</div>
))
)}
</div>
</motion.div>
</div>
</div>,
document.body,
);
}
API Reference
items{}—shortcut?stringOpens with Cmd/Ctrl + this key. Default: "k"
kplaceholder?stringType a command or search…emptyMessage?stringNo results found.open?boolean—onOpenChange?((open: boolean) => void)—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated