---
id: frontend-shadcn-radix-patterns
title: shadcn/ui / Radix / Headless component patterns
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [frontend, ui, vibe-coding]
tech_stack: { language: "TS / React", applicable_to: ["Frontend"] }
applied_in: []
aliases: [shadcn/ui, Radix, headless component, accessible component, Aria-Kit, Ark UI]
---

# shadcn/ui / Radix Patterns

> "Component library install" → "Component code copy". **Radix (a11y primitive) + Tailwind = shadcn/ui**. Customizable. Modern React 의 default.

## 📖 핵심 개념
- Headless: behavior + a11y, style 없음.
- shadcn: copy code (npm install X).
- Radix / Aria / Ark = primitive.
- Tailwind = style.

## 💻 코드 패턴

### shadcn/ui setup
```bash
npx shadcn@latest init
npx shadcn@latest add button dialog dropdown-menu
```

→ Code 가 `components/ui/` 에 추가. Yours 가 됨.

### Generated button
```tsx
// components/ui/button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium ...',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input bg-background',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

export const Button = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
>(({ className, variant, size, ...props }, ref) => (
  <button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />
));
```

### CVA (class-variance-authority)
```ts
import { cva } from 'class-variance-authority';

const styles = cva('base-class', {
  variants: {
    color: { red: 'bg-red-500', blue: 'bg-blue-500' },
    size: { sm: 'h-8', md: 'h-10', lg: 'h-12' },
  },
  compoundVariants: [
    { color: 'red', size: 'lg', class: 'shadow-lg' },
  ],
  defaultVariants: { color: 'blue', size: 'md' },
});

styles({ color: 'red' });
// 'base-class bg-red-500 h-10'
```

### Radix Dialog
```tsx
import * as Dialog from '@radix-ui/react-dialog';

<Dialog.Root>
  <Dialog.Trigger asChild>
    <button>Open</button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="fixed inset-0 bg-black/50" />
    <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded p-6">
      <Dialog.Title>Title</Dialog.Title>
      <Dialog.Description>Description</Dialog.Description>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>
```

→ A11y (focus trap, escape, aria) 자동.

### asChild pattern
```tsx
// 자기 component 사용
<Dialog.Trigger asChild>
  <Button variant="outline">Open</Button>
</Dialog.Trigger>

// → Trigger 가 button render X. 자식 의 button 에 props 머지.
```

→ Style + a11y 공유 — wrapper 없음.

### Radix primitives list
```
- Accordion / Alert Dialog / Avatar / Checkbox
- Collapsible / Context Menu / Dialog / Dropdown Menu
- Hover Card / Label / Menubar / Navigation Menu
- Popover / Progress / Radio Group / Scroll Area
- Select / Separator / Slider / Switch / Tabs
- Toast / Toggle / Tooltip
```

→ A11y + keyboard 자동.

### Headless UI (Tailwind 의)
```tsx
import { Menu } from '@headlessui/react';

<Menu>
  <Menu.Button>Options</Menu.Button>
  <Menu.Items>
    <Menu.Item>{({ active }) => <a className={active ? 'bg-blue-500' : ''}>Profile</a>}</Menu.Item>
    <Menu.Item>...</Menu.Item>
  </Menu.Items>
</Menu>
```

### Ark UI (modern, multi-framework)
```tsx
import { Dialog } from '@ark-ui/react';

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Backdrop />
  <Dialog.Positioner>
    <Dialog.Content>...</Dialog.Content>
  </Dialog.Positioner>
</Dialog.Root>
```

→ React + Vue + Solid + Svelte 같은 API.

### React Aria
```tsx
import { useButton } from '@react-aria/button';

function Button(props) {
  const ref = useRef();
  const { buttonProps } = useButton(props, ref);
  return <button {...buttonProps} ref={ref}>{props.children}</button>;
}
```

→ Adobe 의 a11y primitive.

### Tailwind Variants (alternative)
```ts
import { tv } from 'tailwind-variants';

const button = tv({
  base: 'rounded px-4 py-2',
  variants: {
    color: { red: 'bg-red-500', blue: 'bg-blue-500' },
  },
});

<button className={button({ color: 'red' })} />
```

→ CVA 와 비슷.

### Form (react-hook-form + Zod + shadcn)
```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({ email: z.string().email(), password: z.string().min(8) });
type FormData = z.infer<typeof schema>;

function LoginForm() {
  const form = useForm<FormData>({ resolver: zodResolver(schema) });
  
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl><Input {...field} /></FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}
```

→ shadcn 의 Form / FormField 가 wrapper.

### Theme (CSS variables)
```css
/* globals.css */
:root {
  --primary: 222 47% 11%;
  --primary-foreground: 210 40% 98%;
  --background: 0 0% 100%;
  --foreground: 222 47% 11%;
}

.dark {
  --primary: 210 40% 98%;
  --primary-foreground: 222 47% 11%;
  --background: 222 47% 11%;
  --foreground: 210 40% 98%;
}
```

```js
// tailwind.config.js
{
  theme: {
    extend: {
      colors: {
        primary: 'hsl(var(--primary))',
        background: 'hsl(var(--background))',
      },
    },
  },
}
```

→ Light / dark 가 CSS var 만.

### Dark mode
```tsx
import { ThemeProvider, useTheme } from 'next-themes';

<ThemeProvider attribute="class">
  <App />
</ThemeProvider>

function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return <Button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>Toggle</Button>;
}
```

### MUI / Chakra / Mantine 비교
```
MUI:
- Material Design
- 큰 ecosystem
- Bundle 큰
- 변경 어려움 (theme 만)

Chakra:
- Style props (sx={{...}})
- 작은 bundle
- 합리

Mantine:
- 깊은 component
- 좋은 hook
- TypeScript 친화

shadcn / Radix:
- Code own
- 변경 자유
- 작은 bundle
- 모던 default

→ Modern React = shadcn 가 default.
```

### Bundle size
```
shadcn (Radix + Tailwind): only what 사용.
MUI: full library (3+ MB).

→ shadcn 가 더 작음.
```

### Server component (RSC) 친화
```tsx
// shadcn primitives 가 'use client' 명시.
// Server component 가 import 가능 (자동 client).
```

### Storybook
```tsx
// Button.stories.tsx
export default { component: Button };
export const Primary = { args: { variant: 'default' } };
export const Destructive = { args: { variant: 'destructive' } };
```

→ shadcn 의 component 가 component, storybook 친화.

### A11y testing
```ts
import { axe } from 'jest-axe';

test('button is accessible', async () => {
  const { container } = render(<Button>Click</Button>);
  expect(await axe(container)).toHaveNoViolations();
});
```

### Custom variant
```tsx
const buttonVariants = cva('...', {
  variants: {
    variant: {
      // ... existing
      success: 'bg-green-500 text-white hover:bg-green-600',  // 추가
    },
  },
});
```

→ 기존 file 수정. Library upgrade 영향 X (own code).

### Update strategy
```bash
# shadcn 의 새 version
npx shadcn@latest diff button
# → 변경점 보임

# Manual merge
```

→ Library upgrade 가 자동 X — but freedom.

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| Modern React | shadcn/ui + Radix |
| 빠른 prototype | shadcn (모든 거 즉시) |
| 깊이 customize | Radix only |
| 작은 bundle | Headless UI / Radix |
| Multi-framework | Ark UI |
| Material Design | MUI |
| 폼 | shadcn Form + RHF + Zod |
| Style props | Chakra / Mantine |

## ❌ 안티패턴
- **MUI + Tailwind**: 2 css system 충돌.
- **Radix 없이 dialog 작성**: a11y / focus / esc 깨짐.
- **shadcn 가 그대로 + customize 안**: own code 의 가치 X.
- **CSS variable 없이 dark mode**: class name 폭발.
- **A11y test 없음**: keyboard / screen reader 깨짐.
- **Bundle analyze 없음**: 큰 component lib 잠입.

## 🤖 LLM 활용 힌트
- shadcn/ui = "code 가 own".
- Radix = a11y primitive.
- CVA / Tailwind Variants 가 variant 패턴.
- React Aria / Headless UI / Ark UI = alternative.

## 🔗 관련 문서
- [[React_Headless_UI_Patterns]]
- [[Frontend_Tailwind_Architecture]]
- [[Frontend_Design_Tokens]]
