본문 바로가기
Frontend2024년 12월 25일9분 읽기

Headless UI 패턴 — 로직과 UI 분리의 기술

YS
김영삼
조회 589

Headless UI 패턴이란?

Headless UI는 컴포넌트의 동작 로직(상태 관리, 키보드 네비게이션, ARIA 속성)을 제공하되, 시각적 렌더링은 사용자에게 완전히 위임하는 패턴입니다. 로직과 UI가 분리되므로, 어떤 디자인 시스템에서도 일관된 동작을 보장하면서 자유로운 스타일링이 가능합니다.

Custom Hook 기반 Headless 컴포넌트

// useToggle
function useToggle(initialState = false) {
  const [isOpen, setIsOpen] = useState(initialState);
  const toggle = useCallback(() => setIsOpen(prev => !prev), []);
  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);

  return { isOpen, toggle, open, close };
}

// useDropdown
function useDropdown({ items, onSelect }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const listRef = useRef(null);

  const getItemProps = (index) => ({
    role: 'option',
    'aria-selected': activeIndex === index,
    onClick: () => {
      onSelect(items[index]);
      setIsOpen(false);
    },
    onMouseEnter: () => setActiveIndex(index),
  });

  const getTriggerProps = () => ({
    role: 'combobox',
    'aria-expanded': isOpen,
    'aria-haspopup': 'listbox',
    onClick: () => setIsOpen(!isOpen),
    onKeyDown: (e) => {
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          if (!isOpen) setIsOpen(true);
          setActiveIndex(i => Math.min(i + 1, items.length - 1));
          break;
        case 'ArrowUp':
          e.preventDefault();
          setActiveIndex(i => Math.max(i - 1, 0));
          break;
        case 'Enter':
          if (isOpen && activeIndex >= 0) {
            onSelect(items[activeIndex]);
            setIsOpen(false);
          }
          break;
        case 'Escape':
          setIsOpen(false);
          break;
      }
    },
  });

  const getListProps = () => ({
    role: 'listbox',
    ref: listRef,
  });

  return { isOpen, activeIndex, getTriggerProps, getListProps, getItemProps };
}

사용 예: 커스텀 드롭다운

function CustomDropdown({ items, onSelect, placeholder }) {
  const { isOpen, activeIndex, getTriggerProps, getListProps, getItemProps } =
    useDropdown({ items, onSelect });

  return (
    <div className="relative">
      <button
        {...getTriggerProps()}
        className="px-4 py-2 border rounded-lg flex items-center gap-2"
      >
        {placeholder}
        <ChevronIcon className={isOpen ? 'rotate-180' : ''} />
      </button>

      {isOpen && (
        <ul {...getListProps()} className="absolute mt-1 border rounded-lg shadow-lg bg-white">
          {items.map((item, i) => (
            <li
              key={item.id}
              {...getItemProps(i)}
              className={'px-4 py-2 cursor-pointer ' +
                (activeIndex === i ? 'bg-blue-50 text-blue-600' : '')}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Render Props 패턴

function Accordion({ items, allowMultiple = false, children }) {
  const [openItems, setOpenItems] = useState(new Set());

  const toggleItem = (id) => {
    setOpenItems(prev => {
      const next = new Set(allowMultiple ? prev : []);
      if (prev.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  return children({
    items: items.map(item => ({
      ...item,
      isOpen: openItems.has(item.id),
      toggle: () => toggleItem(item.id),
      triggerProps: {
        'aria-expanded': openItems.has(item.id),
        'aria-controls': 'panel-' + item.id,
        onClick: () => toggleItem(item.id),
      },
      panelProps: {
        id: 'panel-' + item.id,
        role: 'region',
        hidden: !openItems.has(item.id),
      },
    })),
  });
}

Headless UI 라이브러리 비교

라이브러리방식접근성번들 크기
Headless UI컴포넌트WAI-ARIA 완전 준수~12KB
Radix UI컴포넌트WAI-ARIA 완전 준수컴포넌트별
DownshiftHook/Render PropsWAI-ARIA 준수~8KB
React AriaHookAdobe 접근성 표준컴포넌트별
  • Headless 패턴은 디자인 시스템의 기반 레이어로, 위에 프로젝트별 스타일을 입힙니다
  • ARIA 속성과 키보드 네비게이션을 로직에 내장하여 접근성을 기본으로 보장합니다
  • 테스트 시 시각적 요소가 아닌 동작(상태 변화, 이벤트)만 테스트하면 됩니다
  • Tailwind CSS + Headless UI 조합은 현재 가장 인기 있는 UI 구축 방식입니다

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록