junyeokk
Blog
React-Ecosystem·2025. 09. 15

cmdk

웹 앱에서 검색이나 명령 실행 UI를 만들 때, 단순한 <input>에 드롭다운을 붙이는 식으로 구현하면 금방 한계에 부딪힌다. 필터링 로직을 직접 짜야 하고, 키보드 네비게이션(위아래 화살표, Enter 선택)을 수동으로 구현해야 하고, 접근성(ARIA 속성, 스크린리더 호환)까지 챙기려면 코드가 걷잡을 수 없이 복잡해진다.

macOS의 Spotlight(⌘+Space)이나 VS Code의 Command Palette(⌘+Shift+P)처럼 "입력하면 즉시 필터링되고, 키보드로 선택할 수 있는" UI 패턴을 Command Palette(커맨드 팔레트) 라고 부른다. cmdk는 이 패턴을 React에서 쉽게 구현할 수 있게 만든 라이브러리다.


왜 cmdk인가

커맨드 팔레트를 직접 구현한다고 가정하면 대략 이런 것들을 만들어야 한다.

  1. 입력값이 바뀔 때마다 아이템 리스트를 필터링
  2. 필터링 결과에 순위(rank)를 매겨서 정렬
  3. 위/아래 화살표로 아이템 간 이동, Enter로 선택
  4. 아이템이 없을 때 "결과 없음" 표시
  5. 그룹별로 아이템 묶기
  6. Dialog(모달)로 띄울 때 포커스 트랩, ESC 닫기
  7. ARIA combobox 패턴 준수

이걸 하나하나 만들면 수백 줄은 거뜬히 넘어간다. cmdk는 이 모든 것을 컴포저블한 컴포넌트 API로 제공한다. 스타일은 전혀 포함하지 않아서(unstyled) Tailwind든 CSS Modules이든 자유롭게 입힐 수 있고, 내부적으로 Radix UI의 Dialog를 사용해서 모달 관련 접근성도 자동으로 처리된다.

비슷한 라이브러리로 react-cmdk가 있는데, 이쪽은 Headless UI 기반에 스타일이 포함되어 있다. cmdk는 완전히 unstyled라는 점에서 디자인 시스템과 결합하기 더 좋다.


기본 구조

cmdk의 컴포넌트 구조는 직관적이다.

tsx
import { Command } from 'cmdk'

function CommandMenu() {
  return (
    <Command>
      <Command.Input placeholder="검색..." />
      <Command.List>
        <Command.Empty>결과가 없습니다.</Command.Empty>

        <Command.Group heading="페이지">
          <Command.Item onSelect={() => navigate('/home')}>홈</Command.Item>
          <Command.Item onSelect={() => navigate('/settings')}>설정</Command.Item>
        </Command.Group>

        <Command.Group heading="액션">
          <Command.Item onSelect={() => toggleTheme()}>테마 변경</Command.Item>
        </Command.Group>
      </Command.List>
    </Command>
  )
}
  • Command: 루트 컴포넌트. 내부에 필터링 상태, 선택 상태, 키보드 네비게이션 로직이 모두 들어 있다.
  • Command.Input: 검색 입력 필드. 입력값이 바뀌면 자동으로 하위 아이템들을 필터링한다.
  • Command.List: 아이템 목록 컨테이너. --cmdk-list-height CSS 변수로 높이가 자동 설정되어 애니메이션을 걸 수 있다.
  • Command.Empty: 필터링 결과가 0개일 때만 렌더링된다.
  • Command.Group: 아이템을 그룹으로 묶고 heading을 붙인다.
  • Command.Item: 개별 아이템. onSelect 콜백으로 선택 시 동작을 정의한다.

이 구조 자체가 핵심이다. 각 컴포넌트가 독립적이면서도 Command 루트 안에서 자동으로 연결된다. React의 Compound Component 패턴을 적극적으로 활용한 설계다.


Dialog로 사용하기

인라인으로 렌더링할 수도 있지만, 대부분의 커맨드 팔레트는 모달로 띄운다. Command.Dialog를 사용하면 Radix UI Dialog 위에 Command가 결합된 형태로 렌더링된다.

tsx
function App() {
  const [open, setOpen] = React.useState(false)

  React.useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
        e.preventDefault()
        setOpen((prev) => !prev)
      }
    }

    document.addEventListener('keydown', down)
    return () => document.removeEventListener('keydown', down)
  }, [])

  return (
    <Command.Dialog open={open} onOpenChange={setOpen}>
      <Command.Input />
      <Command.List>
        <Command.Empty>결과 없음</Command.Empty>
        <Command.Item>Apple</Command.Item>
        <Command.Item>Banana</Command.Item>
      </Command.List>
    </Command.Dialog>
  )
}

⌘+K(macOS) / Ctrl+K(Windows, Linux) 단축키로 토글하는 게 관례다. 이게 라이브러리 이름이 "cmdk"인 이유이기도 하다. Dialog는 Radix UI를 내부적으로 사용하므로 포커스 트랩, ESC 닫기, 오버레이 클릭 닫기가 모두 자동으로 동작한다.


필터링 동작 원리

cmdk의 가장 강력한 부분은 자동 필터링이다. 기본적으로 각 Command.Item의 텍스트 콘텐츠(.textContent)를 value로 사용하고, 입력값과 비교해서 순위를 매긴다. value를 명시적으로 지정할 수도 있다.

tsx
<Command.Item value="apple">🍎 Apple</Command.Item>

value를 지정하지 않으면 렌더링된 텍스트에서 자동 추론한다. 모든 value는 trim()으로 공백이 제거된다.

커스텀 필터

기본 필터 로직이 맞지 않으면 filter prop으로 커스텀 필터 함수를 넣을 수 있다.

tsx
<Command
  filter={(value, search) => {
    // 1을 반환하면 표시, 0을 반환하면 숨김
    if (value.includes(search)) return 1
    return 0
  }}
/>

반환값은 0~1 사이의 숫자로, 순위를 의미한다. 1에 가까울수록 상위에 표시된다. 이 방식으로 fuzzy matching이나 가중치 기반 정렬을 구현할 수 있다.

keywords로 검색 범위 확장

아이템의 표시 텍스트와 다른 키워드로도 검색되게 하려면 keywords prop을 사용한다.

tsx
<Command.Item keywords={['fruit', 'red']}>Apple</Command.Item>

"fruit"로 검색해도 Apple이 결과에 나온다. 필터 함수에서 세 번째 인자로 keywords 배열을 받을 수 있다.

tsx
<Command
  filter={(value, search, keywords) => {
    const extendValue = value + ' ' + keywords.join(' ')
    if (extendValue.includes(search)) return 1
    return 0
  }}
/>

필터링 비활성화

서버에서 검색 결과를 받아오는 경우처럼 클라이언트 필터링이 필요 없을 때는 shouldFilter={false}로 비활성화한다.

tsx
<Command shouldFilter={false}>
  <Command.List>
    {serverResults.map((item) => (
      <Command.Item key={item.id} value={item.name}>
        {item.name}
      </Command.Item>
    ))}
  </Command.List>
</Command>

이 경우 정렬과 필터링을 직접 관리해야 한다.


제어 컴포넌트로 사용하기

선택된 아이템이나 검색어를 외부 상태로 관리하고 싶을 때 controlled 모드로 사용할 수 있다.

tsx
const [value, setValue] = React.useState('apple')
const [search, setSearch] = React.useState('')

return (
  <Command value={value} onValueChange={setValue}>
    <Command.Input value={search} onValueChange={setSearch} />
    <Command.List>
      <Command.Item>Orange</Command.Item>
      <Command.Item>Apple</Command.Item>
    </Command.List>
  </Command>
)

Commandvalue는 현재 선택(하이라이트)된 아이템이고, Command.Inputvalue는 검색어다. 두 개가 별도의 상태라는 점이 중요하다.


스타일링

cmdk는 스타일을 전혀 포함하지 않는다. 대신 각 컴포넌트에 cmdk-로 시작하는 data attribute가 자동으로 붙는다.

컴포넌트data attribute
Command[cmdk-root]
Command.Dialog[cmdk-dialog], [cmdk-overlay]
Command.Input[cmdk-input]
Command.List[cmdk-list]
Command.Item[cmdk-item]
Command.Group[cmdk-group]
Command.Separator[cmdk-separator]
Command.Empty[cmdk-empty]

이 attribute들로 CSS 셀렉터를 작성할 수 있다.

css
[cmdk-item] {
  padding: 8px 12px;
  border-radius: 6px;
  cursor: pointer;
}

[cmdk-item][data-selected="true"] {
  background: #f0f0f0;
}

[cmdk-item][data-disabled="true"] {
  opacity: 0.5;
  pointer-events: none;
}

리스트 높이 애니메이션은 CSS 변수를 활용한다.

css
[cmdk-list] {
  min-height: 300px;
  height: var(--cmdk-list-height);
  max-height: 500px;
  transition: height 100ms ease;
}

필터링으로 아이템 수가 줄어들면 --cmdk-list-height가 자동으로 업데이트되면서 부드럽게 높이가 줄어든다.


shadcn/ui와 결합

shadcn/ui는 cmdk를 Command 컴포넌트로 래핑해서 제공한다. Radix UI Primitives를 forwardRef로 감싸고 Tailwind 클래스를 입히는 방식이다.

tsx
import { Command as CommandPrimitive } from 'cmdk'

const Command = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
  <CommandPrimitive
    ref={ref}
    className={cn(
      'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
      className,
    )}
    {...props}
  />
))

shadcn의 CommandDialog는 cmdk의 Command를 Radix Dialog 안에 직접 넣는 구조다. cmdk 자체의 Command.Dialog와 달리 shadcn의 Dialog/DialogContent를 사용하므로 디자인 시스템의 일관성을 유지할 수 있다.

tsx
const CommandDialog = ({ children, ...props }: DialogProps) => {
  return (
    <Dialog {...props}>
      <DialogContent className="overflow-hidden p-0">
        <Command>{children}</Command>
      </DialogContent>
    </Dialog>
  )
}

이 방식의 장점은 Dialog의 애니메이션, 오버레이 스타일 등을 shadcn의 다른 Dialog와 동일하게 유지할 수 있다는 점이다.


그룹 동작의 특이점

Command.Group은 내부 아이템이 모두 필터링되어 사라져도 DOM에서 제거되지 않는다. 대신 hidden attribute가 적용된다. 이건 의도적인 설계인데, 그룹이 언마운트되면 내부 아이템의 상태가 초기화되기 때문이다.

css
/* 숨겨진 그룹의 heading도 함께 숨기기 */
[cmdk-group][hidden] {
  display: none;
}

스타일링할 때 이 점을 알아두지 않으면 빈 그룹의 heading이 남아있는 문제가 생길 수 있다.


loop와 키보드 네비게이션

loop prop을 사용하면 마지막 아이템에서 아래 화살표를 누르면 첫 번째 아이템으로 돌아간다.

tsx
<Command loop />

기본적으로는 마지막 아이템에서 멈춘다. UX 관점에서 아이템 수가 적을 때는 loop가 편하고, 길 리스트에서는 끝에서 멈추는 게 더 직관적이다.


실전 활용 패턴

페이지 전환

커맨드 팔레트에서 하위 메뉴로 진입하는 패턴이다. 예를 들어 "프로젝트"를 선택하면 프로젝트 목록이 나오는 식이다.

tsx
function CommandMenu() {
  const [page, setPage] = React.useState<'root' | 'projects'>('root')

  return (
    <Command>
      <Command.Input />
      <Command.List>
        {page === 'root' && (
          <>
            <Command.Item onSelect={() => setPage('projects')}>
              프로젝트 검색...
            </Command.Item>
            <Command.Item>설정</Command.Item>
          </>
        )}

        {page === 'projects' && (
          <>
            <Command.Item>프로젝트 A</Command.Item>
            <Command.Item>프로젝트 B</Command.Item>
          </>
        )}
      </Command.List>
    </Command>
  )
}

forceMount로 고정 아이템

검색 결과와 관계없이 항상 표시해야 하는 아이템에는 forceMount를 사용한다.

tsx
<Command.Item forceMount onSelect={() => createNew()}>
  + 새로 만들기
</Command.Item>

검색어와 매치되지 않아도 항상 렌더링된다. "결과가 없을 때 새로 만들기" 같은 UX에 유용하다.


정리

cmdk는 "커맨드 팔레트"라는 하나의 UI 패턴에 집중한 라이브러리다. 필터링, 정렬, 키보드 네비게이션, 접근성을 모두 내장하면서도 스타일을 강제하지 않아서 어떤 디자인 시스템에도 자연스럽게 녹아든다. shadcn/ui와 결합하면 설치부터 사용까지 몇 분이면 충분하다.

핵심 포인트를 정리하면:

  • 자동 필터링/정렬: 입력값에 따라 아이템이 자동으로 필터링되고 순위가 매겨진다
  • 커스텀 필터: filter prop으로 fuzzy matching 등 커스텀 로직 적용 가능
  • keywords: 표시 텍스트 외에 추가 검색 키워드 지정 가능
  • Compound Component: Command.Input, Command.List, Command.Item 등 조합형 API
  • Unstyled: data attribute 기반 스타일링, CSS 변수로 높이 애니메이션
  • Dialog 통합: Radix UI Dialog 기반, 포커스 트랩/ESC 자동 처리

관련 문서