9.0 KiB
9.0 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| frontend-shadcn-radix-patterns | shadcn/ui / Radix / Headless component patterns | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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
npx shadcn@latest init
npx shadcn@latest add button dialog dropdown-menu
→ Code 가 components/ui/ 에 추가. Yours 가 됨.
Generated button
// 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)
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
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
// 자기 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 의)
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)
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
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)
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)
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)
/* 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%;
}
// tailwind.config.js
{
theme: {
extend: {
colors: {
primary: 'hsl(var(--primary))',
background: 'hsl(var(--background))',
},
},
},
}
→ Light / dark 가 CSS var 만.
Dark mode
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) 친화
// shadcn primitives 가 'use client' 명시.
// Server component 가 import 가능 (자동 client).
Storybook
// 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
import { axe } from 'jest-axe';
test('button is accessible', async () => {
const { container } = render(<Button>Click</Button>);
expect(await axe(container)).toHaveNoViolations();
});
Custom variant
const buttonVariants = cva('...', {
variants: {
variant: {
// ... existing
success: 'bg-green-500 text-white hover:bg-green-600', // 추가
},
},
});
→ 기존 file 수정. Library upgrade 영향 X (own code).
Update strategy
# 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.