접근성은 라이브러리가, 스타일은 내가 — Radix UI·React Aria와 tailwind-variants로 headless 컴포넌트 시스템 구성하기
이 글은 React와 Tailwind CSS를 이미 사용하고 있는 프론트엔드 개발자를 대상으로 합니다.
접근성 있는 UI를 직접 구현해본 적 있다면, 그게 얼마나 까다로운 일인지 잘 알 것입니다. Dialog 하나를 제대로 만들려면 role="dialog", aria-modal, 포커스 트랩, 배경 스크롤 잠금, ESC 키 처리까지 신경 써야 하죠. 저도 처음에 그 목록을 보고 "이걸 다 내가 해야 해?" 싶었던 기억이 납니다. 실제로 스크린 리더 사용자는 전체 웹 사용자의 약 7~10%로 알려져 있고, 글로벌 서비스라면 접근성 감사(accessibility audit)를 통과하지 못할 경우 법적 리스크로까지 이어질 수 있습니다.
그런데 요즘 프론트엔드 생태계는 이 문제를 꽤 우아하게 풀어냈습니다. Radix UI, React Aria 같은 headless 컴포넌트 라이브러리가 접근성 로직을 통째로 떠안아주고, tailwind-variants가 그 위에 체계적인 스타일 레이어를 얹을 수 있게 해줍니다. 두 레이어를 조합하면 "접근성은 라이브러리가, 디자인은 내가"라는 깔끔한 분업이 완성됩니다.
이 글은 headless 컴포넌트 시스템 + 디자인 토큰으로 이어지는 시리즈의 첫 번째로, headless 라이브러리와 tailwind-variants를 조합해 접근성과 디자인 제어권을 모두 가진 컴포넌트 시스템을 구성하는 방법을 실제 코드와 함께 풀어봅니다. 어떤 라이브러리를 선택해야 하는지, 실무에서 어떤 실수가 자주 발생하는지까지 솔직하게 다뤄보겠습니다.
핵심 개념
로직과 스타일을 분리한다는 것의 의미
전통적인 UI 라이브러리(MUI, Chakra 등)는 로직과 스타일을 한 묶음으로 제공합니다. 편리하지만, 디자인 시스템이 생기거나 브랜드 커스터마이징이 필요해지면 그때부터 싸움이 시작됩니다. 기존 스타일을 덮어쓰는 !important 지옥, 라이브러리 내부 클래스명 의존, 업그레이드할 때마다 깨지는 오버라이드... 익숙한 광경이죠.
Headless 아키텍처는 이 묶음을 아예 분리합니다.
Headless 컴포넌트 라이브러리는 WAI-ARIA 준수, 키보드 탐색, 포커스 관리 같은 인터랙션 로직만 제공하고 시각적 스타일은 전혀 포함하지 않는 라이브러리입니다. 빈 껍데기가 아니라, 접근성 엔진이 탑재된 무색(colorless) 컴포넌트라고 생각하면 됩니다.
WAI-ARIA가 처음이라면 MDN의 'WAI-ARIA 기초' 문서를 먼저 훑어보는 것을 권장합니다. 구조를 이해하면 headless 라이브러리가 얼마나 많은 것을 대신해주는지 훨씬 선명하게 보입니다.
이 구조를 세 레이어로 정리하면 다음과 같습니다.
| 레이어 | 도구 예시 | 담당 역할 |
|---|---|---|
| 인터랙션 레이어 | Radix UI, React Aria, Base UI | ARIA 속성, 키보드 탐색, 포커스 트랩 |
| 스타일 레이어 | tailwind-variants, CVA | variant 기반 Tailwind 클래스 조합 |
| 토큰 레이어 | CSS 변수, Tailwind 테마 | 색상, 타이포, 간격의 일관성 |
tailwind-variants가 CVA와 다른 이유
tailwind-variants는 Stitches의 variant API 개념을 Tailwind에 이식한 라이브러리입니다. 핵심 함수인 tv()로 기본 스타일, variants, compoundVariants를 선언적으로 정의하고, 클래스 충돌도 자동으로 병합해줍니다.
CVA(Class Variance Authority)와 자주 비교되는데, 실무에서 체감되는 차이점은 다음과 같습니다.
| 기능 | tailwind-variants | CVA |
|---|---|---|
| Slots (복합 컴포넌트 분리) | ✅ | ❌ |
| 스타일 상속 / extend | ✅ | ❌ |
| Tailwind 클래스 충돌 자동 병합 | ✅ | 별도 설정 필요 |
| CSS 전략 독립성 | Tailwind 전용 | CSS 전략 무관 |
솔직히 Button 하나짜리 단순한 컴포넌트라면 CVA로도 충분합니다. 저도 처음엔 "굳이 tailwind-variants가 필요할까?" 싶었는데, Dialog처럼 Overlay, Content, Title, Description이 한 세트인 복합 컴포넌트가 생기는 순간 slots의 진가가 바로 드러났습니다. 반면 Tailwind 이탈 가능성이 있거나 CSS Modules와 병행하는 환경이라면, CVA가 CSS 전략에 무관하게 동작하므로 더 중립적인 선택입니다.
주요 headless 라이브러리 비교
제가 최근 여러 프로젝트에서 검토했거나 동료들이 선택하는 것을 본 라이브러리들을 정리하면 이렇습니다.
| 라이브러리 | 컴포넌트 수 | 특징 | 주요 사용처 |
|---|---|---|---|
| Radix UI | 30+ | 합성 API, React 전용 | shadcn/ui 기반 |
| React Aria (Adobe) | 50+ | 훅 기반, 국제화·적응형 인터랙션 포함 | 최고 수준 접근성 요구 |
| Base UI (MUI) | 35 | 2026년 v1.0 출시 | MUI 생태계 유지보수 |
| Headless UI | ~10 | 경량, Tailwind Labs 제작 | 소규모 프로젝트 |
2026년 2월 Base UI가 1.0 정식 버전을 출시하면서, shadcn/ui가 Radix 백엔드 또는 Base UI 백엔드 중 선택할 수 있게 됐습니다. 선택지가 넓어진 셈이죠. 직접 비교해봤을 때 Radix는 합성 API의 완성도와 생태계가 더 풍부한 편이고, Base UI는 MUI 팀이 장기 유지보수를 보장한다는 점에서 엔터프라이즈 환경에 유리합니다.
선택 기준 요약: 접근성 요구가 최고 수준이고 국제화까지 필요하다면 React Aria, 빠른 디자인 시스템 구축이 목표라면 Radix UI + shadcn/ui, MUI 생태계 안에서 움직이고 싶다면 Base UI를 검토해보면 좋습니다.
실전 적용
예시 1: Radix UI + tailwind-variants로 Button 컴포넌트 만들기
가장 기본이 되는 패턴입니다. asChild 패턴을 함께 이해하기 위해 Radix의 Slot과 결합하는 구조로 보여드립니다.
// components/button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { tv, type VariantProps } from "tailwind-variants"
const button = tv({
base: [
"inline-flex items-center justify-center rounded-md font-medium",
"transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
],
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2 text-sm",
sm: "h-9 px-3 text-xs",
lg: "h-11 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
})
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof button> {
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp ref={ref} className={button({ variant, size, className })} {...props} />
)
}
)
Button.displayName = "Button"| 코드 포인트 | 설명 |
|---|---|
tv({ base, variants, defaultVariants }) |
스타일 정의를 한 곳에 집중. className prop으로 외부 오버라이드 가능 |
asChild + Slot |
Link, RouterLink 등 다른 엘리먼트에 버튼 스타일을 씌울 때 사용. props와 ref가 자동으로 전달됨 |
React.forwardRef |
Radix 프리미티브와 조합 시 ref 포워딩이 없으면 포커스 관리가 깨질 수 있어서 필수 |
VariantProps<typeof button> |
TypeScript가 잘못된 variant 값을 컴파일 타임에 잡아줌 |
asChild패턴이란? Radix의Slot컴포넌트를 통해 자신의 props를 자식 엘리먼트에 병합하는 패턴입니다.<Button asChild><a href="/home">홈</a></Button>처럼 Button 스타일을<a>태그에 씌울 때 사용합니다. 이때 ref와 모든 props가 정상적으로 전달되지 않으면 접근성이 조용히 깨지므로, 반드시React.forwardRef+...props전개 패턴을 함께 유지하는 것을 권장합니다.
예시 2: Radix Dialog + tailwind-variants Slots로 Modal 만들기
복합 컴포넌트에서 slots의 진가가 드러나는 시나리오입니다. Dialog는 Overlay, Content, Title, Description이 각각 독립적으로 스타일링돼야 하는데, slots를 쓰면 하나의 tv() 정의 안에서 이를 깔끔하게 관리할 수 있습니다.
// components/modal.tsx
import * as React from "react"
import * as Dialog from "@radix-ui/react-dialog"
import { tv } from "tailwind-variants"
const dialog = tv({
slots: {
overlay: [
"fixed inset-0 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
],
content: [
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"rounded-lg bg-white p-6 shadow-xl",
"w-full max-w-md",
"focus:outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
],
title: "text-lg font-semibold leading-none tracking-tight",
description: "text-sm text-muted-foreground mt-2",
closeButton: "absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100",
},
})
const { overlay, content, title, description, closeButton } = dialog()
interface ModalProps {
trigger: React.ReactNode
title: string
description?: string
children: React.ReactNode
}
export function Modal({ trigger, title: titleText, description: descText, children }: ModalProps) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
{/* Radix가 aria-hidden, role="dialog", 포커스 트랩을 자동 처리 */}
<Dialog.Overlay className={overlay()} />
<Dialog.Content className={content()}>
<Dialog.Title className={title()}>{titleText}</Dialog.Title>
{descText && (
<Dialog.Description className={description()}>{descText}</Dialog.Description>
)}
{children}
<Dialog.Close className={closeButton()} aria-label="닫기">
<span aria-hidden="true">✕</span>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}이 구조에서 Radix가 자동으로 처리해주는 것들을 짚어보면 다음과 같습니다.
| Radix가 자동 처리하는 항목 | 직접 구현했다면 |
|---|---|
role="dialog", aria-modal="true" |
수동으로 속성 추가 |
| 포커스 트랩 (Dialog 안에서만 탭 이동) | focusTrap.js 같은 라이브러리 추가 |
| ESC 키로 닫기 | keydown 이벤트 리스너 추가 |
| Dialog 닫힐 때 원래 포커스 위치로 복귀 | ref로 이전 포커스 요소 저장 후 수동 복원 |
| 배경 스크롤 잠금 | body overflow 스타일 직접 조작 |
data-[state=open] 같은 Tailwind의 인터랙티브 변형자를 활용하면 JavaScript 없이 CSS 레벨에서 애니메이션도 처리할 수 있습니다. 그리고 Close 버튼처럼 텍스트가 ✕ 특수문자뿐인 경우, 스크린 리더가 이를 제대로 읽지 못하므로 aria-label="닫기"를 반드시 달아주는 것이 중요합니다. 접근성을 다루는 글인 만큼 예시 코드 자체도 접근성 기준을 지켜야 하니까요.
예시 3: React Aria 훅 + tailwind-variants (최고 수준 접근성)
국제화, 스크린 리더 지원, 플랫폼별 인터랙션 차이까지 고려해야 하는 상황이라면 React Aria가 선택지에 올라옵니다. 훅 기반이라 스타일 결합이 매우 자유롭습니다. React Aria는 개별 패키지(@react-aria/*)로도 설치할 수 있지만, 현재 공식 권장 방식은 react-aria 통합 패키지를 사용하는 것입니다.
// components/aria-button.tsx
import { useButton, type AriaButtonProps } from "react-aria"
import { useRef } from "react"
import { tv } from "tailwind-variants"
const button = tv({
base: "rounded px-4 py-2 font-medium transition-all",
variants: {
isPressed: {
true: "scale-95 opacity-80",
},
isDisabled: {
true: "cursor-not-allowed opacity-50",
},
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
},
},
defaultVariants: {
variant: "primary",
},
})
interface AriaButtonComponentProps extends AriaButtonProps {
variant?: "primary" | "secondary"
}
export function AriaButton({ children, variant = "primary", ...props }: AriaButtonComponentProps) {
const ref = useRef<HTMLButtonElement>(null)
const { buttonProps, isPressed } = useButton(props, ref)
// buttonProps에는 이미 disabled 처리가 포함되어 있습니다.
// isDisabled를 tv()에 별도로 전달하는 것은 CSS 스타일 variant 적용 전용이며,
// HTML disabled 속성과는 별개로 동작합니다.
return (
<button
{...buttonProps}
ref={ref}
className={button({
variant,
isPressed,
isDisabled: props.isDisabled,
})}
>
{children}
</button>
)
}React Aria의
useButton은 클릭 이벤트뿐 아니라 Space/Enter 키 처리, 터치 디바이스의 press 상태, 화면 확대 시 포인터 이벤트까지 처리합니다.isPressed상태를 variant로 넘기는 방식으로 스타일과 자연스럽게 연결됩니다.isDisabled: props.isDisabled를tv()에 별도로 전달하는 이유는cursor-not-allowed같은 CSS 스타일을 제어하기 위해서입니다.buttonProps가 HTMLdisabled속성을 처리해주는 것과는 역할이 다르므로 중복이 아닙니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 접근성 자동화 | WAI-ARIA 역할·속성, 키보드 탐색, 포커스 트랩이 라이브러리 레벨에서 처리되어 별도 구현 불필요 |
| 디자인 완전 제어 | 스타일을 100% 소유하므로 브랜드 아이덴티티와 정확히 일치하는 UI 구현 가능 |
| 타입 안전 variants | TypeScript 자동완성으로 잘못된 variant 값을 컴파일 타임에 차단 |
| slots 패턴 | 복합 컴포넌트의 각 부분을 독립적으로 스타일링하면서 변형 상태를 공유 가능 |
| 번들 최적화 | Tree-shakable 구조로 사용한 컴포넌트만 번들에 포함 |
| 유지보수성 | 스타일 정의가 한 곳에 집중되어 일관성 유지 용이 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 초기 구축 비용 | 스타일이 없으므로 모든 컴포넌트를 직접 스타일링해야 함 | shadcn/ui 코드를 출발점으로 사용하는 것을 권장 |
| CSS 숙련도 요구 | 디자인 경험이 부족한 팀은 빈 캔버스가 부담 | 디자인 토큰(CSS 변수)을 먼저 정의한 후 컴포넌트 작업 시작 |
| 접근성 파괴 위험 | asChild 오용, props 미전달, ref 미포워딩으로 접근성이 깨질 수 있음 |
항상 React.forwardRef + ...props 전개 패턴 유지 |
| 학습 곡선 | Radix 합성 API, React Aria 훅, tailwind-variants slots를 동시에 학습해야 함 | 한 번에 하나씩, Radix + CVA → tailwind-variants slots 순으로 단계적 도입 권장 |
| 라이브러리 락인 | tailwind-variants는 Tailwind 전용이므로 CSS-in-JS 전환 시 재작업 필요 | Tailwind 이탈 가능성이 있다면 CVA가 더 중립적인 선택 |
실무에서 가장 흔한 실수
이 조합을 도입할 때 저도, 그리고 팀원들도 처음엔 비슷한 지점에서 걸렸습니다.
-
React.forwardRef생략: Radix 내부에서 ref를 통해 포커스를 제어하는 경우가 많습니다.forwardRef없이 커스텀 컴포넌트를 Radix Trigger로 사용하면 포커스 복귀가 정상 작동하지 않아 접근성 감사(audit)에서 탈락합니다. Dialog를 닫은 뒤 포커스가 허공으로 사라지는 현상이 생기면 여기부터 확인해보는 것을 권장합니다. -
...props전개를 빠뜨린 채 필요한 prop만 골라 전달:aria-*,data-*속성이 Radix 내부적으로 주입되는 경우가 있습니다. 이를 전달하지 않으면 스타일과 동작이 분리되는 현상이 발생합니다.data-[state=open]스타일이 적용되지 않는 버그 대부분이 이 경우입니다. -
tailwind-merge없이className오버라이드 시도:tv()는 내부적으로 tailwind-merge를 사용하여 클래스 충돌을 해결합니다. 하지만tv()밖에서 직접 클래스를 병합할 때clsx만 쓰면p-2 p-4가 공존하는 충돌이 발생합니다. 외부 병합은 반드시cn()(clsx+tailwind-merge조합) 유틸을 활용하는 것을 권장합니다.
마치며
접근성은 나중에 추가하는 기능이 아니라, 컴포넌트 구조의 첫 번째 레이어여야 합니다. headless 라이브러리 + tailwind-variants 조합은 바로 이 원칙을 현실적으로 구현할 수 있는 가장 실용적인 방법 중 하나입니다.
처음 셋업하는 데 시간이 드는 건 사실이지만, 디자인 시스템이 성장할수록 투자 회수는 분명히 일어납니다. 스타일 오버라이드로 씨름하던 시간이 사라지고, 접근성 버그 리포트가 줄어드는 경험을 해보시면 좋겠습니다.
지금 바로 시작해볼 수 있는 3단계:
- shadcn/ui CLI로 기반을 빠르게 구성해볼 수 있습니다.
pnpm dlx shadcn@latest init명령으로 Radix + tailwind-variants 기반의 컴포넌트 시스템을 바로 시작할 수 있습니다. 생성된components/ui/button.tsx를 열어tv()구조가 어떻게 작성되어 있는지 직접 읽어보는 것을 권장합니다. - 복합 컴포넌트 하나를 slots로 직접 리팩터링해볼 수 있습니다. 기존에 사용 중인 Dialog나 Dropdown 컴포넌트가 있다면, 그 스타일 정의를
tv({ slots: { ... } })구조로 옮겨보면 slots 개념이 자연스럽게 체득됩니다. - axe DevTools 브라우저 확장을 설치하고 기존 컴포넌트를 한 번 스캔해볼 수 있습니다. 평소에 인지하지 못했던 접근성 이슈가 얼마나 있는지 확인할 수 있고, headless 라이브러리 도입의 필요성이 훨씬 구체적으로 느껴질 것입니다.
참고 자료
- Top Headless UI libraries for React in 2026 | GreatFrontEnd
- Headless UI alternatives: Radix Primitives vs React Aria vs Ark UI vs Base UI | LogRocket Blog
- Radix UI vs Headless UI vs Ariakit: The Headless Component War | JavaScript in Plain English
- React Aria 공식 문서 | Adobe
- React Aria Quality & Accessibility | Adobe
- Tailwind Variants 공식 문서 — Slots
- Tailwind Variants — Composing Components
- CVA vs. Tailwind Variants: Choosing the Right Tool | DEV Community
- shadcn/ui and Radix: Accessibility When Customizing | BetterLink Blog
- MUI Releases Base UI 1 with 35 Accessible Components | InfoQ
- Base UI Releases | base-ui.com
- shadcn/ui vs Base UI vs Radix: Components in 2026 | PkgPulse
- Build accessible components with React Aria | OpenReplay Blog