
Button 컴포넌트로 읽는 디자인 시스템 - 1
같은 <Button>인데 왜 어떤 건 50줄이고 어떤 건 400줄일까. 8개 라이브러리 소스를 까보면 보이는 것들.
<Button>은 디자인 시스템에서 가장 단순한 컴포넌트다. 클릭하면 뭔가 일어난다. 그게 전부다.
그런데 라이브러리마다 구현 분량이 다르다. shadcn/ui의 Button은 약 50줄이고, MUI의 Button은 Button.js + ButtonBase.js를 합치면 400줄이 넘는다. 8배 차이.
두 라이브러리는 애초에 다른 문제를 푼다. shadcn은 "이 버튼을 네 프로젝트에 복사해 가서 마음대로 고쳐라"를 풀고, MUI는 "이 버튼 하나로 전 세계 모든 디자인 요구사항을 맞춰라"를 푼다. 스코프가 다르니 코드량이 다를 수밖에 없다.
이 글은 그 "다른 문제"가 뭔지를 8개 라이브러리(Radix UI, React Aria, Base UI, shadcn/ui, MUI, Ant Design, Chakra UI v3, Mantine)의 실제 소스코드를 놓고 짚어본다. Ark UI는 Button을 제공하지 않아서 제외한다 — Ark 철학상 Button은 "headless로 만들 게 없는 너무 단순한 요소"라고 본다.
Headless ↔ Styled는 이분법이 아니다
#디자인 시스템을 고를 때 가장 흔한 질문은 이거다.
"Headless로 갈까, Styled로 갈까?"
Headless는 시각적 스타일을 전혀 제공하지 않고 동작·상태·접근성만 주는 쪽(Radix, React Aria, Base UI). Styled는 시각까지 포함해서 설치하면 바로 예쁘게 나오는 쪽(MUI, Ant Design). 그런데 이 구도로는 설명이 안 되는 라이브러리가 꽤 많다.
- shadcn/ui는 스타일이 있는데 코드를 복사해서 사용자가 소유한다. Headless인가 Styled인가?
- Mantine은 스타일이 있는데 unstyled prop 하나로 전부 벗겨낼 수 있다.
- Chakra UI v3는 Ark UI(headless)가 상태와 접근성을 잡아주고, 그 위에 Recipe(styled) 레이어가 시각적 스타일을 입히는 이중 구조다.
스타일 제공 정도를 축으로 8개를 한 번에 놓으면 이렇게 된다.
완전 Headless 완전 Styled
──────────────────────────────────────────────────────────────────────
Radix Base UI React Aria shadcn/ui Chakra v3 Mantine MUI Ant Design
──────────────────────────── ───────── ───────── ────── ──────────────
"스타일 없음" "복사형" "Hybrid" "(un+)styled" "풀패키지"하나씩 소스를 까보자.
Headless — 동작만 주고 껍데기는 알아서
#Radix UI — "Button은 <button>이다. 우리가 할 일은 없다"
#Radix는 가장 극단적인 headless다. Button 컴포넌트가 아예 존재하지 않는다. Primitive.button이라는 약 50줄짜리 래퍼가 전부다.
// packages/react/primitive/src/primitive.tsx
const Primitive = NODES.reduce((primitive, node) => {
const Slot = createSlot(`Primitive.${node}`);
const Node = React.forwardRef((props, forwardedRef) => {
const { asChild, ...primitiveProps } = props;
const Comp: any = asChild ? Slot : node;
return <Comp {...primitiveProps} ref={forwardedRef} />;
});
return { ...primitive, [node]: Node };
}, {} as Primitives);이 코드가 하는 일은 딱 두 가지다.
- asChild가 true면 Slot 컴포넌트로 렌더링 (자식 요소에 props 병합)
- 아니면 해당 HTML 태그(button, div, a 등)로 렌더링
이게 전부다. CSS도, variant도, color도, loading도, disabled 처리도 없다. Radix는 Button이 이미 브라우저에 있는 <button> 태그로 충분하다고 본다. 자기들이 해줄 가치가 있는 건 asChild 같은 합성 유틸리티뿐이라는 입장이다.
Base UI — "Explicit over Magic"
#Base UI는 Radix보다 한 발 더 나간 명시주의를 보여준다. Button 컴포넌트는 약 20줄이고, 로직은 useButton 훅에 있다.
// packages/react/src/button/Button.tsx
export const Button = React.forwardRef(function Button(componentProps, forwardedRef) {
const {
render, className, disabled = false, focusableWhenDisabled = false,
nativeButton = true, style, ...elementProps
} = componentProps;
const { getButtonProps, buttonRef } = useButton({
disabled,
focusableWhenDisabled,
native: nativeButton,
});
const state: ButtonState = { disabled };
return useRenderElement('button', componentProps, {
state,
ref: [forwardedRef, buttonRef],
props: [elementProps, getButtonProps],
});
});useButton 훅이 disabled, 키보드 처리, composite 위젯 감지, ARIA 속성을 전부 처리한다. Button 컴포넌트 자체는 훅의 결과를 렌더링할 뿐이다.
Base UI의 차별화 포인트는 nativeButton prop이다. 개발자가 "이 버튼은 <button> 태그인가, 아닌가"를 명시적으로 선언해야 한다.
- nativeButton: true → 네이티브 <button> 태그로 렌더링할 것을 선언. 브라우저 기본 키보드 동작(Space, Enter) 사용.
- nativeButton: false → <div role="button"> 같은 비-네이티브 요소로 렌더링할 것을 선언. 이 경우 Base UI가 키보드 이벤트를 직접 시뮬레이션.
이게 중요한 이유는, 두 경우에 필요한 처리가 완전히 다르기 때문이다. 네이티브 버튼은 브라우저가 알아서 Space/Enter를 처리하지만, div는 그 로직을 JS로 흉내 내야 한다. 그런데 라이브러리가 "자동 감지"를 시도하면 render prop으로 요소가 치환된 경우를 놓치거나, SSR 환경에서 문제가 생긴다. Base UI는 이 위험을 감수하지 않고 사용자에게 선언 책임을 넘긴다. nativeButton={true}인데 실제로 <div>를 렌더링하면 개발 모드에서 에러를 던진다.
React Aria — "모든 요소가 Button이 될 수 있다"
#React Aria는 접근성 완전 자동화의 극단이다. useButton 훅이 모든 HTML 요소 타입을 지원한다.
// packages/@react-aria/button/src/useButton.ts
export function useButton(props, ref) {
let { elementType = 'button', isDisabled, onPress, ... } = props;
let additionalProps;
if (elementType === 'button') {
additionalProps = { type, disabled: isDisabled, form, formAction, ... };
} else {
additionalProps = {
role: 'button',
href: elementType === 'a' && !isDisabled ? href : undefined,
'aria-disabled': isDisabled || undefined,
};
}
let { pressProps, isPressed } = usePress({ onPress, isDisabled, ref });
let { focusableProps } = useFocusable(props, ref);
return {
isPressed,
buttonProps: mergeProps(additionalProps, focusableProps, pressProps, ...),
};
}<button>, <a>, <div>, <span>, <input> — 어떤 요소든 Button처럼 동작하게 만든다. 마우스, 터치, 키보드 이벤트를 usePress 라는 통합 추상화로 묶고, ARIA 속성을 자동으로 부여한다.
usePress가 왜 필요하냐면, 모바일 터치·데스크톱 마우스·키보드는 이벤트 순서와 엣지케이스가 다 다르기 때문이다. onClick 하나로 합치면 "터치 후 드래그", "포커스 링 깜빡임", "더블 탭" 같은 디테일을 놓친다. React Aria는 이걸 전부 usePress 안에서 정규화해서, 위에서 보면 그냥 onPress 하나만 던지면 된다.
React Aria의 철학은 "Button이라는 추상 개념은 HTML <button> 태그에 국한되지 않는다"는 거다. 어떤 요소든 버튼 역할을 할 수 있다면 버튼이라고 본다. <div>든 <span>이든 버튼 역할을 해야 하면 ARIA·키보드·포커스를 전부 붙여주겠다는 거다.
Styled — 껍데기까지 다 챙겨주는 쪽
#MUI — "Button은 디자인 시스템의 일부다"
#MUI는 가장 풀 패키지다. Button.js는 약 400줄이고, ButtonBase.js가 또 있다. Ripple, Focus Visible 관리, 테마 통합, 아이콘 슬롯, 로딩 wrapper, polymorphism 타입, ownerState 패턴 — 모든 것이 들어 있다.
// packages/mui-material/src/Button/Button.js (요지)
const ButtonRoot = styled(ButtonBase, {
shouldForwardProp: (prop) => rootShouldForwardProp(prop) || prop === 'classes',
name: 'MuiButton',
slot: 'Root',
overridesResolver: (props, styles) => {
const { ownerState } = props;
return [
styles.root,
styles[ownerState.variant],
styles[`${ownerState.variant}${capitalize(ownerState.color)}`],
styles[`size${capitalize(ownerState.size)}`],
];
},
})(memoTheme(({ theme }) => ({
...theme.typography.button,
variants: [
{ props: { variant: 'contained' }, style: { /* ... */ } },
...Object.entries(theme.palette)
.filter(createSimplePaletteValueFilter())
.map(([color]) => ({
props: { color },
style: {
'--variant-containedColor': theme.palette[color].contrastText,
'--variant-containedBg': theme.palette[color].main,
},
})),
],
})));이 한 덩어리의 코드가 하는 일을 풀어보자.
- name: 'MuiButton' — 테마의 components.MuiButton 설정과 자동 연결. 사용자가 아래 값을 설정하면 자동 적용된다.
theme.components.MuiButton.styleOverrides.root - slot: 'Root' — MuiButton-root CSS 클래스를 자동 생성. 외부에서 이 클래스 셀렉터로 스타일링할 수 있다.
- shouldForwardProp — DOM에 전달하면 안 되는 props(variant, color 등)를 필터링. 이걸 안 하면 React가 "button 태그에 variant라는 속성은 없다"고 경고한다.
- overridesResolver — 테마에서 오버라이드할 스타일 키들을 결정. variant + color + size 조합별로 세밀한 오버라이드가 가능.
- CSS 변수 기반 color 시스템 — palette의 각 color마다 --variant-containedColor 같은 CSS 변수를 세팅. variant 스타일은 이 변수를 참조만 해서, variant × color 조합 폭발을 부분적으로 피한다. (이건 2편에서 Chakra의 colorPalette와 비교한다.)
사용자는 <Button variant="contained" color="primary"> 한 줄만 쓰면 되지만, 뒤에서는 수백 줄의 정교한 로직이 돌아간다. MUI는 이 모든 것을 "디자인 시스템 사용자가 당연히 받아야 할 기본기"라고 본다.
Ant Design — "엔터프라이즈 실용주의. 하위 호환이 최우선"
#Ant Design도 완전 styled이지만 MUI와는 다른 방향으로 복잡하다. 엔터프라이즈 앱 현장의 요구사항에 집착한다. Button 파일이 6개로 쪼개져 있다.
| 파일 | 역할 |
|---|---|
| button.tsx | 메인 컴포넌트 |
| buttonHelpers.tsx | 헬퍼 (중국어 2글자 공백 삽입 등) |
| DefaultLoadingIcon.tsx | 로딩 아이콘 애니메이션 |
| style/token.ts | Component Token 정의 |
| style/index.ts | CSS-in-JS 스타일 등록 |
| style/variant.ts | CSS Variable 기반 variant 시스템 |
흥미로운 디테일이 몇 개 있다.
- type + color + variant 이중 체계 — v5 이전의 type='primary' API를 버리지 않고, 내부에서 color='primary' variant='solid'로 매핑한다. 100% 하위 호환. 엔터프라이즈 앱은 업그레이드 비용이 크기 때문에 old API를 깨면 안 된다.
- loading={{ delay: 300 }} — 짧은 응답에는 로딩 아이콘을 숨기는 delay 지원. 다른 라이브러리에는 없는 flicker 방지 UX. (2편에서 자세히)
- 중국어 2글자 공백 자동 삽입 — "确定"(확인)을 "确 定"으로 자동 변환. Alibaba 내부 어드민 페이지에서 오래 축적된 요구사항이 그대로 라이브러리에 박혀 있다.
- ConfigProvider 깊은 연동 — color, variant, shape, loadingIcon까지 앱 전역에서 제어 가능.
Ant Design은 "멋있는 추상화"보다 필요하면 뭐든 넣는 실용주의다. 중국어 공백 삽입 로직이 라이브러리 코어에 들어가는 건, 그 요구사항이 매일 수천 명 개발자를 괴롭혔다는 증거다.
중간 지점 — 여기가 하이라이트
#양쪽 극단은 이미 충분히 연구된 영역이다. 요즘 가장 활발한 설계 실험은 중간에서 일어난다.
shadcn/ui — "Button은 내 거다"
#shadcn/ui는 기존의 "라이브러리" 개념을 깬다. npm 패키지가 아니다. Button을 쓰려면 CLI로 코드를 복사한다.
npx shadcn@latest add button이 명령어가 button.tsx 파일을 내 프로젝트의 components/ui/ 폴더에 복사한다. 그 다음부터 그 파일은 내 코드다. 자유롭게 수정할 수 있고, 수정해도 라이브러리 업데이트 때 덮어써지지 않는다.
// apps/v4/registry/new-york-v4/ui/button.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium ...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90 ...",
outline: "border bg-background shadow-xs ...",
},
size: { default: "h-9 px-4 py-2", sm: "h-8 ...", lg: "h-10 ..." },
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({ className, variant = "default", size = "default", asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}shadcn/ui는 내부적으로 Radix UI(headless) 위에 Tailwind CSS 스타일을 입힌다. 기반은 headless지만 스타일이 이미 포함되어 있으니까 완전 headless도 아니다. 그래서 shadcn은 스펙트럼의 고유 지점을 새로 만들었다고 보는 게 맞다. 이름을 붙인다면 "헤드리스 기반 + 복사 가능한 스타일 레이어".
이 모델의 장점은 명확하다.
- 자유도 극대화 — variant 추가, 스타일 수정, 로직 변경이 전부 가능. 라이브러리와 싸울 필요 없음.
- 의존성 최소화 — npm update로 깨질 일 없음. 내 코드.
- 학습 곡선 낮음 — 다 보이는 코드라 이해가 쉬움.
단점도 있다. 라이브러리가 개선돼도 내 코드는 그대로라 업데이트가 수동이고, 여러 프로젝트에서 쓰면 일관성 관리가 부담이다.
Mantine — "styled인데 언제든 headless로 나갈 수 있다"
#Mantine은 기본적으로 styled 라이브러리다. 예쁜 기본 스타일이 있고 variant 8종을 제공한다. 그런데 이런 prop이 있다.
<Button unstyled>스타일 없는 버튼</Button>unstyled prop 하나로 Mantine이 붙이는 CSS 클래스를 전부 제거할 수 있다. HTML 구조(<button> 안에 <span> inner, label)는 그대로 유지되지만, Mantine의 배경색·패딩·hover 효과·로딩 애니메이션·disabled 스타일이 전부 날아간다. 결과적으로 브라우저 기본 <button> 스타일만 남는다. 거기에 자기 CSS를 처음부터 입히는 거다.
<!-- unstyled={false} (기본) -->
<button class="mantine-Button-root mantine-Button-root-filled ...">
<span class="mantine-Button-inner">
<span class="mantine-Button-label">Click me</span>
</span>
</button>
<!-- unstyled={true} -->
<button>
<span>
<span>Click me</span>
</span>
</button>이게 왜 흥미로우냐면, 대부분의 Styled 라이브러리에서 "이 컴포넌트 하나만 완전히 다르게 디자인하고 싶다"는 꽤 어려운 일이기 때문이다. MUI에서는 sx prop, styled(), 테마 styleOverrides로 싸워야 하고, 결국 라이브러리 기본 스타일을 완전히 이기려면 !important를 쓰게 된다. Mantine은 이 상황을 일급으로 지원한다. 나머지 컴포넌트들은 그대로 styled이고, 이 버튼 하나만 unstyled로 빠질 수 있다.
더 나아가 Mantine은 HeadlessMantineProvider라는 앱 레벨 Provider도 제공한다. unstyled이 컴포넌트 하나에 적용되는 거라면, HeadlessMantineProvider는 앱 전체에서 Mantine 스타일을 한 번에 끄는 것이다.
// unstyled prop — 컴포넌트 단위
<MantineProvider>
<Button>스타일 있는 버튼</Button>
<Button unstyled>이것만 스타일 없음</Button>
</MantineProvider>
// HeadlessMantineProvider — 앱 전체
<HeadlessMantineProvider>
<Button>전부 스타일 없음</Button>
<Input>이것도 스타일 없음</Input>
</HeadlessMantineProvider>내부 구현을 보면 핵심은 headless: true 플래그 하나다. 이 플래그가 켜지면 모든 컴포넌트의 useStyles 훅에서 Mantine CSS 클래스를 안 붙인다. CSS 변수 주입도 안 한다. 결과적으로 Mantine의 상태 관리·접근성·컴포넌트 구조만 남고, 시각적 스타일은 완전히 사용자 몫이 된다.
styled 라이브러리가 headless 탈출구를 컴포넌트 단위와 앱 단위 두 레벨로 상시 열어두는 거다.
Chakra UI v3 — Ark UI + Recipe의 Hybrid
#Chakra UI v3는 가장 명확한 Hybrid 구조를 가진다. 그림으로 그리면 이렇다.
┌─────────────────────────────────┐
│ Chakra UI v3 Button │
│ ┌───────────────────────────┐ │
│ │ Recipe (styled layer) │ │ ← 스타일, variant, color
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ Ark UI (headless layer) │ │ ← 상태, 접근성, 키보드
│ └───────────────────────────┘ │
└─────────────────────────────────┘v2까지의 Chakra는 "Styled Components + Style Props + 자체 접근성 구현"이었다. v3에서는 세 프로젝트를 흡수·통합했다.
- Ark UI — headless 컴포넌트 라이브러리 (Dialog, Popover, Menu 등 복잡한 컴포넌트의 상태/접근성 담당)
- Panda CSS — 빌드타임 스타일링 시스템 (Recipe 패턴의 원형)
- Park UI — 원래 독립 프로젝트로, Ark UI + Panda CSS 조합 위에 미리 디자인된 컴포넌트를 얹은 것이었다. 2024년에 Chakra 조직에 합류하면서 디자인 토큰·팔레트가 Chakra v3에 흡수됐다.
Chakra v3는 이 세 프로젝트를 합쳐서 하나의 통합 라이브러리로 만든 거다. Park UI가 "Ark UI + Panda CSS 위에 예쁜 스타일을 입힌 데모"였다면, Chakra v3는 거기에 자체 Provider 시스템, 테마 커스터마이징 API, colorPalette 같은 시맨틱 토큰까지 올린 상위 호환이다. 결과적으로 밑바닥은 headless, 위는 styled인 명확한 계층 구조가 된다.
Button의 경우 Ark UI가 Button을 제공하지 않기 때문에(Button은 너무 단순해서 별도 headless가 불필요하다는 Ark UI 철학) Chakra Button은 직접 구현하지만, Recipe 시스템은 Panda CSS에서 가져왔다.
// packages/react/src/theme/recipes/button.ts
export const buttonRecipe = defineRecipe({
className: "chakra-button",
base: { display: "inline-flex", alignItems: "center" /* ... */ },
variants: {
variant: {
solid: { bg: "colorPalette.solid", color: "colorPalette.contrast" },
subtle: { bg: "colorPalette.subtle", color: "colorPalette.fg" },
outline: { borderColor: "colorPalette.border", color: "colorPalette.fg" },
ghost: { bg: "transparent", color: "colorPalette.fg" },
},
size: { /* ... */ },
},
defaultVariants: { size: "md", variant: "solid" },
})Recipe 문법은 shadcn의 CVA와 매우 비슷하다. 사실 둘 다 Panda CSS의 영향을 받았다. 차이는 shadcn이 "코드 복사"라면 Chakra는 "라이브러리 패키지"라는 점이다.
Chakra v3의 특이점은 variant 정의에 "blue.500" 같은 구체적 색상이 없고 "colorPalette.solid" 같은 네임스페이스만 있다는 점이다. 사용자가 colorPalette="blue"를 넘기면 런타임에 blue.solid로 매핑된다. 이 "역할 기반 토큰"이 variant × color 폭발 문제를 가장 우아하게 푸는 해답인데, 이건 2편에서 자세히 이야기 해본다.
스펙트럼이 말해주는 세 가지 흐름
#8개를 나란히 놓고 보면 세 가지 흐름이 드러난다.
1. 중간으로 수렴 중
#완전 headless는 자유롭지만 진입 비용이 높다. 완전 styled는 빠르지만 커스터마이징 싸움이 있다. 요즘 새로 나오는 라이브러리들은 둘 다 포기하지 않으려 한다.
- shadcn/ui는 "복사"라는 방식으로 headless의 자유와 styled의 편리함을 동시에 가진다.
- Mantine은 styled지만 unstyled prop으로 headless 모드를 열어둔다.
- Chakra v3는 headless 기반 위에 styled 레이어를 명시적으로 계층 분리한다.
앞에서 본 "Headless냐 Styled냐" 구도가 맞지 않는 이유가 여기 있다. 2024~2026의 라이브러리들은 스펙트럼 중간을 노린다.
2. "Headless 기반 + 스타일 레이어" 모델이 표준화 중
#특히 주목할 흐름은 이거다.
Radix (headless) ───→ shadcn/ui (+ Tailwind 스타일)
Ark UI (headless) ──→ Chakra v3 (+ Recipe 스타일)유명 headless 라이브러리 위에 스타일 레이어를 올리는 조합이 표준 패턴이 되고 있다. shadcn이 먼저 했고, Chakra v3가 따라갔다. 이 조합이 인기인 이유는, Dialog나 Select처럼 키보드 내비게이션·포커스 트랩·ARIA 속성이 복잡한 컴포넌트를 직접 구현하지 않아도 되기 때문이다. 그 부분은 Radix나 Ark UI 같은 검증된 headless 라이브러리에 맡기고, 스타일만 자기 색깔로 입히면 된다. Button 같은 단순한 컴포넌트에서는 이 구조의 장점이 잘 안 보이지만, 복잡한 컴포넌트로 갈수록 차이가 커진다.
3. 스타일링 엔진이 전부 바뀌는 중
#스펙트럼 오른쪽(styled) 라이브러리들은 거의 다 스타일링 엔진을 갈아치우는 중이다. 이건 단순한 트렌드가 아니라, React Server Components가 불러온 구조적 변화다. 바로 다음 섹션에서 본다.
모두가 CSS-in-JS를 떠나고 있다
#2019~2022년은 Emotion / styled-components의 전성기였다. 컴포넌트 옆에 바로 CSS를 쓸 수 있다는 건 혁명이었다. 그런데 React Server Components가 등장하고, Next.js App Router가 주류가 되면서 문제가 터졌다.
런타임 CSS-in-JS는 클라이언트 번들에서 CSS를 생성하기 때문에, 서버에서 렌더링하는 RSC와 근본적으로 맞지 않는다.
거기에 더해 클라이언트 번들 사이즈, hydration 비용, 스타일 계산 오버헤드 같은 오랜 문제들이 누적되어 있었다. 그래서 2024~2025년 사이, 거의 모든 주요 라이브러리가 스타일링 엔진을 갈아치웠다.
| 라이브러리 | Before | After | 방식 |
|---|---|---|---|
| Chakra UI | Emotion (v2) | Panda CSS (v3) | 빌드 타임 + atomic CSS |
| MUI | Emotion (sx prop) | Pigment CSS (실험) | 빌드 타임 zero-runtime |
| Mantine | Emotion (v6) | CSS Modules (v7) | 완전 static CSS |
| Ant Design | 자체 CSS-in-JS | 유지하되 cssVar: true 옵션 | CSS variable hybrid |
| shadcn/ui | — | Tailwind v4 | 처음부터 runtime 없음 |
| Radix UI | — | unstyled | 처음부터 CSS를 안 내보냄 |
방향은 다 비슷하다. "컴포넌트 선언과 동시에 스타일을 만드는" 런타임 비용을 없애고, 빌드 타임에 static CSS를 뽑아낸다. 다만 구현 디테일은 라이브러리마다 다르고, 그 디테일에 철학이 드러난다.
Mantine — :where()로 specificity 0 만들기
#Mantine v7의 CSS Modules 접근은 CSS specificity 문제를 우아하게 푼다. 예를 들어 라이브러리가 .mantine-Button-root { padding: 8px } 같은 스타일을 specificity 1로 내보내면, 사용자가 className="my-btn"으로 .my-btn { padding: 16px }을 적용해도 specificity가 같아서 선언 순서에 따라 라이브러리 스타일이 이길 수 있다. 결국 !important를 붙이거나 더 구체적인 셀렉터를 써야 하는데, 이게 쌍이면 CSS가 금방 지저분해진다. Mantine은 :where() 의사 클래스를 이용해 모든 기본 스타일의 specificity를 0으로 만든다.
/* packages/@mantine/core/src/components/Button/Button.module.css */
.root {
/* specificity 1 */
}
.root:where([data-loading]) {
/* :where() 안은 specificity 0이라 사용자 className이 무조건 이긴다 */
cursor: not-allowed;
}:where()는 안에 들어있는 selector의 specificity를 0으로 만드는 특수 함수다. :is()와 매칭 동작은 같은데 specificity 계산만 다르다.
결과적으로 사용자가 <Button className="my-btn">만 던져도 library 스타일을 덮을 수 있다.
Chakra v3 — Panda CSS
#Chakra v3는 Panda CSS로 갈아탔다. Panda는 빌드 타임에 JSX를 스캔해서 atomic CSS 클래스를 생성하는 zero-runtime 엔진이다. 앞에서 본 buttonRecipe의 colorPalette.solid 같은 토큰이 빌드 타임에 실제 CSS 클래스로 변환되는 구조다. 런타임 비용이 0이면서도 Chakra v2 시절의 Style Props 편의성을 유지한다.
MUI는 RSC 대응을 위해 빌드 타임 CSS 추출기인 Pigment CSS를 병행 도입 중이지만, 기존 Emotion API와 호환을 유지해야 해서 전환이 점진적이다.
Ant Design은 자체 CSS-in-JS 엔진을 버리지 않는 대신 cssVar: true 옵션으로 테마 토큰만 CSS variable로 뽑아내는 하이브리드 방식을 택했다. 둘 다 한 번에 갈아엎기 어려운 사정이 있고, 그 사정 자체가 각 라이브러리의 사용자 규모와 하위 호환 부담을 보여준다.
결국 스타일링 전략 선택은 기술적 선택이 아니라 "우리 사용자가 스타일을 얼마나 건드리고 싶어 하는가"에 대한 예측이다. 같은 zero-runtime 트렌드를 따라가면서도 끝에 나오는 해답이 전부 다른 이유다.
다시, 50줄과 400줄 사이
#이제 처음 질문으로 돌아가자. shadcn의 Button은 왜 50줄이고, MUI의 Button은 왜 400줄일까?
shadcn이 50줄인 이유
- 밑에 Radix <Slot>(headless)이 깔려 있다. 접근성·포커스·키보드는 Radix가 처리한다.
- 스타일은 Tailwind class이므로 CSS-in-JS 엔진이 필요 없다. cva()로 variant만 선언하면 끝.
- 파일이 사용자 repo에 복사되기 때문에 "모든 edge case를 미리 커버할 필요가 없다". 필요하면 그때그때 사용자가 고친다.
MUI가 400줄인 이유
- Material Design Spec의 모든 variant(text, outlined, contained), 크기, color, disabled elevation, ripple 애니메이션을 한 파일에서 전부 처리한다.
- ButtonBase 안에 키보드 이벤트 처리(Enter keydown / Space keyup), Google Translate가 DOM을 건드렸을 때의 방어 코드(loading: boolean | null), focus ring 관리가 전부 들어 있다.
- OverrideProps 기반 polymorphism으로 component prop을 받아서 다른 요소로 변신해야 한다.
- 라이브러리 사용자는 코드를 수정하지 않는다. 업그레이드만 한다. 그래서 MUI 팀이 모든 케이스를 미리 커버해야 한다.
즉 두 Button의 분량 차이는 "누가 edge case를 책임지는가"라는 설계 결정의 결과다. 사용자가 수정하는 구조(shadcn)에서는 짧아질 수 있다. 사용자가 수정하지 않는 구조(MUI)에서는 길어질 수밖에 없다. 코드량은 라이브러리의 포지셔닝을 숫자로 본 것일 뿐이다.
마무리
#1편은 라이브러리들이 어디에 서 있는지를 봤다. 2편에서는 그 안으로 들어가서, 같은 <Button variant="solid" color="blue" disabled loading> API를 8개 라이브러리가 내부적으로 얼마나 다르게 구현하는지 네 영역(Polymorphism / Variant × Color / 접근성 / Loading)으로 나눠서 본다.
Reference
#






