Deep Dive - shadcn/ui (with. radix-ui)

Deep Dive - shadcn/ui (with. radix-ui)

shadcn/ui란 ?

shadcn-ui는 최근 프론트엔드 생태계에서 큰 주목을 받고 있는 UI 컴포넌트 컬렉션이다.

여기서 중요한 점은 "라이브러리"가 아니라, 복사해서 직접 사용하는 컴포넌트 모음이라는 점이다.

This is NOT a component library. It's a collection of re-usable components that you can copy and paste into your apps.

핵심 특징

  • Radix UI 기반: shadcn-ui의 모든 컴포넌트는 Radix UI의 headless primitives를 활용한다.
  • Tailwind CSS 스타일링: 기능적 뼈대 위에 Tailwind CSS로 스타일을 입힌다.
  • CLI 기반 설치: npm 패키지로 설치하는 것이 아니라, CLI로 프로젝트에 컴포넌트 코드를 직접 복사해온다.

    → 높은 커스터마이징, 유연성 제공

shadcn-ui의 프로젝트 구조

실제 레포를 까보면?

  • apps/
    • apps/www: shadcn-ui 공식 문서 사이트(실제 배포되는 docs)
    • apps/v4: 데모/테스트 앱
  • packages/
    • packages/cli: shadcn-ui의 CLI 도구(컴포넌트 설치, 레지스트리 관리 등)
    • packages/shadcn: 레지스트리 및 컴포넌트 메타데이터, API 등 핵심 로직

shadcn-ui의 공식 GitHub 레포지토리를 보면, 우리가 기대하는 "컴포넌트 소스 코드"가 직접적으로 들어있지 않다.

왜 컴포넌트 소스 코드가 없을까?

shadcn-ui는 전통적인 npm 패키지 방식이 아니라, 코드 배포 플랫폼을 지향한다.

컴포넌트 소스는 GitHub에 직접 포함되어 있지 않고, 별도의 레지스트리(Registry) 서버에 JSON 포맷으로 저장되어 있다.

CLI가 이 레지스트리에서 컴포넌트 소스 코드를 가져와서, 프로젝트의 /components/ui 폴더에 복사해 넣어주는 구조다.

아무리 그래도 원본 소스가 프로젝트내에 없을 줄은 몰랐다. 임의로 아래 링크에 shadcn components를 모아놨으니 참고해보자~ (pnpm dlx shadcn@latest add --all 명령어로 모든 컴포넌트를 설치할 수 있다)

https://github.com/01-binary/shadcn-example

components.json이란 ?

components.json 은 shadcn-ui를 CLI로 설치할 때 프로젝트의 환경과 커스텀 옵션을 정의하는 설정 파일이다.

shadcn-ui CLI가 프로젝트의 구조와 스타일 시스템(Tailwind, 경로 alias 등)을 이해하고, 각자 환경에 맞춰 컴포넌트를 자동으로 생성·설치할 수 있도록 도와준다.

components.json 은 CLI로 컴포넌트를 추가할 때만 필요하다. 단순히 복사/붙여넣기로 컴포넌트를 쓸 경우에는 없어도 된다.

주요 필드와 기능

필드명설명
$schemaJSON Schema 경로. 자동완성 및 타입 체크에 사용됨.
style컴포넌트의 스타일 테마(예: new-york). 초기화 후 변경 불가.
tailwindTailwind CSS 관련 설정(아래 세부 항목 참고)
rscReact Server Components 지원 여부. true면 client 컴포넌트에 "use client" 자동 추가.
tsxTypeScript(true) 또는 JavaScript(false)로 컴포넌트 생성 여부.
aliases경로 alias 설정(아래 세부 항목 참고)

tailwind 관련 세부 설정

  • tailwind.config: Tailwind 설정 파일 경로 (v4에서는 빈 값)
  • tailwind.css: Tailwind CSS를 import하는 CSS 파일 경로
  • tailwind.baseColor: 컴포넌트의 기본 컬러 팔레트(초기화 후 변경 불가)
  • tailwind.cssVariables: 테마용 CSS 변수 사용 여부 (true=CSS 변수, false=유틸리티 클래스)
  • tailwind.prefix: Tailwind 유틸리티 클래스 접두어

aliases 관련 세부 설정

  • aliases.utils: 유틸 함수 import alias
  • aliases.components: 일반 컴포넌트 import alias
  • aliases.ui: UI 컴포넌트 import alias (여기에 따라 설치 위치가 결정됨)
  • aliases.lib: lib 함수 import alias
  • aliases.hooks: 커스텀 훅 import alias

핵심 기술 스택: Radix UI, Tailwind CSS, cva

shadcn-ui를 까보면, 겉으론 단순한 UI 컴포넌트 모음처럼 보이지만 실제로는 세 가지 기술이 뼈대를 이루고 있다.

이 셋이 어떻게 맞물려 있는지, 그리고 왜 이렇게 설계했는지 직접 코드와 원리 중심으로 정리해본다.

Radix UI – 동작의 베이스

shadcn-ui의 거의 모든 컴포넌트는 Radix UI의 Headless Primitives 위에 올라간다.

Radix UI는 스타일이 없는, 순수하게 동작만 제공하는 컴포넌트 세트다.

예를 들어, Accordion, Dialog, Popover, Tabs 같은 복잡한 UI의 상태 관리, 트리거, 토글 등은 Radix UI가 책임진다.

import * as AccordionPrimitive from "@radix-ui/react-accordion";

function Accordion({
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
  return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}

function AccordionItem({
  className,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-item"
      className={cn('border-b last:border-b-0', className)}
      {...props}
    />
  );
}

//...

위와 같이 Radix의 Primitive를 래핑해서 쓰는 구조다. 덕분에 shadcn-ui는 UI 동작을 직접 구현할 필요 없이, 검증된 로직 위에 스타일만 입히면 된다.

Tailwind CSS – 스타일링의 표준

스타일링은 100% Tailwind CSS로 처리한다.

컴포넌트의 className 에 Tailwind 유틸리티가 깔끔하게 조합되어 들어가고, 디자인 시스템의 색상, 간격, 폰트 등은 Tailwind의 설정(tailwind.config.js)과 CSS 변수로 관리한다.

<button
  className={cn(
    "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
    className
  )}
  {...props}
/>

이런 식으로 기본 스타일을 잡고, 추가적인 변형(variant)이나 사이즈는 cva로 분리해서 관리한다.

cva(Class Variance Authority) – Variant 관리의 핵심

Tailwind로 스타일을 짜다 보면, variant(버튼 타입, 크기 등)별로 className 분기가 점점 복잡해진다. 여기서 cva가 진가를 발휘한다.

import { cva } from "class-variance-authority";

const buttonVariants = cva(
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
  {
    variants: {
      variant: {
        default: "bg-primary text-white",
        destructive: "bg-red-500 text-white",
        outline: "border border-gray-300 text-gray-900",
      },
      size: {
        sm: "px-2 py-1",
        lg: "px-4 py-2",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "sm",
    },
  }
);

이렇게 선언해두면,

<button className={buttonVariants({ variant: "destructive", size: "lg" })}>
  Delete
</button>

이런 식으로 variant prop만 바꿔주면 알아서 className 이 조합된다. 스타일 분기가 많아질수록 cva의 가치는 더 커진다.

cn 유틸리티 – className 병합

function Button({
  className,
  variant,
  size,
  ...props
}) {
  return (
    <button
      data-slot="button"
      className={cn(buttonVariants({ variant, size, className }))}
      {...props}
    />
  )
}

여기서 cn 함수는 cva로 동적으로 만들어진 클래스, 사용자가 추가로 전달한 className 이 둘을 조건부로, 그리고 충돌 없이 병합 해준다.

cn 함수 내부는 어떻게 생겼나?

import { clsx } from "clsx"; // 조건부로 className을 문자열로 조합
import { twMerge } from "tailwind-merge"; // Tailwind 클래스끼리 충돌나는 경우, 해결

export function cn(...inputs) {
  return twMerge(clsx(inputs));
}

예를 들어, "px-2 py-1 bg-blue-500""px-4" 가 들어오면, 최종적으로 "py-1 bg-blue-500 px-4" 처럼 중복 없이 병합된다.

Headless UI & Composition: shadcn-ui의 설계 철학

shadcn-ui의 진짜 매력은 "Headless UI + 조합성(Composition)"에 있다.

이게 무슨 말인지, 그리고 왜 이게 현대 프론트엔드에서 중요한지 직접 코드와 함께 풀어본다.

Headless UI란?

기능(동작, 상태, 접근성)은 라이브러리(여기선 Radix UI)가 책임지고, 스타일은 개발자가 직접 입히는 구조. 즉, "동작"과 "스타일"을 완전히 분리한다.

이 덕분에 아래 이점들을 누릴 수 있다.

  • 접근성 걱정 없이
  • 내 디자인 시스템에 맞게
  • 원하는 대로 커스터마이징

Composition(조합성)

shadcn-ui는 컴포넌트 구조 자체가 조합성을 극대화하도록 설계되어 있다.

예시: Compound Components + Slot 패턴

Accordion, Dialog, Tabs 등은 모두 Compound Components 패턴을 쓴다.

<Accordion type="single">
  <AccordionItem value="item-1">
    <AccordionTrigger>열어볼래?</AccordionTrigger>
    <AccordionContent>이렇게 내용이 나온다.</AccordionContent>
  </AccordionItem>
</Accordion>

부모와 자식이 Context로 연결되어, 상태 / 동작을 공유하면서도 선언적으로 구조를 짤 수 있다.

또, Slot 패턴(asChild)을 써서 기존의 <Button>을 <Link> 등 다른 태그로 쉽게 래핑할 수 있다.

<Button asChild>
  <Link href="/profile">프로필로 이동</Link>
</Button>

이렇게 하면, <a> 태그에 버튼 스타일과 동작을 그대로 입힐 수 있다.

실제 내부 코드 (Slot 패턴)

import { Slot } from "@radix-ui/react-slot";

function Button({ asChild, ...props }) {
  const Comp = asChild ? Slot : "button";
  return <Comp {...props} />;
}

위와 같은 패턴으로 구현되어 있어서, asChild가 true면 Slot이 자식 엘리먼트에 props , className , ref 를 다 넘겨준다.

슬롯 패턴(Slot Pattern)과 asChild Props

왜 슬롯 패턴이 필요한가?

실무에서 UI를 만들다 보면, 이미 특정 기능을 가진 컴포넌트(예: Next.js의 <Link>, 직접 만든 커스텀 버튼 등)에 추가적인 스타일이나 동작을 입히고 싶을 때가 정말 많다.

예를 들어, shadcn-ui의 <Button> 스타일을 Next.js의 <Link>에 입히고 싶은데, 그냥 <Button><Link>...</Link></Button>처럼 중첩하면 DOM 구조가 꼬이고(버튼 안에 a태그), 시맨틱 오류까지 발생한다.

이럴 때 shadcn-ui가 제공하는 슬롯 패턴(asChild + Slot 컴포넌트) 를 사용할 수 있다.

Slot 컴포넌트는 "부모가 가진 props(스타일, 동작, 이벤트 등)"를, 자신이 감싸고 있는 단 하나의 자식 컴포넌트에게 그대로 물려준다.

즉, shadcn-ui 컴포넌트에서 asChild prop을 true로 주면, 원래 렌더링하던 엘리먼트 (예: <button>) 대신 Slot을 렌더링하고, Slot은 부모의 모든 props / className / ref 등을 자식에게 병합해서 전달한다.

실제 사용 예시

1. 일반적인 사용 (문제점)

<Button>
  <Link href="/profile">Profile</Link>
</Button>
  • <button> 안에 <a>가 들어가서 시맨틱 오류 발생

2. 슬롯 패턴(asChild) 적용

<Button asChild>
  <Link href="/profile">Profile</Link>
</Button>
  • <Button>이 <button>을 렌더링하지 않고, 자신의 props / className 을 <Link>에게 그대로 넘긴다.
  • 결과적으로 <a> 태그가 버튼 스타일과 동작을 모두 가지게 된다.

내부 동작 원리 – Slot이 하는 일

  1. 자식 식별

    Slot은 자신이 감싸고 있는 자식이 "단 하나"인지 확인한다.

    여러 개면 에러, 없으면 null 반환.

  2. props 수집 및 병합
    • 부모 props(slotProps): className, 이벤트 핸들러, 데이터 속성 등
    • 자식 props(childProps): 자식이 원래 가지고 있던 props(예: style, 기존 이벤트 등)
    • 병합 로직:
      • className: 부모+자식 합치기 (중복/충돌은 Tailwind 기준으로 병합)
      • style: 객체 병합(겹치면 자식 우선)
      • 이벤트 핸들러: 둘 다 있으면 "둘 다 실행" (보통 자식→부모 순서)
  3. ref 병합

    부모와 자식이 각각 ref 를 가질 수 있으니, composeRefs 유틸리티로 두 ref 를 하나로 합친다.

  4. 자식 복제 및 props 주입

    React.cloneElement를 사용해서, 원래 자식 컴포넌트에 병합된 props, ref를 주입한 "새로운 엘리먼트"를 만든다.

  5. 최종 렌더링

    실제 DOM에는 Slot이 등장하지 않고, 자식 컴포넌트(예: <Link><MyStyledButton>)가 직접 렌더링된다. 이 자식은 부모(예: <Button><AccordionTrigger>)의 스타일과 동작, 그리고 자신만의 고유한 기능을 모두 가지게 된다.

Slot 패턴 사용 결과

// AccordionTrigger 내부 (간략화)
function AccordionTrigger({ asChild, children, ...props }) {
  const Comp = asChild ? Slot : AccordionPrimitive.Trigger; // asChild가 true면 Slot 사용
  return (
    <Comp className="accordion-trigger-styles ..." {...props}>
      {children}
    </Comp>
  );
}

// 사용하는 쪽
<AccordionTrigger asChild>
  <MyStyledButton>더 보기</MyStyledButton>
</AccordionTrigger>

최종적으로 렌더링되는 것은 <MyStyledButton> 이 만드는 <button> 이지만, 이 버튼 AccordionTrigger 의 스타일과 MyStyledButton 의 스타일을 모두 가지며, AccordionPrimitive.Trigger 의 핵심 기능(클릭 시 Accordion 열고 닫기)도 수행하게 된다.

Radix UI의 웹 접근성: ARIA 속성과 role 자동 적용

Radix UI는 “접근성”을 컴포넌트 설계의 핵심 원칙 중 하나로 삼고 있다. 실제로 각 Primitive Component는 WAI-ARIA Authoring Practices의 가이드에 따라, role과 ARIA 속성을 자동으로 붙여주며, 개발자가 별도로 접근성 로직을 신경 쓰지 않아도 기본적인 웹 접근성이 보장된다.

Radix UI에서 자동으로 붙는 ARIA/role 속성 예시

Dialog (모달)

  • role="dialog": 이 요소가 대화상자임을 명시
  • aria-hidden="true": 모달 외부의 모든 DOM 요소에 동적으로 붙임
  • aria-labelledbyaria-describedby: 제목과 설명을 스크린리더가 읽을 수 있도록 연결

Tabs(탭)

  • role="tablist" (탭 리스트)
  • 각 탭 버튼에 role="tab"aria-selectedaria-controls
  • 각 탭 패널에 role="tabpanel"aria-labelledby

Switch(스위치)

  • role="switch"
  • aria-checked="true" 또는 "false"

Tab 실제 코드

<Tabs.Root>
  <Tabs.List aria-label="My Tabs">
    <Tabs.Trigger value="tab-1">Tab 1</Tabs.Trigger>
    <Tabs.Trigger value="tab-2">Tab 2</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="tab-1">내용1</Tabs.Content>
  <Tabs.Content value="tab-2">내용2</Tabs.Content>
</Tabs.Root>

DOM 예시

<div role="tablist" aria-label="My Tabs">
  <button role="tab" aria-selected="true" aria-controls="tabpanel-1" ...>Tab 1</button>
  <button role="tab" aria-selected="false" aria-controls="tabpanel-2" ...>Tab 2</button>
</div>
<div id="tabpanel-1" role="tabpanel" aria-labelledby="tab-1">내용1</div>
<div id="tabpanel-2" role="tabpanel" aria-labelledby="tab-2">내용2</div>

Compound Components Pattern

shadcn-ui에서 Accordion, Tabs, Dialog 같은 컴포넌트를 보면 컴파운드 컴포넌트 패턴이 기본값처럼 쓰인다.

이 패턴은 부모 컴포넌트가 상태와 로직을 관리하고, 자식 컴포넌트들은 컨텍스트(Context)를 통해 필요한 값이나 함수를 받아서 동작하는 구조다.

예를 들어 Accordion 을 쓸 때는 이런 식이다.

<Accordion>
  <AccordionItem>
    <AccordionTrigger>열기</AccordionTrigger>
    <AccordionContent>내용</AccordionContent>
  </AccordionItem>
</Accordion>

Accordion 이 전체 상태를 관리하고, Item, Trigger, Content 는 자기 역할만 신경 쓰면 된다. 이런 구조 덕분에 UI 구조가 한눈에 보이고, 필요한 부분만 골라서 조합하거나 커스텀하게 확장하기도 쉽다.실제로 shadcn-ui에서 복잡한 상태를 가진 컴포넌트 대부분이 이 패턴을 기반으로 동작한다.

Reference

shadcn ui 자세히 알아보기

shadcn/ui가 무엇인지와 동작 방식을 탐구하고 그 인기를 파헤칩니다.

https://pyjun01.github.io/v/shadcn-ui/
shadcn ui 자세히 알아보기

Build your Component Library - shadcn/ui

A set of beautifully-designed, accessible components and a code distribution platform. Works with your favorite frameworks. Open Source. Open Code.

https://ui.shadcn.com/
Build your Component Library - shadcn/ui

Radix-ui 요점 정리 🌟

💡 회사에서 디자인 시스템을 구축하는 업무를 맡게 되었다. 거기서 사용하는 오픈소스 라이브러리 중 하나가 radix-ui이다.

https://velog.io/@leehyewon0531/Radix-ui-%EC%9A%94%EC%A0%90-%EC%A0%95%EB%A6%AC
Radix-ui 요점 정리 🌟

github.dev

https://github.dev/radix-ui/primitives

WAI-ARIA Roles - ARIA | MDN

ARIA roles provide semantic meaning to content, allowing screen readers and other tools to present and support interaction with an object in a way that is consistent with user expectations of that type of object. ARIA roles can be used to describe elements that don't natively exist in HTML or exist but don't yet have full browser support.

https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles
WAI-ARIA Roles - ARIA | MDN

Accessible Rich Internet Applications (WAI-ARIA) 1.2

Accessibility of web content requires semantic information about widgets, structures, and behaviors, in order to allow assistive technologies to convey appropriate information to persons with disabilities. This specification provides an ontology of roles, states, and properties that define accessible user interface elements and can be used to improve the accessibility and interoperability of web content and applications. These semantics are designed to allow an author to properly convey user interface behaviors and structural information to assistive technologies in document-level markup. This version adds features new since WAI-ARIA 1.1 [wai-aria-1.1] to improve interoperability with assistive technologies to form a more consistent accessibility model for [HTML] and [SVG2]. This specification complements both [HTML] and [SVG2].

https://www.w3.org/TR/wai-aria-1.2/