shadcn/ui와 Radix: 컴포넌트를 커스터마이징하면서 접근성 지키기

지난주 한 동료가 제게 물었습니다. “이 버튼은 왜 키보드로 누를 수 없죠?”
잠시 당황했습니다. 분명 shadcn/ui를 쓰고 있는데 어떻게 이런 문제가 생긴 걸까요? DevTools를 열어 보니 Tooltip.Trigger 바깥을 <div>로 감싸 놓았습니다. 커스텀 스타일을 추가하려고 한 일이었지만, 바로 그것이 문제였습니다.
솔직히 저도 비슷한 실수를 한 적이 있습니다. shadcn/ui를 처음 쓸 때는 코드가 프로젝트에 그대로 복사되니 컴포넌트를 “마음대로 바꿔도 된다”고 생각했습니다. 스타일을 손보고, 태그를 바꾸고, wrapper를 추가해도 겉보기에는 문제가 없었습니다. 그러다 어느 날 QA 테스트에서 키보드 조작이 작동하지 않고, 스크린 리더가 내용을 읽지 못해 전체 상호작용 흐름이 끊긴다는 사실을 발견했습니다.
그제야 shadcn/ui가 주는 “자유”에는 대가가 따른다는 것을 알았습니다. 소스 코드는 내어 주지만 그 뒤에는 Radix의 접근성 기능이 숨어 있습니다. 함부로 수정하면 그 기능이 깨집니다.
이 글에서는 shadcn/ui와 Radix의 관계를 살펴보고, 컴포넌트를 커스터마이징하면서 접근성을 유지하는 방법에 집중합니다. asChild를 어떻게 써야 하는지, 포커스를 어떻게 관리하는지, ARIA 속성이 어떻게 상속되는지 이해하고 나면 적어도 다음에 컴포넌트를 수정할 때 무엇을 바꿔도 되고 무엇을 건드리면 안 되는지 판단할 수 있습니다.
shadcn/ui와 Radix는 정확히 어떤 관계인가요?
먼저 많은 사람이 혼동하는 점부터 짚겠습니다. shadcn/ui는 npm 패키지가 아닙니다.
npm install @shadcn/ui로 설치할 수 없습니다. 본질적으로는 컴포넌트 소스 코드를 제공하는 “코드 배포 플랫폼”입니다. 코드를 프로젝트에 복사한 뒤에는 온전히 여러분의 것이므로 원하는 대로 수정하거나 삭제할 수 있습니다.
그렇다면 이 컴포넌트들의 접근성 기능은 어디에서 올까요? 바로 Radix입니다.
Radix UI는 Primitives라고도 불리는 “스타일 없는 컴포넌트 라이브러리”입니다. 스타일 대신 동작을 제공합니다. 예를 들어 Dialog가 열릴 때 포커스를 어떻게 관리하는지, Dropdown Menu가 키보드 위·아래 화살표를 어떻게 처리하는지, Tooltip을 스크린 리더에서 어떻게 숨기는지를 담당합니다. 이 동작들은 WAI-ARIA 명세를 따르며 NVDA, JAWS, VoiceOver 같은 주요 스크린 리더로 실제 테스트를 거쳤습니다.
shadcn/ui는 Radix 위에 Tailwind CSS 스타일을 입힌 것입니다. 보기 좋은 외형을 제공하면서 Radix의 접근성 동작을 그 아래에 담아 둡니다. 복사해 온 버튼 코드는 몇 줄의 Tailwind 클래스처럼 보이지만, 실제로는 Radix의 로직을 감싸고 있습니다.
더 단순하게 말하면 다음과 같습니다.
- Radix는 ‘사용 가능함’을 담당합니다: aria 속성, role, 포커스 관리, 키보드 탐색
- shadcn/ui는 ‘보기 좋음’을 담당합니다: Tailwind 스타일, 디자인 일관성
따라서 shadcn/ui 컴포넌트를 수정할 때는 “표면”을 바꾸고 있지만 “기반”의 동작 로직은 Radix에서 온다는 점을 기억해야 합니다. 표면은 자유롭게 바꿀 수 있어도 기반을 잘못 건드리면 문제가 생깁니다.
asChild 속성: 마법일까요, 함정일까요?
asChild는 Radix에서 매우 특별한 속성입니다. 대부분의 Radix 컴포넌트는 각 “부분”에서 이 속성을 지원합니다.
무슨 뜻일까요? 예를 들어 Tooltip.Trigger는 기본적으로 <button> 요소로 렌더링됩니다. 하지만 링크에 Tooltip을 적용하고 싶을 때는 asChild를 사용합니다.
<Tooltip.Trigger asChild>
<a href="/help">도움말 센터</a>
</Tooltip.Trigger>
asChild={true}로 설정하면 Radix는 자체 <button>을 렌더링하지 않습니다. 대신 제공한 자식 요소를 “복제”하고 동작과 속성을 넘겨줍니다. 이 링크는 Tooltip Trigger의 모든 기능을 갖게 됩니다. 마우스를 올리면 Tooltip이 표시되고, 키보드로 포커스해도 실행되며, 올바른 aria 속성도 적용됩니다.
매우 편리해 보입니다.
하지만 함정도 여기에 있습니다.
포커스를 받을 수 없는 요소로 바꾸면 접근성이 완전히 사라집니다.
// ❌ 잘못된 예
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">클릭하세요</div>
</Tooltip.Trigger>
div는 키보드 포커스를 받을 수 없고(직접 tabIndex={0}을 추가하지 않는 한), Enter/Space 키에도 반응하지 않습니다. 스크린 리더도 이를 버튼으로 인식하지 않습니다. 키보드 사용자는 이 Tooltip에 아예 “도달할 수” 없습니다.
Radix 공식 문서에도 분명히 적혀 있습니다. “div로 바꾸면 더 이상 접근 가능한 요소가 아닙니다.”
물론 대부분은 직접 div로 바꾸기보다 자체 React 컴포넌트를 사용하는 경우가 많습니다.
<Tooltip.Trigger asChild>
<MyButton>클릭하세요</MyButton>
</Tooltip.Trigger>
이 방식은 괜찮지만 반드시 지켜야 할 규칙이 두 가지 있습니다.
1. 컴포넌트가 props를 모두 펼쳐야 합니다
Radix는 자식 요소를 복제하면서 이벤트 핸들러, aria 속성, ref 등 여러 속성을 전달합니다. 컴포넌트가 이 속성들을 받지 않으면 기능이 끊깁니다.
// ❌ 잘못된 예: props를 받지 않음
const MyButton = () => <button className="btn">...</button>
// ✅ 올바른 예: 모든 props를 펼침
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. 컴포넌트가 ref를 전달해야 합니다
Radix는 DOM 요소에 직접 접근해야 할 때가 있습니다. 크기를 측정하거나 포커스를 관리할 때가 대표적입니다. ref를 전달하지 않으면 오류가 발생합니다.
// ❌ 잘못된 예: ref를 받지 않음
const MyButton = (props) => <button {...props}>...</button>
// ✅ 올바른 예: ref 전달
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
사실 이 두 규칙은 Radix에만 필요한 것이 아닙니다. 어떤 “리프 컴포넌트”를 만들든 모든 props와 ref를 받는 것은 기본에 가깝습니다.
재미있는 활용법도 있습니다. 여러 Radix 컴포넌트를 서로 중첩할 수 있습니다.
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>다이얼로그 열기</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
버튼 하나가 Tooltip Trigger이면서 Dialog Trigger가 됩니다. 두 동작을 겹쳐도 모두 정상적으로 작동합니다.
포커스 관리와 키보드 탐색
포커스 관리는 접근성에서 가장 놓치기 쉬운 부분입니다.
많은 사람이 “보기 좋은 스타일”에만 신경 쓰고 사용자가 마우스를 쓰지 않을 수도 있다는 사실은 잊습니다. 키보드 사용자와 스크린 리더 사용자의 조작은 포커스 위치에 전적으로 의존합니다.
Radix는 이 부분을 상당히 많이 자동 처리합니다. 한 가지 예를 들면 다음과 같습니다.
AlertDialog가 열리면 포커스가 자동으로 Cancel 버튼으로 이동합니다.
세심하게 설계된 동작입니다. AlertDialog는 보통 삭제나 종료처럼 위험한 작업을 확인할 때 사용합니다. 다이얼로그를 연 사용자가 가장 먼저 할 가능성이 큰 행동은 “확인”보다 “취소”입니다. 포커스가 바로 Cancel에 있으면 Enter를 한 번 눌러 다이얼로그를 닫을 수 있으므로 실수를 막을 수 있습니다.
포커스가 Confirm 버튼에 있다면 어떨까요? 사용자가 실수로 Enter를 눌러 곧바로 삭제를 실행할 수 있습니다. 큰 사고로 이어집니다.
이 동작은 Radix가 WAI-ARIA 작성 지침에 따라 구현한 것이므로 직접 코드를 작성할 필요가 없습니다.
하지만 문제가 있습니다. AlertDialog 내용을 커스터마이징하면 포커스가 예상과 다른 곳으로 이동할 수 있습니다.
예를 들어 다이얼로그에 입력 필드를 추가해 보겠습니다.
<AlertDialog.Content>
<AlertDialog.Title>정말 삭제할까요?</AlertDialog.Title>
<AlertDialog.Description>확인하려면 "DELETE"를 입력하세요</AlertDialog.Description>
<input placeholder="DELETE 입력" /> {/* 직접 추가한 요소 */}
<AlertDialog.Cancel>취소</AlertDialog.Cancel>
<AlertDialog.Action>확인</AlertDialog.Action>
</AlertDialog.Content>
이제 다이얼로그를 열면 포커스는 어디로 갈까요?
Radix는 기본적으로 포커스를 받을 수 있는 첫 번째 요소를 찾습니다. input이 Cancel 앞에 있으므로 포커스가 input으로 이동합니다. 사용자가 Cancel에 도달하려면 Tab을 몇 번 눌러야 하고, 이는 예상한 흐름을 방해합니다.
해결 방법은 autoFocus로 포커스 대상을 지정하거나 요소 순서를 조정하는 것입니다.
<AlertDialog.Content>
<AlertDialog.Title>정말 삭제할까요?</AlertDialog.Title>
<AlertDialog.Description>확인하려면 "DELETE"를 입력하세요</AlertDialog.Description>
<AlertDialog.Cancel autoFocus>취소</AlertDialog.Cancel> {/* 포커스 강제 지정 */}
<input placeholder="DELETE 입력" />
<AlertDialog.Action>확인</AlertDialog.Action>
</AlertDialog.Content>
Cancel을 앞쪽으로 옮기거나 autoFocus를 추가하면 포커스가 잘못된 곳으로 가지 않습니다.
키보드 탐색에도 비슷한 문제가 있습니다.
Tabs 컴포넌트에서 사용자는 좌우 화살표로 탭을 전환합니다. 이는 WAI-ARIA 표준 동작입니다. 탭에 커스텀 스타일을 추가하면서 실수로 role="tab"을 덮어쓰면 키보드 탐색이 작동하지 않습니다.
Dropdown Menu에서는 위·아래 화살표로 메뉴 항목을 선택하고, Enter로 확정하며, Esc로 닫습니다. 모두 Radix 내부에서 처리하는 동작입니다. 하지만 메뉴 항목에 onSelect 대신 onClick을 추가하면 키보드 동작을 망가뜨릴 수 있습니다.
테스트 방법은 단순하고 확실합니다. 마우스를 치우고 키보드만으로 컴포넌트의 전체 흐름을 조작해 보세요.
- Tab으로 컴포넌트에 진입할 수 있나요?
- 화살표 키로 선택지를 전환할 수 있나요?
- Enter로 동작을 실행할 수 있나요?
- Esc로 다이얼로그를 닫을 수 있나요?
어느 한 단계라도 막히면 접근성에 문제가 있다는 뜻입니다.
ARIA 속성 자동 상속
ARIA 속성은 Radix가 많은 작업을 대신 처리해 줍니다.
컴포넌트에 올바른 role과 aria-* 속성을 자동으로 추가합니다. 예를 들면 다음과 같습니다.
- Dialog에는
role="dialog"와aria-modal="true"가 추가됩니다 - Tabs.Tab에는
role="tab"과aria-selected가 추가됩니다 - Switch에는
role="switch"와aria-checked가 추가됩니다
이 속성들은 신경 쓰지 않아도 됩니다. Radix 내부에서 이미 처리합니다.
하지만 반드시 직접 해야 할 일이 하나 있습니다. 컨트롤에 접근 가능한 이름(accessible name)을 제공해야 합니다.
스크린 리더 사용자는 이 버튼이 무엇인지, 이 다이얼로그의 이름이 무엇인지, 이 입력 필드에 무엇을 입력해야 하는지 알아야 합니다. 이름이 없으면 추측할 수밖에 없습니다.
Radix는 이를 돕는 Label primitive를 제공합니다.
<Label.Root htmlFor="email-input">이메일 주소</Label.Root>
<Input id="email-input" />
Label.Root는 input과 자동으로 연결됩니다. 스크린 리더는 “이메일 주소”를 먼저 읽고 입력 필드의 값을 이어서 읽습니다.
네이티브 input이 아닌 커스텀 컨트롤에는 이름을 직접 제공해야 합니다.
<Switch aria-label="야간 모드 사용" />
<Tabs.Tab aria-label="제품 상세 정보" />
또는 aria-labelledby로 화면에 보이는 텍스트를 연결할 수 있습니다.
<div id="mode-label">야간 모드</div>
<Switch aria-labelledby="mode-label" />
확인하려면 스크린 리더를 켜고 한 번 조작해 보세요.
Mac에는 VoiceOver(Cmd+F5로 실행), Windows에는 무료로 내려받을 수 있는 NVDA가 있습니다. 스크린 리더가 컴포넌트를 어떻게 읽는지 들어 보세요. “주문 제출 버튼”이 아니라 “버튼”이라고만 읽는다면 접근 가능한 이름이 빠진 것입니다.
한 가지 더 있습니다. 바로 색상 대비입니다.
Radix는 스타일을 다루지 않으므로 색상 대비는 개발자의 책임입니다. WCAG는 텍스트와 배경의 명암비가 일반 텍스트는 최소 4.5:1, 큰 텍스트는 최소 3:1이 되도록 요구합니다. shadcn/ui의 기본 색상은 대체로 기준을 충족하지만 직접 색상을 바꿀 때는 주의해야 합니다.
WebAIM Contrast Checker라는 도구에 전경색과 배경색을 입력하면 명암비를 계산할 수 있습니다.
실전 점검 목록
shadcn/ui 컴포넌트를 커스터마이징할 때마다 다음 목록으로 확인해 보세요.
asChild 점검
asChild의 자식이 포커스를 받을 수 있는 요소인가요? (button/a/input이며 div가 아닌가요?)- 커스텀 컴포넌트가 모든 props를 펼치나요?
- 커스텀 컴포넌트가 ref를 전달하나요?
포커스 관리 점검
- 다이얼로그가 열릴 때 포커스가 올바른 위치로 이동하나요?
- 다이얼로그가 닫힌 뒤 포커스가 트리거 요소로 돌아오나요?
- 포커스를 받을 수 있는 요소가 중첩되어 있을 때 포커스 순서가 합리적인가요?
키보드 탐색 점검
- Tab으로 컴포넌트에 진입할 수 있나요?
- 화살표 키로 선택지를 전환할 수 있나요? (Tabs, Dropdown)
- Enter로 동작을 실행할 수 있나요?
- Esc로 다이얼로그를 닫을 수 있나요?
- Space로 상태를 전환할 수 있나요? (Switch, Checkbox)
ARIA 점검
- 모든 컨트롤에 접근 가능한 이름이 있나요?
- 스크린 리더가 역할과 상태를 정확히 읽나요?
- 동적 상태 변경에 올바른 aria-live 영역이 있나요?
시각적 점검
- 포커스 표시가 선명하게 보이나요?
- 색상 명암비가 기준을 충족하나요? (4.5:1 또는 3:1)
- 색상만으로 정보를 전달하지 않나요? (아이콘이나 텍스트가 함께 있나요?)
테스트 도구
- 키보드 테스트: 마우스를 치우고 키보드만으로 전체 흐름 조작
- 스크린 리더: VoiceOver(Mac) 또는 NVDA(Windows)
- 자동화: axe DevTools 브라우저 확장 프로그램
결론
shadcn/ui는 코드 수정의 자유를 주지만 그 자유에는 경계가 있습니다.
그 경계는 Radix의 접근성 동작입니다. 스타일, 레이아웃, 클래스 이름은 바꿀 수 있지만 기반의 동작 로직을 망가뜨리면 안 됩니다. button을 div로 바꾸거나 props 펼치기를 빼먹는 순간 키보드 사용자가 피해를 봅니다.
다음 몇 가지를 기억하세요.
- asChild를 사용할 때: 자식은 포커스를 받을 수 있어야 하며, 커스텀 컴포넌트는 props를 펼치고 ref를 전달해야 합니다
- 포커스 관리: 다이얼로그 내용을 커스터마이징할 때 포커스가 어디로 이동하는지 확인합니다
- ARIA 속성: role은 Radix가 자동으로 추가하지만 label은 개발자가 제공해야 합니다
다음에 컴포넌트를 수정하면 먼저 키보드로 한 번 조작해 보세요. 문제가 보이면 QA가 찾아오기 전에 바로 고치면 됩니다.
결국 접근성은 “추가 기능”이 아니라 기본 요구 사항입니다. shadcn/ui와 Radix가 가장 어려운 부분을 이미 처리해 주고 있으니, 그 노력을 망가뜨리지 않는 것이 남은 일입니다.
FAQ
shadcn/ui와 Radix UI는 어떤 관계인가요?
접근성을 해치지 않으려면 asChild 속성을 어떻게 사용해야 하나요?
다이얼로그 내용을 커스터마이징할 때 포커스 관리는 무엇을 주의해야 하나요?
컴포넌트의 접근성이 제대로 작동하는지 어떻게 테스트하나요?
Radix가 aria 속성을 자동으로 추가해도 제가 해야 할 일이 있나요?
2분 읽기 · 게시일: 2026년 3월 30일 · 수정일: 2026년 9월 4일
Tailwind & shadcn/ui 실전
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
shadcn/ui 컴포지션 패턴: 여러 컴포넌트를 함께 사용하는 모범 사례
shadcn/ui 컴포지션 패턴의 모범 사례를 배우고 Dialog+Form, DataTable+DropdownMenu 같은 대표 조합과 Context 패턴, 상태 관리, 성능 최적화 등 고급 주제를 익혀 봅니다.
14편 중 8편
다음
Dialog, Sheet, Popover: 오버레이 컴포넌트의 접근성과 포커스 관리
shadcn/ui의 Dialog, Sheet, Popover 세 가지 오버레이 컴포넌트가 접근성과 포커스를 처리하는 방식을 자세히 살펴봅니다. WCAG 표준, ARIA 속성, 키보드 탐색, 포커스 트랩과 전체 코드 예제를 함께 설명합니다.
14편 중 10편



댓글
GitHub로 로그인하여 댓글을 남기세요