
Button 컴포넌트로 읽는 디자인 시스템 - 2
1편에서 라이브러리들의 포지셔닝을 봤다면, 2편은 그 포지셔닝이 실제 prop 설계에서 어떻게 다르게 나타나는지를 본다.
1편에서 얘기한 건 "라이브러리들이 어디 서 있는가"였다. 이번 글은 그 위치가 <Button>의 prop 하나하나에 어떻게 녹아 있는지를 본다.
소재는 네 개다. 전부 Button에서 흔히 쓰는, 겉보기엔 평범한 prop이다.
<Button
as={Link} // ← polymorphism
variant="solid" // ← variant × color 조합
color="blue"
disabled // ← 접근성 디테일
loading // ← 비동기 상태
>
저장
</Button>같은 prop인데 내부 구현은 라이브러리마다 완전히 다르다. 하나씩 살펴본다.
Polymorphism — <Button>이 <a>가 되어야 할 때
#Button 컴포넌트를 쓰다 보면 반드시 이 순간이 온다.
"이 버튼, 링크로도 써야 해."
시각적으로는 똑같은 버튼인데 실제 렌더링되는 DOM은 <a href=...>여야 하는 상황. 라우터 컴포넌트(<Link>)여야 할 수도 있다.
8개 라이브러리가 이 문제를 푸는 방법은 5가지로 갈린다.
방법 1 — Radix UI / shadcn/ui의 asChild
#Radix는 <Slot> 컴포넌트를 통해 asChild 패턴을 제공한다. shadcn/ui는 이걸 그대로 이어받는다.
// 사용자가 쓰는 코드
<Button asChild>
<Link href="/dashboard">대시보드</Link>
</Button>문법이 특이하다. 자식으로 실제 요소를 받고, 부모(Button)는 자신의 props와 ref를 그 자식에게 머지한다. 핵심 구현은 Radix Slot.tsx 안에 있다.
// packages/react/slot/src/Slot.tsx
const Slot = React.forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
const { children, ...slotProps } = props;
if (!React.isValidElement(children)) return null;
return React.cloneElement(children, {
...mergeProps(slotProps, children.props),
ref: forwardedRef
? composeRefs(forwardedRef, (children as any).ref)
: (children as any).ref,
});
});React.cloneElement는 JSX 요소를 복제하면서 props를 바꿔 끼울 수 있는 React API다. Radix Slot은 이걸 이용해 "부모에서 온 props + 자식 원본 props"를 합친 뒤, 자식 요소에 다시 주입한다.
여기서 포인트는 mergeProps다. 부모가 onClick을 주고 자식도 onClick을 갖고 있으면, 하나가 다른 하나를 덮어쓰면 안 된다. 두 개를 순서대로 실행해야 한다.
function mergeProps(slotProps, childProps) {
const overrideProps = { ...childProps };
for (const propName in childProps) {
const slotPropValue = slotProps[propName];
const childPropValue = childProps[propName];
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler) {
// 이벤트 핸들러는 두 개 다 호출
overrideProps[propName] = (...args) => {
childPropValue?.(...args);
slotPropValue?.(...args);
};
} else if (propName === 'style') {
overrideProps.style = { ...slotPropValue, ...childPropValue };
} else if (propName === 'className') {
overrideProps.className = [slotPropValue, childPropValue].filter(Boolean).join(' ');
}
}
return { ...slotProps, ...overrideProps };
}이벤트 핸들러는 합치고, style은 머지하고, className은 문자열을 이어 붙인다. 즉 prop의 종류에 따라 "합친다"는 행위 자체가 다르다.
이벤트는 순서대로 호출, 스타일은 객체 머지, 클래스명은 문자열 결합. 단순히 {...a, ...b}로 덮어쓰면 뒤에 오는 값이 앞을 지워버리기 때문에, 이런 타입별 머지 전략이 필요하다.
장점: JSX 구조가 직관적이다. 자식 요소를 그대로 쓰니까 href, to, target 같은 고유 prop이 TypeScript 타입으로 자연스럽게 들어온다.
단점: asChild가 뭔지 처음 보면 당황스럽다. "이게 왜 자식의 속성을 가로채지?" 처음엔 동작 방식을 이해하는 데 시간이 필요하다.
방법 2 — Base UI의 render prop
#Base UI는 같은 문제를 render prop으로 푼다.
// packages/react/src/button/Button.tsx
<Button
render={(props, state) => <a {...props} href="/dashboard" />}
>
대시보드
</Button>render는 함수다. Base UI는 이 함수를 호출하면서 "렌더링해야 할 props"와 "현재 상태(pressed, focused 등)"를 인자로 넘긴다. 사용자는 그걸 받아서 원하는 요소로 직접 렌더링한다.
props에는 뭐가 들어 있을까?
- 접근성 속성 (aria-disabled 등)
- 이벤트 핸들러 (onClick, onKeyDown, ...)
- 포커스 관리 관련 tabIndex
- data-pressed, data-disabled 같은 상태 속성
state에는 이런 게 들어 있다.
- pressed: boolean
- disabled: boolean
- focused: boolean
장점: 완전히 명시적이다. "어떤 props가 주입되는지" 함수 시그니처에 다 노출되기 때문에 숨은 게 없다. 또 state를 받아서 "pressed일 때만 아이콘 바꾸기" 같은 조건부 렌더링도 쉽다.
단점: asChild는 선언적으로 자식 요소를 넘기지만, render prop은 사용자가 렌더링 구조를 직접 작성해야 한다. 자유도는 높지만 그만큼 코드에서 신경 쓸 부분이 늘어난다.
방법 3 — MUI의 component prop (OverrideProps)
#MUI는 component prop으로 푼다. Radix의 asChild(2021~)나 Base UI의 render prop이 등장하기 전, React 커뮤니티에서 가장 오래 쓰인 방식이다.
<Button component={Link} to="/dashboard">대시보드</Button>component={Link}을 주면 MUI 내부적으로 이 값을 React.createElement(Link, ...)로 바꿔서 렌더링한다. 그런데 타입 시스템 쪽이 훨씬 복잡하다.
Link에는 to prop이 있고 <a>에는 href가 있는데, 이걸 MUI Button이 어떻게 타입 체크를 해줄까?
해답은 OverrideProps라는 헬퍼 타입이다.
// packages/mui-material/src/OverridableComponent.d.ts
export type OverrideProps<
TypeMap extends OverridableTypeMap,
RootComponent extends React.ElementType,
> = {
// component prop으로 넘긴 요소의 props 전부
} & Omit<React.ComponentPropsWithRef<RootComponent>, keyof TypeMap['props']>
& { component?: RootComponent };해석하면: "Button이 가진 고유 props 타입 + component로 넘긴 요소(예: Link)의 props 타입 - 겹치는 키"를 계산해서, 제네릭으로 결합된 타입을 내놓는다.
이게 있으니까 <Button component={Link} to="/x">에서 to가 타입 체크를 통과하고, <Button component="a" href="/x">에서 href가 통과한다.
하지만 대가는 TypeScript 추론 비용이다. 이 generic 체인이 깊어질수록 에디터 자동완성이 느려지고, 타입 체크 시간이 길어진다는 이슈가 MUI GitHub에서 꾸준히 보고되어 왔다.
장점: component 한 단어로 "이 요소로 렌더해줘"라는 의도가 바로 읽힌다.
단점: 타입 추론이 무겁다. 그리고 중첩 스타일링(예: component={Link}인데 Link에도 className 있을 때) 머지가 사용자 책임이다.
방법 4 — Mantine의 polymorphicFactory
#Mantine은 polymorphism을 모든 컴포넌트의 공통 기능으로 끌어올렸다. 전용 헬퍼인 polymorphicFactory가 있다.
// packages/@mantine/core/src/core/factory/polymorphic-factory.ts
export function polymorphicFactory<Payload extends PolymorphicFactoryPayload>(
ui: React.ForwardRefRenderFunction<...>,
) {
const Component = React.forwardRef(ui) as unknown as
<C = Payload['defaultComponent']>(
props: { component?: C } & PolymorphicComponentProps<C, Payload['props']>
) => React.ReactElement;
return Component;
}모든 Mantine 컴포넌트가 이 factory로 만들어진다. 즉 component prop이 라이브러리 전체에 걸쳐 일관된 API로 존재한다. Button에도 있고, Box에도 있고, Card에도 있다. 이 일관성이 Mantine을 쓰는 이유 중 하나다.
방법 5 — React Aria / Ant Design의 "자동 전환"
#React Aria Components는 hooks spread 방식이면서, 동시에 href를 주면 자동으로 <a>로 렌더된다. Ant Design도 비슷하다.
// components/button/button.tsx (Ant Design)
if (linkButtonRestProps.href !== undefined) {
buttonNode = (
<a {...linkButtonRestProps} className={classes} onClick={handleClick} ref={buttonRef}>
{iconNode}
{kids}
</a>
);
} else {
buttonNode = (
<button {...rest} type={htmlType} className={classes} onClick={handleClick} ref={buttonRef}>
...
</button>
);
}href가 있으면 <a>, 없으면 <button>. Prop 하나로 요소가 바뀐다. 사용법이 제일 쉽다.
그런데 라우터 컴포넌트(<Link>)는 이 방식으로 못 쓴다. href라는 네이티브 prop을 통한 전환일 뿐이라 "임의의 React 컴포넌트로 치환"은 불가능하다.
장점: 가장 단순하다. href 주면 링크, 안 주면 버튼.
단점: 범용성이 낮다. React Router <Link>나 Next.js <Link>로 치환하고 싶으면 불가능.
다섯 방식을 한 번에 보면
#| 방법 | 라이브러리 | 문법 | 범용성 |
|---|---|---|---|
| asChild • Slot | Radix, shadcn | Button asChild > Link | 매우 높음 |
| render prop | Base UI | Button render={fn} | 매우 높음 |
| component prop | MUI | Button component={Link} | 높음 |
| polymorphicFactory | Mantine | Button component={Link} | 높음 |
| href 자동 전환 | Ant Design, React Aria | Button href="/x" | 낮음 |
다섯 방식은 결국 "사용자에게 얼마나 많은 자유를 줄 것인가"의 스펙트럼이다. href 자동 전환은 범용성이 낮지만 단순하고, Slot은 범용성이 최대지만 개념이 생소하다. 라이브러리가 자기 사용자를 어떻게 가정하는지가 그대로 드러나는 지점이다.
Variant × Color 조합 폭발 — 32가지 경우의 수 관리법
#Button에서 두 번째로 피할 수 없는 문제는 variant × color 조합이다.
- variant 4개: solid, outline, ghost, subtle
- color 8개: blue, red, green, gray, yellow, purple, pink, cyan
총 32가지 조합. 각각이 배경색, 글자색, 테두리, hover, active 색을 다 가져야 한다. 라이브러리들은 이걸 어떻게 관리할까?
접근 1 — 전부 하드코딩 (MUI, Ant Design)
#MUI는 color를 "Material Design palette의 한 색"으로 제한한다(primary, secondary, error, warning, info, success). 이 limited set에 대해서만 각 variant×color 조합의 스타일을 직접 정의한다.
// packages/mui-material/src/Button/Button.js
...(ownerState.variant === 'contained' && ownerState.color === 'primary' && {
color: theme.palette.primary.contrastText,
backgroundColor: theme.palette.primary.main,
'&:hover': { backgroundColor: theme.palette.primary.dark },
}),
...(ownerState.variant === 'outlined' && ownerState.color === 'primary' && {
color: theme.palette.primary.main,
border: `1px solid ${theme.palette.primary.main}`,
'&:hover': { border: `1px solid ${theme.palette.primary.dark}` },
}),
// ... outlined + secondary, outlined + error, text + primary, ...조합 수만큼 분기가 있다. 확장성이 낮다. 새로운 color를 추가하려면 모든 variant × 새 color 쌍을 전부 직접 써줘야 한다. 그래서 MUI는 애초에 color 종류를 제한해서 폭발을 막는다.
접근 2 — Tailwind class 스위치 (shadcn/ui)
#shadcn은 CVA(Class Variance Authority)로 variant를 선언한다.
// apps/v4/registry/new-york-v4/ui/button.tsx
const buttonVariants = cva(
"inline-flex items-center justify-center ...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 px-8",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);여기서 재밌는 건 color가 variant 목록에 없다는 거다. shadcn은 color를 primary, destructive 같은 시맨틱 이름으로 Tailwind theme에 정의해두고, 거기서 다른 색을 쓰고 싶으면 사용자가 --primary CSS variable을 바꾸게 한다.
즉 variant × color 폭발을 해결한 게 아니라 "color variant를 아예 없애는" 방식으로 피해간다. 색 변경이 필요하면 CSS variable을 교체하는 방식으로 처리한다.
접근 3 — Chakra v3의 colorPalette (역할 토큰)
#Chakra v3의 접근이 구조적으로 흥미롭다. 핵심은 recipe에서 색을 쓸 때 실제 색 이름 대신 "역할"만 쓰는 것이다.
// packages/react/src/theme/recipes/button.ts
const buttonRecipe = defineRecipe({
className: 'chakra-button',
variants: {
variant: {
solid: {
bg: 'colorPalette.solid',
color: 'colorPalette.contrast',
_hover: { bg: 'colorPalette.solid/90' },
},
outline: {
color: 'colorPalette.fg',
borderColor: 'colorPalette.muted',
_hover: { bg: 'colorPalette.subtle' },
},
ghost: {
color: 'colorPalette.fg',
_hover: { bg: 'colorPalette.subtle' },
},
subtle: {
bg: 'colorPalette.subtle',
color: 'colorPalette.fg',
},
},
},
});여기 colorPalette.solid, colorPalette.contrast, colorPalette.fg, colorPalette.subtle는 색 이름이 아니다. "역할" 이름이다. 실제 색은 사용자가 컴포넌트에 colorPalette="blue"를 주는 순간 결정된다.
<Button colorPalette="blue" variant="solid">저장</Button>
<Button colorPalette="red" variant="outline">삭제</Button>내부 동작은 이렇다. Chakra는 CSS variable을 네임스페이스로 사용한다.
[data-color-palette="blue"] {
--colors-color-palette-solid: var(--colors-blue-500);
--colors-color-palette-contrast: var(--colors-white);
--colors-color-palette-fg: var(--colors-blue-700);
--colors-color-palette-subtle: var(--colors-blue-100);
--colors-color-palette-muted: var(--colors-blue-300);
}colorPalette="blue"라는 prop은 사실상 "CSS variable 스코프를 스위칭하는" 역할만 한다. 그 아래의 recipe는 그냥 var(--colors-color-palette-solid)를 읽을 뿐이다.
이 방식에서는 variant × color 폭발이 일어나지 않는다. variant 정의는 4개뿐이고, color는 CSS variable 한 번 스위칭으로 처리된다. 4개 + 색 개수이지, 곱하기가 아니다.
MUI가 contained + primary, contained + secondary, outlined + primary... 조합마다 스타일을 쓰는 동안, Chakra는 4개의 variant 정의만 유지하면 된다. 색 하나를 추가할 때도 CSS variable 네임스페이스 하나만 추가하면 된다.
접근 4 — Mantine의 Button.Group + CSS variable
#Mantine도 CSS variable 기반이지만 Chakra처럼 "역할" 개념은 없다. 대신 Mantine은 --mantine-color-{color}-filled처럼 색 이름 자체가 variable에 들어간다.
.root {
--button-bg: var(--mantine-color-{color}-filled);
--button-hover: var(--mantine-color-{color}-filled-hover);
}Chakra와 Mantine의 차이: Chakra는 한 번 더 추상화해서 "역할 토큰"을 끼워 넣었고, Mantine은 색 이름을 직접 쓴다. Chakra 쪽이 더 유연하지만 진입장벽이 살짝 있다.
네 접근을 한 표로
#| 방식 | 라이브러리 | 조합 관리 | 새 색 추가 비용 |
|---|---|---|---|
| 하드코딩 | MUI, Ant Design | variant × color 분기 | 모든 variant에 스타일 추가 |
| 시맨틱 class | shadcn/ui | color 개념 없음, CSS var만 교체 | 테마 variable 교체 |
| colorPalette (역할 토큰) | Chakra v3 | variant 정의만 관리 | palette namespace 하나 추가 |
| 색 이름 variable | Mantine | variant 정의만 관리 | palette namespace 하나 추가 |
variant 시스템 설계는 "미래에 색이 몇 개나 될지"에 대한 예측과 직결된다.
MUI는 Material palette 밖으로 나가지 않을 거라고 가정하기 때문에 하드코딩이 괜찮다.
Chakra는 사용자가 자기 브랜드 컬러를 통째로 집어넣는 상황을 가정하기 때문에 역할 토큰이 필요하다. 라이브러리의 가정이 바뀌면 아키텍처도 같이 바뀐다.
접근성 디테일 — disabled 버튼이 포커스를 받아야 하는 이유
#"접근성"이라고 하면 보통 aria-label 정도를 떠올리는데, Button의 접근성 디테일은 훨씬 깊다. Base UI와 MUI의 ButtonBase 코드를 열어보면 이 두 가지가 눈에 띈다.
디테일 1 — Composite widget 안의 disabled 버튼
#문제부터. 다음 상황을 상상해보자.
툴바 안에 버튼 5개가 있다. 그중 하나가 disabled다. 사용자가 키보드 화살표로 툴바 안을 이동하는데, disabled 버튼에서 건너뛰게 해야 할까, 아니면 멈춰야 할까?
정답: 멈춰야 한다. WAI-ARIA Authoring Practices에 따르면, toolbar, menubar 같은 composite widget 안에서는 disabled 요소도 focus를 받아야 한다.
이유는 "disabled라는 사실"을 스크린 리더 사용자가 알아야 하기 때문이다. 건너뛰면 그 버튼의 존재 자체가 감춰진다.
그런데 HTML의 <button disabled>는 기본적으로 포커스를 못 받는다. 이걸 해결하려면 disabled 속성을 빼고, 대신 aria-disabled="true"를 쓰고, 클릭 이벤트를 JavaScript 단에서 차단해야 한다. Base UI의 useFocusableWhenDisabled가 정확히 이걸 한다.
// packages/react/src/utils/useFocusableWhenDisabled.ts
export function useFocusableWhenDisabled({
composite,
disabled,
focusableWhenDisabled,
isNativeButton,
tabIndex = 0,
}) {
const mergedFocusableWhenDisabled = composite || focusableWhenDisabled;
const buttonProps = isNativeButton
? {
type: 'button' as const,
disabled: mergedFocusableWhenDisabled ? undefined : disabled,
'aria-disabled': mergedFocusableWhenDisabled && disabled ? true : undefined,
}
: {
'aria-disabled': disabled ? true : undefined,
};
return {
...buttonProps,
tabIndex: mergedFocusableWhenDisabled || !disabled ? tabIndex : -1,
};
}파라미터를 하나씩 풀어보자.
- composite: 이 버튼이 toolbar/menubar 같은 composite widget 안에 있는지. true면 "focusable when disabled" 모드가 강제 활성화된다.
- disabled: 버튼이 disabled인지.
- focusableWhenDisabled: composite가 아니더라도, 사용자가 명시적으로 "disabled인데 포커스는 받게" 하고 싶을 때의 opt-in flag.
- isNativeButton: 렌더되는 요소가 실제 <button>인지 (아니면 <div role="button"> 같은 거).
- tabIndex: 기본 tabIndex. 기본값 0.
리턴값의 로직:
- isNativeButton이고 "focusable when disabled"면: disabled 속성을 빼고 aria-disabled를 붙인다. 이래야 <button>이 포커스를 받을 수 있다.
- isNativeButton이 아니면(div 등): 원래 disabled 속성이 없으니 aria-disabled만 붙이면 된다.
- tabIndex: disabled고 "focusable when disabled"도 아니면 -1로 꺼버린다.
요약: 네이티브 <button disabled>는 "기본 포커스 제외" 동작이 하드코딩되어 있으므로, composite widget에서는 아예 disabled 속성 자체를 쓸 수 없다. 대신 aria-disabled로 우회한다. 이렇게 해야 스크린 리더 사용자에게 disabled 버튼의 존재를 알릴 수 있다.
디테일 2 — MUI ButtonBase의 Space vs Enter 분리
#또 하나의 숨은 디테일. Space 키와 Enter 키는 버튼을 다른 타이밍에 누른다.
- Enter: keydown에 즉시 클릭 발생
- Space: keydown에는 preventDefault만 하고, 실제 클릭은 keyup에서 발생
브라우저에서 Space 키의 기본 동작은 페이지를 아래로 스크롤하는 것이다(Page Down과 동일).
네이티브 <button>은 브라우저가 이걸 가로채서 스크롤 대신 버튼 클릭을 해주지만, <div role="button"> 같은 커스텀 요소에서는 그런 처리가 없다.
그래서 keydown에서 스크롤을 막고, keyup에서 클릭을 발생시켜야 네이티브 버튼과 동일한 동작이 된다.
MUI의 ButtonBase가 바로 이걸 한다.
// packages/mui-material/src/ButtonBase/ButtonBase.js
const handleKeyDown = useEventCallback((event) => {
// If must use keydown event to fire the click
if (
!event.repeat &&
(event.target === event.currentTarget) &&
event.key === ' '
) {
event.preventDefault();
}
if (
event.target === event.currentTarget &&
isNonNativeButton() &&
event.key === 'Enter' &&
!disabled
) {
event.preventDefault();
if (onClick) onClick(event);
}
});
const handleKeyUp = useEventCallback((event) => {
if (
event.target === event.currentTarget &&
isNonNativeButton() &&
!disabled &&
event.key === ' '
) {
if (onClick) onClick(event);
}
});- handleKeyDown: Enter면 즉시 onClick 호출. Space면 기본 스크롤 동작만 막는다.
- handleKeyUp: Space면 여기서 onClick 호출.
위 동작이 native button이 아닌 경우(<div>나 <a> 등에 Button을 씌운 경우)에만 적용되는 이유는, 네이티브 <button>은 브라우저가 이미 이 동작을 해주기 때문이다. isNonNativeButton()이 그걸 체크한다.
요약: Space/Enter 키 동작은 브라우저 네이티브 버튼에만 들어 있고, 커스텀 버튼을 만들면 이걸 직접 구현해야 접근성이 유지된다.
MUI는 ButtonBase 안에 이 로직을 내장해서, component prop으로 div로 치환해도 동작이 유지되도록 했다.
접근성은 "aria-label 붙이면 되는 거" 수준이 아니다. Button 같은 단순 컴포넌트에도 WAI-ARIA Authoring Practices를 한 문장 한 문장 구현해야 하는 디테일이 숨어 있다.
Base UI의 useFocusableWhenDisabled와 MUI의 keydown/keyup 분리는 둘 다 이 표준을 충실하게 구현한 결과이고, 라이브러리가 해주지 않으면 사용자가 직접 짜야 한다. 1편에서 말한 MUI Button이 400줄인 이유의 절반이 여기 있다.
Loading 상태 — boolean 하나에 숨은 다섯 개의 설계
#<Button loading>저장</Button>단순한 설계. 스피너가 돌고 클릭이 막힌다. 구현은 if (loading) return <Spinner /> 한 줄이면 될 것 같지만, 실제로는 그렇지 않다. 5개 라이브러리가 이 prop을 전부 다르게 구현한다.
방식 1 — shadcn/ui: 기본 미지원, 조합형
#shadcn Button에는 loading prop이 없다. 사용자가 필요하면 직접 합성한다.
<Button disabled={isLoading}>
{isLoading && <Loader2 className="animate-spin" />}
저장
</Button>설계 철학: "Button은 단순해야 한다. Loading은 상위 레이어가 책임진다."
shadcn은 코드를 사용자가 소유하므로, 필요하면 사용자가 파일을 열어서 loading prop을 직접 추가하면 된다. 라이브러리가 먼저 결정해줄 필요가 없다.
방식 2 — MUI: loading: boolean | null
#MUI는 loading prop을 제공한다. 그런데 타입이 특이하다.
// packages/mui-material/src/Button/Button.d.ts
loading?: boolean | null;null이 있다. 왜?
Google Translate 같은 번역 확장이 DOM을 건드리면 React의 reconciliation이 깨지는 경우가 있다.
실제 구현에서는 loading이 true든 false든 값이 주어지면 로딩 래퍼를 항상 DOM에 유지하고, null(기본값)일 때만 래퍼를 렌더링하지 않는다. 이렇게 하면 래퍼가 조건부로 나타났다 사라지면서 확장 프로그램이 건드린 DOM과 충돌하는 상황을 피할 수 있다.
방식 3 — Ant Design: loading: boolean | { delay: number }
#Ant Design의 loading은 객체도 받는다.
<Button loading={{ delay: 300 }}>저장</Button>delay가 있는 이유는 flicker 방지다. API 응답이 100ms 안에 오는 경우가 많은데, 그때마다 스피너가 반짝 나타났다 사라지면 사용자 경험이 나빠진다. delay: 300을 주면 "300ms 동안은 스피너를 숨기고, 그 이상 걸릴 때만 보여준다".
구현은 useLayoutEffect로 타이머를 건다.
// components/button/button.tsx (Ant Design)
const [innerLoading, setLoading] = React.useState<boolean>(
typeof loading === 'object' ? loading.delay > 0 ? false : true : !!loading
);
React.useLayoutEffect(() => {
let delayTimer: ReturnType<typeof setTimeout> | null = null;
if (typeof loading === 'object' && loading.delay > 0) {
delayTimer = setTimeout(() => {
delayTimer = null;
setLoading(true);
}, loading.delay);
} else {
setLoading(!!loading);
}
return () => {
if (delayTimer) clearTimeout(delayTimer);
};
}, [loading]);- loading이 { delay: 300 }: setTimeout으로 300ms 후에 loading 켜기. 그 전에 loading이 false로 바뀌면 clearTimeout으로 취소.
- loading이 true: 즉시 켬.
- loading이 false: 즉시 끔.
한 줄 요약: "짧은 로딩은 숨겨라"는 UX 디테일을, 사용자가 직접 타이머를 관리하지 않아도 되도록 prop 레벨에서 해결하고 있다.
방식 4 — React Aria Components: isPending + 폼 재제출 방어
#React Aria는 loading 대신 isPending이라는 이름을 쓴다. 그리고 여기엔 폼 재제출 방어라는 추가 기능이 있다.
// packages/react-aria-components/src/Button.tsx
if (isPending) {
// Mark the button as having aria-disabled for screen readers
ariaDisabled = true;
// If the button is type="submit", change it to type="button"
// to prevent the form from being resubmitted on Enter/Space.
if (props.type === 'submit') {
props = { ...props, type: 'button' };
}
}여기 숨은 문제: 폼 안에 <button type="submit">이 있는데, 네트워크 요청 중이라 isPending=true다. 사용자가 Enter를 누르면? 폼이 다시 제출된다. 브라우저는 submit 버튼을 찾으면 Enter로 폼 제출을 트리거하는데, aria-disabled만 걸려 있으면 이 동작이 안 막힌다.
React Aria는 이걸 type="submit"을 임시로 "button"으로 바꿔서 막는다. pending 중에는 이 버튼이 submit 버튼으로 식별되지 않기 때문에 폼이 재제출되지 않는다.
한 줄 요약: 접근성과 UX 엣지케이스를 빠짐없이 커버하고 있다. React Aria가 "가장 고집스럽게 접근성을 챙기는 라이브러리"라는 평판이 이런 데서 나온다.
방식 5 — Mantine / Chakra: data-loading 속성 + CSS
#Mantine과 Chakra v3는 상태를 DOM attribute로 노출하고, 스타일링은 CSS에 맡긴다.
<button data-loading={loading || undefined}>...</button>.root[data-loading] {
cursor: not-allowed;
color: transparent;
}
.root[data-loading] .loader {
opacity: 1;
}로직은 attribute를 붙이는 것뿐이고, 나머지는 CSS가 한다. React Aria Components의 data-pressed, data-hovered 접근과 같은 계열이다.
styled 라이브러리이면서도 headless 쪽의 "상태는 attribute로 노출하고 스타일은 CSS에 위임한다"는 방식을 차용한 것이다.
다섯 방식을 한 표로
#| 방식 | 라이브러리 | 핵심 특징 |
|---|---|---|
| 지원 안 함 | shadcn/ui | 사용자가 합성 |
| boolean | null | MUI | Google Translate DOM crash 방어 |
| boolean | { delay } | Ant Design | flicker 방지 delay |
| isPending | React Aria | 폼 재제출 방어 |
| data-loading attribute | Mantine, Chakra | 로직/스타일 분리 |
loading 하나에 이렇게 다른 답이 나오는 건, 각 라이브러리가 "Button의 loading이 풀어야 할 주 문제"를 다르게 정의하기 때문이다.
- shadcn은 "그건 Button 문제가 아니다"
- MUI는 "DOM을 건드리는 외부 요인까지 방어해야 한다"
- Ant Design은 "flicker UX가 중요한 엔터프라이즈 환경"
- React Aria는 "submit 재제출 방어가 핵심"
- Mantine/Chakra는 "로직만 주고 스타일은 CSS에".
1편에서 본 포지셔닝 차이가 prop 하나에도 고스란히 드러난다.
영역별로 가장 설득력 있었던 접근
#8개 라이브러리를 다 살펴본 뒤 느낀 건, 어느 하나가 전부 이기진 않는다는 거다. 다만 영역별로 가장 설득력 있었던 접근을 꼽자면 이렇다.
Polymorphism: Radix의 asChild가 가장 설득력 있었다. JSX가 직관적이고 머지 로직이 투명하다. component prop은 타입 추론 비용이 따라온다.
Variant 시스템: shadcn의 CVA 선언 방식에 Chakra v3의 colorPalette 역할 토큰 개념을 결합한 구조가 인상적이었다. variant는 최소한으로 정의하고 color는 CSS variable 네임스페이스로 분리하면, 조합 폭발을 원천 차단할 수 있다.
스타일링 엔진: Tailwind v4나 Panda CSS처럼 런타임 엔진 없이, 빌드 타임에 모든 CSS가 확정되는 방식이 RSC 시대에 가장 잘 맞는다고 느꼈다.
접근성: Base UI의 useFocusableWhenDisabled를 훅으로 분리한 설계와, MUI ButtonBase의 Space/Enter 분리 처리가 눈에 띄었다. composite widget 안에서도 의미 있는 동작을 보장한다.
Loading: React Aria의 isPending 네이밍이 가장 명확했고, 거기에 Ant Design의 delay 옵션까지 더하면 폼 재제출 방어와 flicker 방지를 동시에 챙길 수 있다.
철학: shadcn의 "코드 소유" 모델과 Radix의 "headless primitive" 조합이 가장 흥미로웠다. 사용자 repo에 복사해서 들어가는 파일이되, 그 파일이 Radix primitive를 wrapping하는 구조다.
마무리
#1편에서 8개 라이브러리가 어디에 서 있는지를 봤고, 2편에서는 그 포지셔닝이 실제 prop 설계에서 어떻게 다르게 나타나는지를 봤다.
Polymorphism, variant × color, 접근성, loading — 겉보기엔 평범한 prop 네 개를 열어보니, 라이브러리마다 전혀 다른 문제를 풀고 있었다.
Button은 디자인 시스템에서 가장 단순한 컴포넌트지만, 그래서 오히려 라이브러리의 설계 철학이 가장 적나라하게 드러나는 컴포넌트이기도 하다.
디자인 시스템을 고를 때 docs 첫 페이지와 함께 Button 소스 파일도 열어보면, 그 라이브러리가 어떤 문제를 중요하게 생각하는지 더 선명하게 보일 것이다.
Reference
#www.w3.org
chakra-ui/packages/react/src/theme/recipes at main · chakra-ui/chakra-ui
Chakra UI is a component system for building SaaS products with speed ⚡️ - chakra-ui/chakra-ui