Command Palette
A Cmd or Ctrl+K launcher for your app: fuzzy search across labels and keywords, grouped results with hints and badges, arrow-key navigation with a springy highlight on the current row, and a frosted panel.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true on devices that can be touched, whatever else they claim.
*
* This is not the inverse of `useHoverCapable`: iPadOS Safari browses
* desktop-class and answers `(hover: hover) and (pointer: fine)` with true
* while a finger is the only input there is, so anything that treats
* hover-capable as "no touch here" strands every iPad. Gate the *touch path*
* of an interaction on this hook and leave hover-only polish on
* `useHoverCapable`, so a component that opens on hover also opens on tap.
*/
export function useTouchCapable() {
const [canTouch, setCanTouch] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia?.("(any-pointer: coarse)");
// iPadOS disguises its pointer media queries; maxTouchPoints it reports
// honestly, which is what makes it the standard iPad tell.
const update = () =>
setCanTouch(Boolean(mq?.matches) || navigator.maxTouchPoints > 0);
update();
mq?.addEventListener?.("change", update);
return () => mq?.removeEventListener?.("change", update);
}, []);
return canTouch;
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/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 { useTouchCapable } from "@/lib/hooks/use-touch-capable";
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 canTouch = useTouchCapable();
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 root = document.documentElement;
const previousRootOverflow = root.style.overflow;
const previousBodyOverflow = document.body.style.overflow;
root.style.overflow = "hidden";
document.body.style.overflow = "hidden";
return () => {
root.style.overflow = previousRootOverflow;
document.body.style.overflow = previousBodyOverflow;
};
}, [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={cn(
"h-12 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none",
// The palette focuses this field the moment it opens, and iOS
// zooms the page in on a focused field under 16px: the fixed
// overlay is magnified off-center — clipped leading edge, half
// an icon column — and the zoom outlives the palette. 16px on
// touch keeps the page at scale 1; pointer devices keep 14px.
canTouch && "text-base",
)}
/>
<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 overscroll-contain p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{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,
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
itemsCommandItem[]—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