Headless UI 직접 만들기 - 로직과 구조만 남기고 UI는 자유롭게

2026. 5. 9. 07:04·AI
🧩 HEADLESS UI · 2026 실전 설계 가이드

Headless UI 직접 만들기
로직과 구조만 남기고 UI는 자유롭게

Dropdown · Modal · 접근성까지 — Radix UI가 왜 그렇게 설계됐는지 직접 구현하며 이해하는  가이드

🏗️ Compound + Context 설계 ♿ WAI-ARIA 접근성 포함

📋 목차

  1. 왜 Headless UI가 필요한가? — props 폭발의 끝
  2. Headless UI란? — 정확한 정의와 2026 생태계
  3. 핵심 구조 — Headless UI를 구성하는 3요소
  4. 실전 구현 1 — Dropdown (완전체)
  5. 접근성(a11y) — Headless UI의 진짜 가치
  6. 실전 구현 2 — Modal (Portal + 포커스 트랩)
  7. 스타일은 어떻게 입힐까? — 3가지 전략
  8. Custom Hook과 결합 — Controlled 패턴
  9. Headless UI vs 일반 컴포넌트 — 언제 무엇을?
  10. 2026 트렌드 — Radix, Base UI, shadcn의 시대
  11. 초보자가 자주 저지르는 실수 4가지

이 시리즈의 여정을 돌아보면, Compound Component로 UI 구조를 설계하고, Zustand로 전역 상태를 관리하고, Custom Hook으로 로직을 분리하는 법을 배웠습니다. 이제 그 모든 것이 합쳐지는 마지막 단계입니다. Headless UI — UI 라이브러리를 직접 설계하는 사고방식입니다.

Radix UI가 왜 그런 문법을 쓰는지, shadcn/ui가 왜 그렇게 생겼는지 궁금했다면, 이 글이 그 답입니다. 단순 튜토리얼이 아니라 "UI 라이브러리를 만드는 설계 사고방식"을 직접 코드로 구현합니다.

이 글을 읽고 나면 Dropdown과 Modal을 직접 Headless 방식으로 만들 수 있고, WAI-ARIA 접근성을 포함하는 방법을 알게 되며, 어떤 UI 라이브러리의 소스코드도 구조를 파악할 수 있게 됩니다.

Headless UI 개념 다이어그램 — 로직 레이어와 UI 레이어 분리 구조 2026

1. 왜 Headless UI가 필요한가? — props 폭발의 끝

일반적인 방식으로 Modal을 만들면 처음엔 간단합니다. 하지만 요구사항이 쌓일수록 이렇게 됩니다.

⚠️ props 폭발 — 확장할수록 무너지는 구조

<!-- 처음엔 이렇게 시작됩니다 -->
<Modal title="확인" />

<!-- 기능이 늘어나면서... -->
<Modal
  title="확인"
  showCloseButton={true}
  closeOnOverlayClick={false}
  footerContent={<div>...</div>}
  headerIcon={<Icon />}
  size="lg"
  animation="slide"
  zIndex={1000}
  onClose={handleClose}
  onConfirm={handleConfirm}
  confirmText="저장"
  cancelText="취소"
  isLoading={loading}
/>
<!-- props 12개... 이건 컴포넌트가 아니라 설정 파일입니다 -->

이 구조의 문제는 props가 많다는 것이 아닙니다. 컴포넌트가 너무 많은 것을 혼자 결정하려 한다는 것입니다. 스타일, 레이아웃, 동작, 텍스트까지 전부 props로 받으면 새로운 디자인 요구가 생길 때마다 props를 추가해야 합니다. 결국 유지보수 불가 상태가 됩니다.

💡 핵심 인사이트
props가 많아질수록 컴포넌트 설계가 잘못됐을 가능성이 큽니다. Headless UI는 "결정권을 사용자에게 돌려주는 설계"입니다. 동작만 제공하고, 어떻게 보일지는 사용하는 쪽이 결정합니다.

2. Headless UI란? — 정확한 정의와  생태계

Headless UI는 스타일 없이 동작과 구조만 제공하는 UI 컴포넌트 패턴입니다. "Headless"라는 이름은 "눈에 보이는 머리(head, 즉 스타일)가 없다"는 뜻입니다. 접근성(ARIA), 키보드 내비게이션, 포커스 관리, 상태 공유 — 이 모든 어려운 부분을 처리하지만 픽셀 단위의 시각적 표현은 전혀 강제하지 않습니다.

🧩 HEADLESS UI의 진짜 본질

Headless UI의 핵심은 "스타일 분리"가 아닙니다

상태(State) + 동작(Behavior) + 접근성(Accessibility)을
UI와 완전히 분리하는 설계 방식입니다

UI(스타일)는 프로젝트마다 바뀌지만, 동작·접근성·상태는 재사용됩니다
이것이 라이브러리를 만드는 이유이자 Headless의 존재 이유입니다

📊 2026년 Headless UI 생태계 현황

1위 Radix UI — 주간 다운로드 910만 회. shadcn/ui의 기반 엔진. 가장 넓은 Primitive 커버리지. WAI-ARIA 완전 지원
신흥 Base UI (by MUI 팀) — 2025년 shadcn/ui가 공식 지원 추가. "더 유연한 제어"를 원하는 팀 대상. Radix 대비 컴포넌트를 더 직접 조립하는 방식
특화 React Aria (Adobe) — 정부·의료·금융 등 WCAG 최고 수준 접근성이 필요한 환경. 가장 철저한 접근성 테스트 커버리지
생태계 shadcn/ui — Headless 라이브러리가 아니라 Radix/Base UI 위에 Tailwind를 입힌 "코드 소유형" 컴포넌트 컬렉션. 2026년 기준 새 React 프로젝트 1순위 선택지

🔍 2026 핵심 트렌드 — "Headless 아키텍처는 더 이상 엘리트 팀만의 선택이 아닙니다." 전체 비즈니스의 73%가 Headless 아키텍처를 채택했으며(ZenrioTech 조사, 2026), UI Kit에서 "UI 생태계"로의 전환이 일어나고 있습니다. 컴포넌트는 외부 의존성이 아니라 직접 소유하는 소스코드가 되어가고 있습니다.

3. 핵심 구조 — Headless UI를 구성하는 3요소

이 시리즈를 따라왔다면 이미 모든 재료를 갖추고 있습니다. Headless UI는 세 가지가 합쳐진 구조입니다.

Headless UI = Compound Component + Context API + Custom Hook

Compound Component

🏗️

트리 구조 API
Dropdown.Trigger

+

Context API

🔗

자식 간 상태 공유
useContext(DropdownCtx)

+

Custom Hook

🪝

로직 분리·재사용
useDropdown()

이 구조의 핵심은 "컴포넌트 트리 자체가 API"라는 점입니다. Dropdown.Trigger, Dropdown.Menu, Dropdown.Item처럼 점(.)으로 연결된 구조가 사용법을 직관적으로 알려줍니다. Radix UI, shadcn/ui가 모두 이 패턴을 쓰는 이유가 여기 있습니다.

🔥 나쁜 Headless UI vs 좋은 Headless UI

❌ 나쁜 예 — props 기반 (사실상 일반 컴포넌트)

<Dropdown
  isOpen={open}
  onToggle={setOpen}
  trigger="열기"
  items={menuItems}
/>
<!-- props로 모든 것을 제어
   → 구조 변경 불가
   → 새 디자인마다 props 추가
   → 이건 Headless가 아님 -->

✅ 좋은 예 — 구조 기반 API

<Dropdown>
  <Dropdown.Trigger>열기</Dropdown.Trigger>
  <Dropdown.Menu>
    <Dropdown.Item>메뉴1</Dropdown.Item>
  </Dropdown.Menu>
</Dropdown>
<!-- 구조로 동작을 정의
   → 레이아웃 자유롭게 조합
   → props 없이 확장 가능
   → 이것이 진짜 Headless -->

핵심 차이 — "props로 전달하냐"가 아니라 "구조로 동작을 정의하냐"가 Headless UI의 기준입니다. Trigger와 Menu의 위치, 순서, 조합을 JSX 구조 자체로 결정할 수 있어야 진짜 Headless입니다.

4. 실전 구현 1 — Dropdown (완전체)

단계별로 구현합니다. 각 단계의 역할을 이해하는 것이 핵심입니다.

① Context 생성 — 자식 컴포넌트 간 상태 터널

import { createContext, useContext, useState, useRef, useEffect } from 'react';

type DropdownContextType = {
  open: boolean;
  setOpen: (v: boolean) => void;
};

const DropdownContext = createContext<DropdownContextType | null>(null);

// 안전한 Context 소비 — null 체크 내장
function useDropdownContext() {
  const ctx = useContext(DropdownContext);
  if (!ctx) throw new Error('Dropdown 컴포넌트 안에서만 사용하세요');
  return ctx;
}

② Root — 상태를 관리하고 Context로 공급

function Dropdown({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  // 외부 클릭 시 닫기 — 실무에서 필수
  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', handleClickOutside);
    return () => document.removeEventListener('mousedown', handleClickOutside);
  }, []);

  return (
    <DropdownContext.Provider value={{ open, setOpen }}>
      <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
        {children}
      </div>
    </DropdownContext.Provider>
  );
}

③ Trigger — 열기/닫기 + 접근성 속성

function Trigger({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const { open, setOpen } = useDropdownContext();

  return (
    <button
      className={className}
      aria-expanded={open}           // 스크린 리더: "열림/닫힘" 상태 알림
      aria-haspopup="menu"           // 스크린 리더: "메뉴가 있음" 알림
      onClick={() => setOpen(!open)}
      onKeyDown={(e) => {
        if (e.key === 'Escape') setOpen(false);
        if (e.key === 'ArrowDown' && !open) setOpen(true);
      }}
    >
      {children}
    </button>
  );
}

④ Menu + Item — 열렸을 때만 렌더링

function Menu({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const { open } = useDropdownContext();
  if (!open) return null;

  return (
    <div
      role="menu"                    // WAI-ARIA: 메뉴 역할 명시
      aria-orientation="vertical"
      className={className}
      style={{ position: 'absolute', top: '100%', left: 0, zIndex: 50 }}
    >
      {children}
    </div>
  );
}

function Item({
  children,
  onClick,
  className,
}: {
  children: React.ReactNode;
  onClick?: () => void;
  className?: string;
}) {
  const { setOpen } = useDropdownContext();

  return (
    <div
      role="menuitem"
      tabIndex={0}
      className={className}
      onClick={() => { onClick?.(); setOpen(false); }}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          onClick?.();
          setOpen(false);
        }
      }}
    >
      {children}
    </div>
  );
}

⑤ 서브컴포넌트 연결 + 사용 예시

// 서브컴포넌트 연결 — Compound 패턴 완성
Dropdown.Trigger = Trigger;
Dropdown.Menu = Menu;
Dropdown.Item = Item;
export default Dropdown;

// 사용하는 쪽 — 스타일은 완전 자유
function Header() {
  return (
    <Dropdown>
      <Dropdown.Trigger className="px-4 py-2 bg-blue-500 text-white rounded">
        메뉴 열기 ▼
      </Dropdown.Trigger>
      <Dropdown.Menu className="bg-white shadow-lg rounded border w-48">
        <Dropdown.Item
          className="px-4 py-2 hover:bg-gray-100 cursor-pointer"
          onClick={() => console.log('프로필')}
        >
          👤 프로필
        </Dropdown.Item>
        <Dropdown.Item
          className="px-4 py-2 hover:bg-red-50 text-red-600 cursor-pointer"
          onClick={() => console.log('로그아웃')}
        >
          🚪 로그아웃
        </Dropdown.Item>
      </Dropdown.Menu>
    </Dropdown>
  );
}

✅ 이 구조의 핵심 — Dropdown 컴포넌트는 열기/닫기 로직, 외부 클릭 감지, 접근성 속성을 모두 제공합니다. 사용하는 쪽은 className만 넘기면 어떤 디자인이든 자유롭게 구현할 수 있습니다.

📌 Dropdown이 "간단한 상태 공유"의 예제라면, 다음에 구현할 Modal은 "복잡한 UI + 접근성 + 포커스 관리까지 포함된 Headless UI의 완성형 예제"입니다. 먼저 접근성의 전체 그림을 이해하고 Modal로 넘어가겠습니다.

Headless Dropdown 컴포넌트 트리 구조 다이어그램 — Compound + Context + 접근성

5. 접근성(a11y) — Headless UI의 진짜 가치

Headless UI의 진짜 가치는 단순히 "스타일 없음"이 아닙니다. 접근성 로직을 컴포넌트 안에 내장하는 것입니다. 드롭다운 메뉴 하나를 처음부터 접근성에 맞게 구현하는 것은 생각보다 훨씬 복잡합니다. 반드시 고려해야 할 3가지 영역이 있습니다.

♿ 접근성 3대 영역 — Headless UI가 반드시 처리해야 할 것

① 키보드 탐색 (Tab / Arrow / Enter / Escape)

마우스 없이도 모든 기능 사용 가능해야 합니다. Tab으로 포커스 이동, Arrow 키로 항목 탐색, Enter로 선택, Escape로 닫기 — WAI-ARIA 메뉴 패턴의 필수 사항입니다.

② 포커스 트랩 (Modal 필수)

Modal이 열린 동안 Tab 키가 모달 외부 요소로 빠져나가면 안 됩니다. 포커스가 모달 안에만 머물러야 하며, 모달이 닫히면 이전에 포커스가 있던 요소로 돌아와야 합니다.

③ 스크린 리더 지원 (ARIA 속성)

aria-expanded, aria-haspopup, role="menu", role="menuitem", aria-modal 등의 속성이 없으면 시각장애인 사용자는 컴포넌트의 상태와 구조를 파악할 수 없습니다.

접근성 요소 적용 위치 역할
aria-expanded Trigger 스크린 리더에 열림/닫힘 상태 전달
aria-haspopup="menu" Trigger 버튼 누르면 메뉴가 나타남을 미리 알림
role="menu" Menu 이 요소가 메뉴임을 스크린 리더에 알림
role="menuitem" Item 개별 항목임을 스크린 리더에 알림
tabIndex={0} Item 키보드 Tab으로 포커스 이동 가능하게
Escape 키 처리 Trigger / Menu 키보드로 메뉴 닫기 — WAI-ARIA 필수 사항

⚡ 접근성 중요성

Radix UI가 910만 주간 다운로드를 기록하는 가장 큰 이유가 바로 접근성 포함 기본값입니다. 접근성 없는 Headless UI는 "반쪽짜리 구현"이며, 2026년 기준 접근성은 더 이상 선택이 아닌 법적 요구사항(WCAG 2.1)이 된 지역이 늘어나고 있습니다.

6. 실전 구현 2 — Modal (Portal + 포커스 트랩)

Modal은 Dropdown보다 한 단계 복잡합니다. 두 가지 핵심 개념이 추가됩니다. Portal(DOM 트리 바깥에 렌더링)과 포커스 트랩(모달이 열린 동안 포커스가 모달 안에만 머물러야 함)입니다.

📄 Modal.tsx — Portal + 포커스 트랩 포함 완전체

import { createContext, useContext, useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

type ModalContextType = { open: boolean; setOpen: (v: boolean) => void };
const ModalContext = createContext<ModalContextType | null>(null);

function useModalContext() {
  const ctx = useContext(ModalContext);
  if (!ctx) throw new Error('Modal 컴포넌트 안에서만 사용하세요');
  return ctx;
}

// Root — 상태 관리
function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <ModalContext.Provider value={{ open, setOpen }}>
      {children}
    </ModalContext.Provider>
  );
}

// Trigger — 모달 열기
function ModalTrigger({ children, className }: { children: React.ReactNode; className?: string }) {
  const { setOpen } = useModalContext();
  return <button className={className} onClick={() => setOpen(true)}>{children}</button>;
}

// Content — Portal로 body에 마운트 + 포커스 트랩 + 접근성
function ModalContent({ children, className }: { children: React.ReactNode; className?: string }) {
  const { open, setOpen } = useModalContext();
  const dialogRef = useRef<HTMLDivElement>(null);
  // 모달 열기 전 포커스가 있던 요소를 기억 — 닫힐 때 복원
  const previousFocus = useRef<HTMLElement | null>(null);

  // Escape 키로 닫기 + body 스크롤 잠금
  useEffect(() => {
    if (!open) return;
    const prev = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    const handleKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false);
    };
    document.addEventListener('keydown', handleKey);
    return () => {
      document.body.style.overflow = prev;
      document.removeEventListener('keydown', handleKey);
    };
  }, [open, setOpen]);

  // 포커스 관리 — 열릴 때 모달 내부로, 닫힐 때 이전 요소로 복원
  useEffect(() => {
    if (open) {
      // 현재 포커스 위치를 기억
      previousFocus.current = document.activeElement as HTMLElement;
      // 모달 내 첫 번째 포커스 가능한 요소로 이동
      const focusable = dialogRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      focusable?.focus();
    } else {
      // 닫힐 때 이전 포커스 위치로 복원 (스크린 리더 UX 필수)
      previousFocus.current?.focus();
    }
  }, [open]);

  if (!open) return null;

  // createPortal — document.body에 직접 렌더링 (z-index 문제 해결)
  return createPortal(
    <>
      {/* 오버레이: 클릭 시 닫기 */}
      <div
        aria-hidden="true"
        onClick={() => setOpen(false)}
        style={{
          position: 'fixed', inset: 0,
          background: 'rgba(0,0,0,0.5)',
          zIndex: 999
        }}
      />
      {/* 모달 본체 */}
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        className={className}
        style={{
          position: 'fixed',
          top: '50%', left: '50%',
          transform: 'translate(-50%, -50%)',
          zIndex: 1000
        }}
      >
        {children}
      </div>
    </>,
    document.body
  );
}

// Close 버튼 — 분리 가능한 서브컴포넌트
function ModalClose({ children, className }: { children: React.ReactNode; className?: string }) {
  const { setOpen } = useModalContext();
  return (
    <button aria-label="모달 닫기" className={className} onClick={() => setOpen(false)}>
      {children}
    </button>
  );
}

// 서브컴포넌트 연결
Modal.Trigger = ModalTrigger;
Modal.Content = ModalContent;
Modal.Close = ModalClose;
export default Modal;

사용하는 쪽은 이렇게 씁니다.

function DeleteConfirmModal() {
  return (
    <Modal>
      <Modal.Trigger className="px-4 py-2 bg-red-500 text-white rounded">
        삭제하기
      </Modal.Trigger>
      <Modal.Content className="bg-white rounded-xl p-6 w-96 shadow-2xl">
        <h2 className="text-xl font-bold mb-2">정말 삭제하시겠어요?</h2>
        <p className="text-gray-600 mb-6">이 작업은 되돌릴 수 없습니다.</p>
        <div className="flex gap-3 justify-end">
          <Modal.Close className="px-4 py-2 border rounded">취소</Modal.Close>
          <button className="px-4 py-2 bg-red-500 text-white rounded">삭제</button>
        </div>
      </Modal.Content>
    </Modal>
  );
}

🏆 Modal이 Dropdown보다 복잡한 이유 4가지

  • Portal — z-index 충돌 방지를 위해 document.body에 직접 렌더링. createPortal 필수
  • 포커스 진입 — 모달이 열리면 자동으로 첫 번째 포커스 가능 요소로 이동. Tab 키가 모달 밖으로 나가지 않도록 함
  • 포커스 복원 — 모달이 닫히면 모달 열기 전에 포커스가 있던 요소로 자동 복원. 스크린 리더 사용자 필수 경험
  • 스크롤 잠금 — 모달 열린 동안 body 스크롤 차단. 닫히면 원상복구

7. 스타일은 어떻게 입힐까? — 3가지 전략

Headless UI가 스타일을 강제하지 않는다는 건 알겠는데, 그래서 실제로 어떻게 입히나요? Tailwind CSS, CSS Modules, styled-components, vanilla CSS — 어떤 스타일 시스템과도 자유롭게 결합할 수 있습니다. 실무에서 쓰이는 3가지 전략이 있습니다.

🎨 전략 1 — className prop (가장 단순)

컴포넌트가 className을 받아서 그대로 전달합니다. Tailwind CSS와 완벽하게 어울립니다.

<Dropdown.Trigger className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
  열기
</Dropdown.Trigger>

🎨 전략 2 — render prop / children as function (상태 노출)

컴포넌트 내부 상태(hover, active, disabled 등)를 children 함수로 노출합니다. Radix UI가 자주 쓰는 방식입니다.

// Item이 active 상태를 children 함수로 전달하는 패턴
<Dropdown.Item>
  {(active) => (
    <div className={`px-4 py-2 ${active ? 'bg-blue-50 text-blue-700' : 'text-gray-700'}`}>
      프로필
    </div>
  )}
</Dropdown.Item>

🎨 전략 3 — data-state 속성 (CSS 선택자 방식)

Radix UI가 쓰는 방식입니다. 컴포넌트가 data-state="open" 같은 속성을 DOM에 부여하면, CSS에서 선택자로 스타일링합니다.

// 컴포넌트에서 data-state 속성 부여
<div data-state={open ? 'open' : 'closed'}>...</div>

/* CSS에서 상태별 스타일링 */
[data-state="open"] { animation: slideDown 0.2s ease; }
[data-state="closed"] { animation: slideUp 0.2s ease; }

8. Custom Hook과 결합 — Controlled 패턴

지금까지 만든 Dropdown은 상태를 내부에서 관리합니다(Uncontrolled). 실무에서는 외부에서 상태를 제어하는 Controlled 패턴도 필요합니다. 예를 들어 특정 조건일 때 강제로 닫거나, 외부 버튼으로 열어야 할 때입니다.

📄 useDropdown.ts + Controlled Dropdown

// Custom Hook으로 로직 분리
function useDropdown(defaultOpen = false) {
  const [open, setOpen] = useState(defaultOpen);
  const toggle = () => setOpen((v) => !v);
  const close = () => setOpen(false);
  const show = () => setOpen(true);
  return { open, setOpen, toggle, close, show };
}

// Controlled Dropdown — 외부에서 상태 주입 가능
function DropdownControlled({
  open,
  onOpenChange,
  children,
}: {
  open: boolean;
  onOpenChange: (v: boolean) => void;
  children: React.ReactNode;
}) {
  return (
    <DropdownContext.Provider value={{ open, setOpen: onOpenChange }}>
      <div style={{ position: 'relative', display: 'inline-block' }}>
        {children}
      </div>
    </DropdownContext.Provider>
  );
}

// 사용 예 — 외부에서 완전히 제어
function Page() {
  const dropdown = useDropdown();

  return (
    <div>
      <button onClick={dropdown.close}>전체 닫기</button>
      <DropdownControlled open={dropdown.open} onOpenChange={dropdown.setOpen}>
        <Dropdown.Trigger>열기</Dropdown.Trigger>
        <Dropdown.Menu>...</Dropdown.Menu>
      </DropdownControlled>
    </div>
  );
}

💡 Controlled vs Uncontrolled
대부분의 경우엔 Uncontrolled(내부 상태)로 충분합니다. 여러 드롭다운 중 하나만 열려야 한다거나, 외부 이벤트로 강제 닫기가 필요할 때 Controlled 패턴을 도입하세요. Radix UI가 두 가지 모두를 지원하는 것이 바로 이 이유입니다.

9. Headless UI vs 일반 컴포넌트 — 언제 무엇을?

비교 항목 일반 컴포넌트 Headless UI
스타일 포함 ✅ 기본 제공 ❌ 없음 (의도적)
커스터마이징 제한적 (라이브러리와 싸움) 완전 자유
접근성 라이브러리에 따라 다름 ✅ 내장
학습 난이도 낮음 중간
디자인 시스템 구축 어려움 최적
추천 상황 빠른 MVP, 단순 프로젝트 커스텀 디자인, 재사용 UI 시스템

💡 한 줄 정리 — Headless UI는 "어떻게 동작할지"만 책임지고, "어떻게 보일지"는 사용하는 쪽이 결정한다

10. 2026 트렌드 — Radix, Base UI, shadcn의 시대

Headless UI는 더 이상 "엘리트 팀만의 선택"이 아닙니다. 2026년 현재 생태계 전체가 이 방향으로 이동하고 있습니다.

📈 Headless UI 채택이 폭발적으로 늘어난 이유

  • shadcn/ui 등장 — Radix UI 위에 Tailwind를 입힌 "복사-붙여넣기" 컴포넌트. CLI 하나로 컴포넌트 소스코드가 직접 내 프로젝트에 들어옴. 코드 소유권 혁명
  • MUI도 합류 — MUI 팀이 Base UI를 출시하며 Headless 시장에 합류. 2025년 shadcn/ui가 Base UI 공식 지원 추가
  • 번들 사이즈 이점 — Radix UI는 Tree-shaking 완전 지원. MUI의 기본 번들 대비 훨씬 작은 용량으로 Core Web Vitals 유리
  • RSC 호환성 — React Server Components 환경에서 CSS-in-JS 기반 라이브러리가 서버 렌더링과 충돌. Headless + Tailwind 조합이 유일한 실용적 대안

⚠️ 주의 — "shadcn 룩" 문제

shadcn/ui가 워낙 좋은 기본값을 제공하다 보니, 2026년 많은 SaaS 제품이 거의 동일하게 생겼다는 비판이 나오고 있습니다. Headless UI를 직접 구현할 수 있으면, shadcn/ui를 베이스로 쓰면서도 완전히 독자적인 디자인 시스템을 구축할 수 있습니다. 이것이 이 글에서 직접 구현을 배우는 진짜 이유입니다.

2026년 Headless UI 생태계 지형도 — Radix UI, shadcn, Base UI, React Aria 비교

11. 초보자가 자주 저지르는 실수 4가지

❌ 실수 1 — Headless 컴포넌트 안에 스타일을 넣는다

인라인 스타일이나 특정 CSS를 컴포넌트 내부에 하드코딩하면 Headless의 의미가 사라집니다. 레이아웃을 위한 position: relative처럼 불가피한 경우를 제외하면 스타일은 사용하는 쪽에 완전히 맡기세요.

❌ 실수 2 — Context에 너무 많은 것을 넣는다

Dropdown Context에 세부 UI 상태(호버 여부, 애니메이션 상태 등)까지 몰아넣으면 불필요한 리렌더링이 늘어납니다. Context에는 꼭 공유가 필요한 최소한의 상태만 담으세요.

❌ 실수 3 — 접근성을 나중으로 미룬다

Headless UI의 핵심 가치가 접근성입니다. ARIA 속성과 키보드 이벤트를 처음부터 구현하지 않으면, 나중에 추가하기가 훨씬 어렵습니다. aria-expanded, role="menu", Escape 키 처리는 처음부터 넣으세요.

❌ 실수 4 — 모든 것을 직접 만들려 한다

Headless UI의 원리를 이해하는 것과, 모든 컴포넌트를 처음부터 만드는 것은 다릅니다. 2026년 기준으로 Dropdown, Modal, Tooltip 같은 컴포넌트는 Radix UI나 shadcn/ui를 사용하는 것이 훨씬 생산적입니다. 원리를 이해하고 라이브러리를 잘 활용하는 것이 목표입니다.

✅ 실전 체크리스트 — Headless UI 설계 전 확인

  • ☑컴포넌트의 props가 5개 이상인가? → Headless 구조로 분리 검토
  • ☑같은 로직을 다른 디자인으로 여러 번 써야 하는가? → Headless 최적
  • ☑aria-expanded, role="menu" 등 ARIA 속성이 포함됐는가? → 접근성 필수
  • ☑Escape 키, Enter 키 이벤트가 처리됐는가? → 키보드 접근성 필수
  • ☑Modal이라면 createPortal과 포커스 트랩이 있는가?
  • ☑빠른 MVP라면 → Radix UI / shadcn/ui 활용, 디자인 시스템 구축이라면 → 직접 구현

🔑 핵심 정리

  • Headless UI의 핵심은 "스타일 분리"가 아니라 상태(State) + 동작(Behavior) + 접근성(Accessibility)을 UI와 완전히 분리하는 설계 방식이다
  • 구조는 Compound Component + Context API + Custom Hook의 조합. 컴포넌트 트리 자체가 API가 된다
  • 접근성(ARIA 속성, 키보드 이벤트, 포커스 복원)은 처음부터 포함해야 한다 — 나중에 추가하면 두 배로 어렵다
  • Modal은 Dropdown 대비 createPortal + 포커스 진입·복원 + 스크롤 잠금이 추가된 완성형 예제
  • 2026년 기준 73%의 프로젝트가 Headless 아키텍처를 채택. Radix UI 주간 910만 다운로드
  • "이걸 왜 라이브러리로 만드는가?" — 답은 하나입니다. 재사용성과 설계 일관성. UI(스타일)는 프로젝트마다 바뀌어도, 동작·접근성은 한 번 만들면 어디서든 재사용됩니다. 이것이 좋은 React 설계가 "보여지는 코드"가 아니라 "숨겨진 구조"에서 결정되는 이유입니다

❓ 자주 묻는 질문 (FAQ)

Q1. Headless UI를 직접 만들어야 하나요? Radix UI나 shadcn/ui를 쓰면 안 되나요?

실무에서는 대부분 Radix UI나 shadcn/ui를 사용하는 것이 훨씬 생산적입니다. 직접 만드는 이유는 두 가지입니다. 첫째, 라이브러리 내부 구조를 이해하면 커스터마이징과 버그 대응이 훨씬 쉬워집니다. 둘째, 라이브러리가 제공하지 않는 특수한 컴포넌트(예: 회사 고유의 인터랙션 패턴)를 만들어야 할 때 직접 구현 능력이 필요합니다. "이해하고 쓰는 것"과 "모르고 쓰는 것"은 유지보수 난이도에서 큰 차이를 만듭니다.

Q2. Radix UI와 shadcn/ui의 차이는 무엇인가요? 둘 중 어떤 걸 써야 하나요?

Radix UI는 완전히 스타일 없는 Headless 라이브러리입니다. shadcn/ui는 Radix UI 위에 Tailwind CSS를 입혀서 "복사-붙여넣기" 방식으로 제공하는 컴포넌트 컬렉션입니다. 간단히는 "Radix = 엔진, shadcn = 엔진 + 차체"로 생각하시면 됩니다. Tailwind CSS를 쓰고 빠르게 개발하고 싶다면 shadcn/ui, 완전히 독자적인 디자인 시스템을 구축한다면 Radix UI를 직접 사용하세요. 2026년 기준 대부분의 신규 프로젝트는 shadcn/ui를 1순위로 선택합니다.

Q3. Headless UI 컴포넌트에서 키보드 완전 접근성(포커스 트랩 등)을 구현하는 게 너무 복잡합니다. 더 쉬운 방법이 있나요?

네, 있습니다. @radix-ui/react-focus-scope 패키지를 사용하면 포커스 트랩을 몇 줄로 구현할 수 있습니다. 또한 복잡한 접근성이 필요하다면 Radix UI를 직접 가져다 쓰거나, Adobe의 React Aria 훅(useModal, useOverlay)을 활용하면 WAI-ARIA 표준에 완전히 부합하는 접근성을 훨씬 쉽게 구현할 수 있습니다. 직접 구현은 학습 목적으로 이해하고, 실무에서는 검증된 라이브러리를 활용하는 것이 맞습니다.

Q4. Headless UI를 Next.js App Router(RSC) 환경에서 사용할 때 주의할 점이 있나요?

Headless UI 컴포넌트는 useState, useEffect, useRef 등을 사용하기 때문에 반드시 클라이언트 컴포넌트여야 합니다. 파일 상단에 'use client'를 선언하세요. 실무 패턴은 Headless UI 컴포넌트를 'use client' 파일로 만들고, 이를 감싸는 Server Component에서 데이터를 가져와 props로 전달하는 방식입니다. createPortal을 사용하는 Modal은 SSR 중에 document에 접근하면 에러가 발생하므로, 마운트 여부를 체크하는 guard가 필요합니다.

SERIES COMPLETE

React 실무 설계 5단계 로드맵을 완주했습니다

이제 컴포넌트 구조, 상태관리, 로직 분리, UI 추상화까지 — 실무 React 개발에 필요한 핵심 설계 패턴을 모두 갖췄습니다. 다음 단계는 이 지식을 실제 프로젝트에 적용하는 것입니다.

→ Radix UI 내부 구조 분석
→ Headless UI + Tailwind 디자인 시스템
→ React 폴더 구조 설계 전략

📌 React 실무 설계 시리즈 — 5편 완결
1편: Vite + TypeScript React 입문 | 2편: Compound Component & Render Props
3편: Context API vs Zustand | 4편: Custom Hook 설계 패턴
5편: Headless UI 직접 만들기 (현재 글)
💡 좋은 React 설계는 "보여지는 코드"가 아니라 "숨겨진 구조"에서 결정됩니다

🔗 시리즈 이전 글

✓ TypeScript + React 입문자를 위한 첫 프로젝트 만들기 (Vite 기반)

✓ React 컴포넌트 설계 패턴 완전 입문 — Compound Component & Render Props

✓ React 상태관리 비교: Context API vs Zustand (최신 기준)

✓ React Custom Hook 설계 패턴 — 실무에서 통하는 로직 분리 전략

'AI' 카테고리의 다른 글

Magical로 만든 텍스트,Canva AI로 30초 만에 썸네일로 바꾸는 법  (1) 2026.05.10
Claude Dreaming — AI도 잠을 자야 똑똑해진다  (1) 2026.05.09
Blender MCP 설정 완전 정리— uv·addon.py·포트 9876 한 번에 해결  (1) 2026.05.08
Claude API 시작 가이드 —Blender·Adobe 자동화를 직접 만드는 방법  (0) 2026.05.08
크롬 확장 프로그램 자동화 가이드 - Magical + Thunderbit 사용법  (1) 2026.05.07
'AI' 카테고리의 다른 글
  • Magical로 만든 텍스트,Canva AI로 30초 만에 썸네일로 바꾸는 법
  • Claude Dreaming — AI도 잠을 자야 똑똑해진다
  • Blender MCP 설정 완전 정리— uv·addon.py·포트 9876 한 번에 해결
  • Claude API 시작 가이드 —Blender·Adobe 자동화를 직접 만드는 방법
Arahant
Arahant
  • Arahant
    MANI
    Arahant
    • 분류 전체보기 (58) N
      • 시사 (4)
      • 라이프 (4)
      • AI (50) N
  • 인기 글

  • 태그

    1인AI미디어
    1인사업자자동화
    1인크리에이터도구
    1인크리에이터자동화
    2026AI개발자
    2026AI개발자취업
    2026AI트렌드
    2026개발자취업
    2026웹개발
    2026웹개발트렌드
  • 최근 글

  • 블로그 메뉴

    • 홈
    • about
    • contact
    • 개인정보 처리방침
  • hELLO· Designed By정상우.v4.10.6
Arahant
Headless UI 직접 만들기 - 로직과 구조만 남기고 UI는 자유롭게
상단으로

티스토리툴바