Command
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
명령 또는 페이지 검색…
이동 · 설정
대시보드로 이동/admin
새 블로그 글 작성C then B
테마 전환라이트 / 다크
↑↓이동↵실행esc닫기
Command
⌘K(또는 Ctrl+K) 커맨드 팔레트입니다. 검색·그룹·키보드 네비(↑↓↵)·ARIA combobox를 지원하고, open/onOpenChange로 제어합니다.
⌘K 커맨드 팔레트
버튼 또는 ⌘K로 열고, 항목을 검색·실행합니다. 각 항목의 onSelect 또는 공통 onSelect로 동작을 연결합니다.
Source Code
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
Commandtypescript
'use client';
/**
* Command 컴포넌트 (⌘K 커맨드 팔레트)
*
* - 제어 컴포넌트: open / onOpenChange. enableShortcut면 ⌘K·Ctrl+K로 전역 토글.
* - 검색어로 label·keywords 필터, group으로 묶어 표시(첫 등장 순서 유지), 키보드 네비는 평면 인덱스.
* - ↑/↓ 순환, ↵ 실행(item.onSelect → onSelect), Esc/백드롭 닫기, 활성 항목 scrollIntoView.
* - ARIA: input role=combobox + listbox/option.
*
* 내부 다이얼로그(CommandDialog)는 open일 때만 마운트 — 열 때마다 검색어/포커스가 초기화됩니다.
*/
import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/cn';
export interface CommandItem {
id: string;
label: string;
group?: string;
hint?: string;
keywords?: string;
icon?: ReactNode;
onSelect?: () => void;
}
export interface CommandProps {
open: boolean;
onOpenChange: (open: boolean) => void;
items: CommandItem[];
placeholder?: string;
emptyText?: ReactNode;
/** ⌘K·Ctrl+K 전역 토글 등록 (기본 true) */
enableShortcut?: boolean;
onSelect?: (item: CommandItem) => void;
}
export function Command({ open, onOpenChange, enableShortcut = true, ...rest }: CommandProps) {
// 전역 ⌘K 토글 (setState 아님 — prop 콜백 호출)
useEffect(() => {
if (!enableShortcut) return;
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
onOpenChange(!open);
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [enableShortcut, open, onOpenChange]);
if (typeof window === 'undefined' || !open) return null;
return <CommandDialog onOpenChange={onOpenChange} {...rest} />;
}
type CommandDialogProps = Omit<CommandProps, 'open' | 'enableShortcut'>;
function CommandDialog({
onOpenChange,
items,
placeholder = '명령 또는 페이지 검색…',
emptyText,
onSelect,
}: CommandDialogProps) {
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(c) => c.label.toLowerCase().includes(q) || (c.keywords?.toLowerCase().includes(q) ?? false),
);
}, [query, items]);
const grouped = useMemo(() => {
const order: string[] = [];
const map = new Map<string, { cmd: CommandItem; index: number }[]>();
filtered.forEach((cmd, index) => {
const g = cmd.group ?? '';
if (!map.has(g)) {
map.set(g, []);
order.push(g);
}
map.get(g)!.push({ cmd, index });
});
return order.map((g) => ({ group: g, items: map.get(g)! }));
}, [filtered]);
// 마운트 시 입력 포커스 (DOM 부수효과 — setState 아님)
useEffect(() => {
const t = setTimeout(() => inputRef.current?.focus(), 20);
return () => clearTimeout(t);
}, []);
// 활성 항목 scrollIntoView
useEffect(() => {
const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`);
el?.scrollIntoView({ block: 'nearest' });
}, [active]);
const run = (cmd: CommandItem) => {
onOpenChange(false);
if (cmd.onSelect) cmd.onSelect();
else onSelect?.(cmd);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive((a) => (filtered.length ? (a + 1) % filtered.length : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => (filtered.length ? (a - 1 + filtered.length) % filtered.length : 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const cmd = filtered[active];
if (cmd) run(cmd);
} else if (e.key === 'Escape') {
e.preventDefault();
onOpenChange(false);
}
};
const activeId = filtered[active] ? `cmd-${filtered[active].id}` : undefined;
return createPortal(
<div className="fixed inset-0 z-120 flex items-start justify-center p-4 pt-[14vh]" onKeyDown={onKeyDown}>
<button
type="button"
aria-label="닫기"
onClick={() => onOpenChange(false)}
className="absolute inset-0 cursor-default bg-slate-900/40 backdrop-blur-sm animate-in fade-in duration-200"
/>
<div
role="dialog"
aria-modal="true"
aria-label="커맨드 팔레트"
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl animate-in fade-in zoom-in-95 duration-150"
>
<div className="flex items-center gap-3 border-b border-slate-100 px-4">
<svg className="size-5 shrink-0 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.3-4.3M11 19a8 8 0 100-16 8 8 0 000 16z" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
role="combobox"
aria-expanded
aria-controls="command-listbox"
aria-activedescendant={activeId}
aria-autocomplete="list"
placeholder={placeholder}
className="w-full bg-transparent py-3.5 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none"
/>
</div>
<div ref={listRef} id="command-listbox" role="listbox" aria-label="명령 목록" className="max-h-72 overflow-y-auto p-2">
{filtered.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-slate-400">
{emptyText ?? `"${query}" 에 대한 결과가 없어요`}
</div>
) : (
grouped.map(({ group, items: groupItems }) => (
<div key={group || '_'} className="mb-1.5 last:mb-0">
{group && (
<p className="px-3 pb-1 pt-2 text-[10px] font-bold uppercase tracking-wider text-slate-400">{group}</p>
)}
{groupItems.map(({ cmd, index }) => {
const isActive = index === active;
return (
<div
key={cmd.id}
id={`cmd-${cmd.id}`}
role="option"
aria-selected={isActive}
data-index={index}
onMouseMove={() => setActive(index)}
onClick={() => run(cmd)}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition-colors',
isActive ? 'bg-violet-50 text-violet-700' : 'text-slate-700',
)}
>
{cmd.icon && (
<span
className={cn(
'grid size-8 shrink-0 place-items-center rounded-lg',
isActive ? 'bg-gradient-to-br from-violet-500 to-fuchsia-500 text-white' : 'bg-slate-100 text-slate-500',
)}
>
{cmd.icon}
</span>
)}
<span className="flex-1 text-sm font-medium">{cmd.label}</span>
{cmd.hint && (
<span className={cn('font-mono text-[11px]', isActive ? 'text-violet-400' : 'text-slate-300')}>
{cmd.hint}
</span>
)}
</div>
);
})}
</div>
))
)}
</div>
<div className="flex items-center gap-3 border-t border-slate-100 px-4 py-2.5 text-[11px] text-slate-400">
<span className="flex items-center gap-1">
<kbd className="rounded border border-slate-200 bg-slate-50 px-1 font-mono">↑</kbd>
<kbd className="rounded border border-slate-200 bg-slate-50 px-1 font-mono">↓</kbd>
이동
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-slate-200 bg-slate-50 px-1 font-mono">↵</kbd>
실행
</span>
<span className="flex items-center gap-1">
<kbd className="rounded border border-slate-200 bg-slate-50 px-1 font-mono">esc</kbd>
닫기
</span>
</div>
</div>
</div>,
document.body,
);
}