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
const [open, setOpen] = useState(false);

<Button onClick={() => setOpen(true)}>명령 팔레트 (⌘K)</Button>
<Command
  open={open}
  onOpenChange={setOpen}
  items={items}              // { id, label, group?, hint?, icon?, onSelect? }
  onSelect={(it) => run(it)}
/>
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,
  );
}