Block
Command palette
A searchable command list with groups, shortcuts, and keyboard selection.
- #arc
- #block
- #command
- #search
- #palette
- #keyboard
- #overlay
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/command-palette.jsonFiles
components/arc/blocks/command-palette/command-palette.module.csscomponents/arc/blocks/command-palette/command-palette.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdowncommand-palette.module.csscommand-palette.module.csscommand-palette.module.csscss
.palette {
width: 100%;
overflow: hidden;
border: 1px solid var(--float-edge, var(--surface));
border-radius: var(--radius-panel);
background: var(--surface);
box-shadow: var(--shadow-floating);
transform-origin: top center;
}
.searchRow {
position: relative;
display: flex;
min-height: 54px;
align-items: center;
gap: 10px;
padding: 8px 14px;
border-bottom: 1px solid var(--border-subtle);
transition: background var(--duration-fast) var(--ease-standard);
}
/* While typing the row fills with the accent; the panel's own edge (same color as its fill) would then show as a pale frame around it, so it steps aside. The width stays, so nothing shifts. */
.palette:has(input:focus-visible) { border-color: transparent; background-clip: padding-box; }
/* Focus fills the row with the accent, like Input; icon, text and keys switch to the accent foreground. */
.searchRow:has(input:focus-visible) { border-bottom-color: var(--accent); background: var(--accent); }
.searchRow:has(input:focus-visible) .searchIcon, .searchRow:has(input:focus-visible) .clearButton { color: var(--accent-foreground); }
.searchRow:has(input:focus-visible) input { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.searchRow:has(input:focus-visible) input::placeholder { color: var(--accent-foreground-muted); }
.searchRow:has(input:focus-visible) .commandKey, .searchRow:has(input:focus-visible) .closeButton { border-color: color-mix(in oklab, var(--accent-foreground) 40%, transparent); background: transparent; color: var(--accent-foreground-muted); }
.searchRow:has(input:focus-visible) .clearButton:active, .searchRow:has(input:focus-visible) .closeButton:active { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); }
@media (hover: hover) and (pointer: fine) { .searchRow:has(input:focus-visible) .clearButton:hover, .searchRow:has(input:focus-visible) .closeButton:hover { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); } }
.searchIcon, .itemIcon { display: grid; flex: none; place-items: center; color: var(--text-muted); }
.searchIcon { width: 22px; height: 22px; }
.searchRow input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
.searchRow input::placeholder { color: var(--text-muted); }
.searchRow input:focus-visible { outline: none; }
.commandKey, .shortcut {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border: 1px solid transparent;
border-radius: 7px;
background: var(--surface-muted);
color: var(--text-muted);
font-family: inherit;
font-size: var(--text-xs);
font-weight: 500;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.commandKey { min-width: 38px; height: 24px; padding: 0 6px; gap: 2px; }
.clearButton { display: grid; width: 24px; height: 24px; flex: none; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .clearButton:hover, .closeButton:hover { background: var(--surface); color: var(--foreground); } }
.clearButton:active, .closeButton:active { background: var(--surface); color: var(--foreground); }
.closeButton { display: grid; min-width: 27px; height: 24px; flex: none; place-items: center; border: 1px solid var(--border-subtle); border-radius: 7px; background: transparent; color: var(--text-muted); padding: 0 6px; font: inherit; font-size: var(--text-xs); font-weight: 500; line-height: 1; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
/* The frame animates to the list height; the list keeps its own scroll. */
.resultsFrame { overflow: hidden; }
.results { position: relative; max-height: 342px; overflow: auto; padding: 6px 8px; }
.highlight { position: absolute; top: 0; right: 8px; left: 8px; border-radius: 8px; background: var(--surface-muted); opacity: 0; pointer-events: none; }
/* Groups are positioned so a row that pops out of the flow is placed against the same box it was measured in, even mid-glide. */
.group { position: relative; }
.group + .group { margin-top: 6px; }
.groupHeading { display: block; padding: 6px 8px 3px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); }
/* One line per result: the title, then its description in muted text, so the list stays short and scannable. */
.result { position: relative; display: flex; width: 100%; min-height: 34px; align-items: center; gap: 10px; border: 0; border-radius: 8px; padding: 0 8px; background: transparent; color: var(--foreground); text-align: left; cursor: pointer; }
/* The active row is drawn by the shared highlight; the row itself only tints its icon and arrow. */
.itemIcon { transition: color var(--duration-fast) var(--ease-standard); }
.itemIcon { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--text-muted); }
.itemIcon svg { width: 16px; height: 16px; }
.resultCopy { display: flex; min-width: 0; flex: 1; align-items: baseline; gap: 8px; }
.resultCopy strong, .resultCopy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.resultCopy strong { flex: none; max-width: 100%; }
.resultCopy small { min-width: 0; }
.resultCopy strong { font-size: var(--text-sm); font-weight: 500; }
.resultCopy small { color: var(--text-muted); font-size: var(--text-xs); }
.shortcut { min-width: 18px; height: 18px; padding: 0 4px; border-color: transparent; border-radius: 5px; font-size: 11px; }
.activeResult
.empty { display: grid; justify-items: center; gap: 4px; padding: 26px 12px 28px; text-align: center; }
.empty > span { display: grid; width: 32px; height: 32px; place-items: center; margin-bottom: 5px; color: var(--text-muted); }
.empty strong { font-size: var(--text-sm); font-weight: 500; }
.empty small { color: var(--text-muted); font-size: var(--text-xs); }
.visuallyHidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (max-width: 480px) {
.searchRow { gap: 8px; padding: 8px 11px; }
.results { padding: 6px; }
.highlight { right: 6px; left: 6px; }
.result { gap: 8px; }
.shortcut, .commandKey { display: none; }
}
@media (max-width: 340px) {
.result { padding-inline: 6px; }
}
@media (prefers-reduced-motion: reduce) {
.searchRow, .itemIcon, .clearButton, .closeButton { transition: none; }
}
command-palette.tsxcommand-palette.tsxcommand-palette.tsxtsx
"use client";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import type { Transition, ValueAnimationTransition, Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { CommandIcon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./command-palette.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
export interface CommandItem { id: string; label: string; description?: string; group?: string; keywords?: string[]; icon?: ReactNode; shortcut?: string; }
export interface CommandPaletteProps { items: CommandItem[]; placeholder?: string; onSelect?: (item: CommandItem) => void; onClose?: () => void; label?: string; /** Focus the search field on mount, for a palette that opens on demand. */ autoFocus?: boolean; /** The search text, when the parent owns it (for results that come from somewhere else). */ query?: string; onQueryChange?: (query: string) => void; /** false: `items` are already the matches (a server search), so the palette lists them as they are. */ filter?: boolean; }
const enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };
const leave: Transition = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] };
const GLIDE_ROWS = 6;
/** Rows fade out while the rest glide into their place; when the list snaps they leave at once so nothing overlaps the new rows. */
const rowExit: Variants = { exit: (glide: boolean) => ({ opacity: 0, transition: glide ? leave : { duration: 0 } }) };
export function CommandPalette({ items, placeholder = "명령 검색하기", onSelect, onClose, label = "명령 팔레트", autoFocus = false, query: queryProp, onQueryChange, filter = true }: CommandPaletteProps) {
const inputId = useId();
const [ownQuery, setOwnQuery] = useState("");
const query = queryProp ?? ownQuery;
const setQuery = (next: string) => { setOwnQuery(next); onQueryChange?.(next); };
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [listHeight, setListHeight] = useState<number | "auto">("auto");
const reduced = useReducedMotion();
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const pointer = useRef(false);
const lastPointer = useRef({ x: -1, y: -1 });
const highlightShown = useRef(false);
const highlightY = useMotionValue(0);
const highlightHeight = useMotionValue(0);
const highlightOpacity = useMotionValue(0);
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!filter || !normalized) return items;
return items.filter(item => [item.label, item.description, item.group, ...(item.keywords ?? [])].filter(Boolean).join(" ").toLowerCase().includes(normalized));
}, [items, query, filter]);
const groupedItems = useMemo(() => {
const grouped = new Map<string, Array<{ item: CommandItem; index: number }>>();
filtered.forEach((item, index) => {
const group = item.group ?? "작업";
const entries = grouped.get(group) ?? [];
entries.push({ item, index });
grouped.set(group, entries);
});
return [...grouped.entries()];
}, [filtered]);
// When every remaining row moves within about a viewport, the list closes its gaps with a glide. A long jump (the first letters typed into a long list) snaps like a search result list instead of streaking rows through the frame; only the frame height morphs.
const [shift, setShift] = useState({ list: filtered, glide: true });
if (shift.list !== filtered) {
const before = new Map(shift.list.map((item, index) => [item.id, index]));
setShift({ list: filtered, glide: filtered.every((item, index) => Math.abs((before.get(item.id) ?? index) - index) <= GLIDE_ROWS) });
}
const glide = shift.glide && !reduced;
const safeActiveIndex = activeIndex === null
? -1
: Math.min(activeIndex, Math.max(filtered.length - 1, 0));
const activeId = filtered[safeActiveIndex]?.id;
useEffect(() => {
if (autoFocus) inputRef.current?.focus({ preventScroll: true });
}, [autoFocus]);
useEffect(() => {
const focusShortcut = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", focusShortcut);
return () => document.removeEventListener("keydown", focusShortcut);
}, []);
// The results frame follows the list height, so filtering closes or opens the gap instead of snapping.
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const observer = new ResizeObserver(([entry]) => setListHeight(Math.round(entry?.borderBoxSize?.[0]?.blockSize ?? list.offsetHeight)));
observer.observe(list);
return () => observer.disconnect();
}, []);
// One highlight follows the active result: a spring for the pointer, a 70ms slide for arrow keys, a fade when it first appears.
useLayoutEffect(() => {
const node = activeId ? document.getElementById(`${inputId}-${activeId}`) : null;
if (!node) {
highlightShown.current = false;
animate(highlightOpacity, 0, { duration: reduced ? 0 : motionTokens.duration.instant });
return;
}
const move: ValueAnimationTransition<number> = !highlightShown.current || reduced ? { duration: 0 } : pointer.current ? motionTokens.spring.snappy : { duration: motionTokens.duration.micro, ease: [...motionTokens.ease.enter] };
// Rows sit inside positioned groups, so their offset is summed up to the list (layout transforms stay out of it).
let top = 0;
for (let element: HTMLElement | null = node; element && element !== listRef.current; element = element.offsetParent as HTMLElement | null) top += element.offsetTop + (element === node ? 0 : element.clientTop);
animate(highlightY, top, move);
animate(highlightHeight, node.offsetHeight, move);
animate(highlightOpacity, 1, { duration: reduced ? 0 : motionTokens.duration.micro });
highlightShown.current = true;
}, [activeId, inputId, reduced, highlightY, highlightHeight, highlightOpacity]);
useEffect(() => {
if (!activeId) return;
document.getElementById(`${inputId}-${activeId}`)?.scrollIntoView({ block: "nearest" });
}, [activeId, inputId]);
function choose(item: CommandItem) {
playUiSound("select");
onSelect?.(item);
setQuery("");
setActiveIndex(null);
inputRef.current?.focus();
}
function handleKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
pointer.current = false;
if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex(index => Math.min((index ?? -1) + 1, Math.max(filtered.length - 1, 0))); }
if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex(index => Math.max((index ?? filtered.length) - 1, 0)); }
if (event.key === "Home") { event.preventDefault(); setActiveIndex(0); }
if (event.key === "End") { event.preventDefault(); setActiveIndex(Math.max(filtered.length - 1, 0)); }
// Enter runs the highlighted result; after typing, with nothing highlighted yet, it runs the top match.
const target = filtered[safeActiveIndex] ?? (query ? filtered[0] : undefined);
if (event.key === "Enter" && target) { event.preventDefault(); choose(target); }
if (event.key === "Escape") {
event.preventDefault();
if (query) { setQuery(""); setActiveIndex(null); }
else onClose?.();
}
}
return <motion.div className={styles.palette} initial={reduced ? false : { opacity: 0, y: 6, scale: .98 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={reduced ? { duration: 0 } : { default: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } }}>
<div className={styles.searchRow}>
<span className={styles.searchIcon}><UiIcon iconRole="search" width={18} height={18} strokeWidth={1.75} aria-hidden="true"/></span>
<label className={styles.visuallyHidden} htmlFor={inputId}>{label}</label>
<input ref={inputRef} id={inputId} role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls={`${inputId}-results`} aria-activedescendant={activeId ? `${inputId}-${activeId}` : undefined} value={query} onChange={event => { setQuery(event.target.value); setActiveIndex(null); }} onKeyDown={handleKeyDown} placeholder={placeholder} autoComplete="off"/>
<AnimatePresence mode="popLayout" initial={false}>
{query ? <motion.button key="clear" className={styles.clearButton} type="button" aria-label="검색어 지우기" onClick={() => { setQuery(""); setActiveIndex(null); inputRef.current?.focus(); }} initial={reduced ? false : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)`, transition: leave }} transition={reduced ? { duration: 0 } : { default: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.instant } }}><UiIcon iconRole="close" width={15} height={15} aria-hidden="true"/></motion.button> : null}
</AnimatePresence>
{onClose ? <button className={styles.closeButton} type="button" aria-label="명령 팔레트 닫기" onClick={onClose}>Esc</button> : null}
<kbd className={styles.commandKey}>⌘ K</kbd>
</div>
<motion.div className={styles.resultsFrame} initial={false} animate={{ height: listHeight }} transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}>
<motion.div ref={listRef} layoutScroll id={`${inputId}-results`} className={styles.results} role="listbox" aria-label="명령 검색 결과">
<motion.span className={styles.highlight} style={{ y: highlightY, height: highlightHeight, opacity: highlightOpacity }} aria-hidden="true"/>
{/* Results leave before the empty state arrives, so the frame makes one height change instead of two. */}
<AnimatePresence mode="wait" initial={false}>
{filtered.length ? <motion.div key="results" initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, transition: leave }} transition={enter}>
{/* Filtered-out rows pop out of the flow as they fade, so the rest glide up and the frame starts closing on the same keystroke. */}
<AnimatePresence mode="popLayout" initial={false} custom={glide}>
{groupedItems.map(([group, entries]) => <motion.div layout={glide ? "position" : false} className={styles.group} role="group" aria-label={group} key={group} variants={rowExit} initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit="exit" transition={reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }}>
<span className={styles.groupHeading} aria-hidden="true">{group}</span>
<AnimatePresence mode="popLayout" initial={false} custom={glide}>{entries.map(({ item, index }) => <motion.button layout={glide ? "position" : false} key={item.id} id={`${inputId}-${item.id}`} className={`${styles.result} ${index === safeActiveIndex ? styles.activeResult : ""}`} type="button" role="option" aria-selected={index === safeActiveIndex} onClick={() => choose(item)} onPointerMove={event => {
// Ignore the synthetic moves browsers send while the list scrolls under a still pointer.
if (event.clientX === lastPointer.current.x && event.clientY === lastPointer.current.y) return;
lastPointer.current = { x: event.clientX, y: event.clientY };
if (index !== safeActiveIndex) { pointer.current = true; setActiveIndex(index); }
}} variants={rowExit} initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit="exit" transition={reduced ? { duration: 0 } : { default: enter, layout: motionTokens.spring.smooth }}><span className={styles.itemIcon} aria-hidden="true">{item.icon ?? <HugeiconsIcon icon={CommandIcon} width={16} height={16}/>}</span><span className={styles.resultCopy}><strong>{item.label}</strong>{item.description && <small>{item.description}</small>}</span>{item.shortcut && <kbd className={styles.shortcut}>{item.shortcut}</kbd>}</motion.button>)}</AnimatePresence>
</motion.div>)}
</AnimatePresence>
</motion.div> : <motion.div key="empty" className={styles.empty} initial={reduced ? false : { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, transition: leave }} transition={enter}><span><UiIcon iconRole="search" width={20} height={20} aria-hidden="true"/></span><strong>일치하는 명령이 없어요</strong><small>다른 단어로 검색하거나 검색어를 지워 보세요.</small></motion.div>}
</AnimatePresence>
</motion.div>
</motion.div>
</motion.div>;
}
export default CommandPalette;