Button
Action
사용자가 즉시 실행할 행동을 담는 버튼.
Usage
폼 제출 저장·삭제처럼 결과가 분명한 행동 화면에서 우선순위가 있는 CTA
import
import
import { Button } from '@mildang/design-system/Button';타입
import type { ButtonProps, ButtonVariant } from '@mildang/design-system/Button';예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
API Reference
Button Props
Prop
Type
Default
boolean
false
ReactNode
지정 안 함
string
지정 안 함
boolean
false
ReactNode
지정 안 함
boolean
false
string
지정 안 함
boolean
false
"xl" | "lg" | "md" | "sm" | "xs"
md
ReactNode
지정 안 함
"primary" | "impact" | "secondary" | "tertiary" | "quaternary"
quaternary
관련 타입
아이콘 배치
아이콘은 children 이 아니라 startIcon·endIcon 으로 넣는다. 크기와 텍스트와의 간격은
size 가 함께 정하므로 아이콘 쪽에 크기를 따로 주지 않는다.
startIcon— 행동 자체를 가리키는 아이콘. 내려받기, 추가, 새로 고침처럼 아이콘만 봐도 무슨 일이 일어날지 아는 경우다.endIcon— 행동 다음을 가리키는 아이콘. 다음 단계로 넘어가기, 목록 펼치기처럼 방향이 뜻인 경우다.- 아이콘이 있는 쪽 좌우 여백은
paddingXWithIcon으로 조금 좁아진다 — 아이콘의 시각 무게가 글자보다 커서 그대로 두면 한쪽이 벌어져 보인다. - 톤만 따로 가져가야 하면
iconColor로 아이콘 색만 바꾼다. 텍스트 색은color가 그대로 맡는다.
Size Guide
다섯 단계 사이즈와 측정값을 비교합니다.
import DeleteIcon from '@mildang/icons/react/delete';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const sizes = ['xl', 'lg', 'md', 'sm', 'xs'] as const;
const specs = [
['xl', '18px', '500', '28px', '11px / 20px', '~50px', '24px', '8px'],
['lg', '15px', '500', '26px', '8px / 16px', '~42px', '22px', '8px'],
['md', '14px', '500', '24px', '6px / 12px', '~36px', '20px', '8px'],
['sm', '13px', '400', '20px', '6px / 10px', '~32px', '18px', '8px'],
['xs', '13px', '400', '20px', '2px / 8px', '~24px', '16px', '6px'],
] as const;
const tableStyle = css({
width: '100%',
maxWidth: '760px',
borderCollapse: 'collapse',
fontSize: '13px',
mt: '32px',
});
const cellStyle = css({
px: '12px',
py: '8px',
borderBottom: '1px solid token(colors.neutral.border.low)',
whiteSpace: 'nowrap',
fontFamily: 'mono',
fontSize: '12px',
});
const labelCellStyle = css({
px: '12px',
py: '8px',
borderBottom: '1px solid token(colors.neutral.border.low)',
whiteSpace: 'nowrap',
fontWeight: 'bold',
});
const headerStyle = css({
px: '12px',
py: '8px',
borderBottom: '2px solid token(colors.neutral.border.base)',
fontWeight: 'bold',
textAlign: 'left',
background: 'neutral.surface.high',
whiteSpace: 'nowrap',
});
export default function ButtonSizeGuideExample() {
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'flex-start' })}>
{sizes.map((size) => (
<div key={size} className={css({ display: 'flex', alignItems: 'center', gap: '16px' })}>
<Text
variant="caption-lg-medium"
color="neutral.text.low"
className={css({ width: '24px', textAlign: 'right', flexShrink: 0 })}
>
{size}
</Text>
<Button variant="primary" size={size} startIcon={<DeleteIcon />}>
버튼 텍스트
</Button>
</div>
))}
<table className={tableStyle}>
<thead>
<tr>
{[
'size',
'폰트 크기',
'굵기',
'줄 높이',
'패딩 (상하 / 좌우)',
'버튼 높이',
'아이콘',
'radius',
].map((header) => (
<th className={headerStyle} key={header}>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{specs.map((row) => (
<tr key={row[0]}>
{row.map((value, index) => (
<td className={index === 0 ? labelCellStyle : cellStyle} key={`${row[0]}-${index}`}>
{value}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
버튼을 나란히 둘 때
가로로 두 개 이상 놓으면 버튼 사이 간격은 8px 이다.
- 단일 — 취할 행동이 하나뿐인 자리. 위계를 낮출 이유가 없으니
primary로 둔다. - 한 쌍 — 취소·저장처럼 짝을 이루는 자리. 주 행동을 오른쪽에 두고 왼쪽은 한 단계 낮은 위계(
secondary이하)로 내린다.
한 영역에 primary 를 두 개 두지 않는다 — 둘 다 주 행동이면 사용자는 어느 쪽이 기본인지 못 고른다.
비동기 작업 로딩
시간이 걸리는 제출은 클릭 직후 loading을 켜 중복 클릭을 막고, 작업이 끝나면 다시 끈다.
로딩 중에는 버튼의 진행 상태가 표시되고 버튼을 다시 누를 수 없다.
로딩 버튼
loading 상태에서 진행 중 표시를 보여줍니다.
import { Button, type ButtonProps } from '@mildang/design-system/Button';
export default function ButtonWithSpinnerExample(args: ButtonProps = {}) {
return (
<Button {...{ variant: 'primary' as const, size: 'md' as const, ...args }} loading>
Text
</Button>
);
}
사용 가이드
권장
- 한 화면의 주요 행동은 위계를 명확히 구분한다.
- 버튼 문구는 실행 결과를 동사로 설명한다.
지양
- 페이지 이동 용도로 Button을 사용하지 않는다.
- 같은 영역에 primary 행동을 여러 개 두지 않는다.
예제
Demo
args를 그대로 전달하는 기본 버튼입니다.
import { Button, type ButtonProps } from '@mildang/design-system/Button';
export default function ButtonDemoExample(args: ButtonProps = {}) {
return <Button {...{ variant: 'primary' as const, size: 'md' as const, children: 'Text', ...args }} />;
}
위계별 상태
각 위계의 크기와 비활성·로딩 상태를 함께 비교합니다.
import { Button, type ButtonProps } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const sizes = ['xl', 'lg', 'md', 'sm', 'xs'] as const;
type StateConfig = { label: string; id?: string; props?: Partial<ButtonProps> };
const createStates = (prefix: string): StateConfig[] => [
{ label: 'Inactive', id: `${prefix}-inactive` },
{ label: 'Hover', id: `${prefix}-hover` },
{ label: 'Pressed', id: `${prefix}-active` },
{ label: 'Disabled', id: `${prefix}-disabled`, props: { disabled: true } },
{ label: 'Loading', id: `${prefix}-loading`, props: { loading: true } },
];
const rowStyle = css({ display: 'flex', alignItems: 'center', gap: '20px' });
const sizeLabelStyle = css({ width: '24px', textAlign: 'right', flexShrink: 0, userSelect: 'none' });
const btnCellStyle = css({ display: 'flex', justifyContent: 'center', minWidth: '100px' });
export default function ButtonHierarchyStatesExample({
variant,
args,
}: {
variant?: ButtonProps['variant'];
args?: ButtonProps;
}) {
const resolvedVariant = variant ?? 'primary';
const resolvedArgs = {
variant: resolvedVariant,
size: 'lg' as const,
children: '제출하기',
...(args ?? {}),
};
const states = createStates(String(resolvedVariant));
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '16px' })}>
<div className={rowStyle}>
<div className={sizeLabelStyle} />
{states.map((state) => (
<Text
key={state.label}
as="div"
variant="caption-lg-medium"
color="neutral.text.low"
className={btnCellStyle}
>
{state.label}
</Text>
))}
</div>
{sizes.map((size) => (
<div key={size} className={rowStyle}>
<Text as="div" variant="caption-lg-medium" color="neutral.text.low" className={sizeLabelStyle}>
{size}
</Text>
{states.map((state) => (
<div key={state.label} className={btnCellStyle}>
<Button id={`${size}-${state.id}`} {...resolvedArgs} size={size} {...state.props}>
{state.props?.loading ? 'Text' : resolvedArgs.children}
</Button>
</div>
))}
</div>
))}
</div>
);
}
위계 비교
다섯 단계 위계를 한 화면에서 비교합니다.
import { Button, type ButtonProps, type ButtonVariant } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const variants: readonly ButtonVariant[] = ['impact', 'primary', 'secondary', 'tertiary', 'quaternary'];
const gridStyle = css({ display: 'flex', gap: '16px' });
const itemStyle = css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' });
export default function ButtonHierarchyExample(args: ButtonProps = {}) {
const resolvedArgs = { variant: 'primary' as const, size: 'lg' as const, children: '제출하기', ...args };
return (
<div className={gridStyle}>
{variants.map((variant) => (
<div className={itemStyle} key={variant}>
<Text variant="title-lg-medium" color="neutral.text.low">
{variant}
</Text>
<Button {...resolvedArgs} variant={variant}>
{resolvedArgs.children}
</Button>
</div>
))}
</div>
);
}
Destructive
위계별 위험 액션 톤을 비교합니다.
import DeleteIcon from '@mildang/icons/react/delete';
import { Button, type ButtonProps } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const gridStyle = css({ display: 'flex', gap: '16px' });
const itemStyle = css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' });
export default function ButtonDestructiveExample(args: ButtonProps = {}) {
const resolvedArgs = {
variant: 'primary' as const,
size: 'lg' as const,
children: '삭제',
startIcon: <DeleteIcon />,
...args,
};
return (
<div className={gridStyle}>
{(['primary', 'secondary', 'tertiary', 'quaternary'] as const).map((variant) => (
<div className={itemStyle} key={variant}>
<Text variant="title-lg-medium" color="neutral.text.low">
{variant}
</Text>
<Button
{...resolvedArgs}
variant={variant}
{...(variant === 'primary'
? { bg: 'critical.fill.base' }
: {
color: 'critical.text.base',
iconColor: 'critical.fill.base',
...(variant === 'secondary'
? { boxShadow: 'inset 0 0 0 1px token(colors.neutral.border.highest)' }
: {}),
})}
>
{resolvedArgs.children}
</Button>
</div>
))}
</div>
);
}
Migration Guide
구버전 위계와 신규 위계의 대응을 확인합니다.
import { Button } from '@mildang/design-system/Button';
import DeleteIcon from '@mildang/icons/react/delete';
import { VStack } from '@mildang/styled-system/jsx';
import { css } from '@mildang/styled-system/css';
import { Fragment, type ReactNode } from 'react';
type MigrationRow = { scenario: string; before: string; after: string; preview: ReactNode };
const tableStyle = css({
display: 'grid',
gridTemplateColumns: 'auto 1fr 1fr auto',
gap: '0',
borderCollapse: 'collapse',
fontSize: '14px',
width: '100%',
maxWidth: '1100px',
});
const cellStyle = css({
px: '12px',
py: '14px',
borderBottom: '1px solid token(colors.neutral.border.low)',
display: 'flex',
alignItems: 'center',
});
const codeStyle = css({
fontFamily: 'mono',
fontSize: '13px',
background: 'neutral.surface.high',
px: '8px',
py: '4px',
borderRadius: '4px',
whiteSpace: 'pre',
overflow: 'auto',
display: 'inline-block',
});
const headingStyle = css({
px: '12px',
py: '10px',
borderBottom: '2px solid token(colors.neutral.border.base)',
fontWeight: 'bold',
background: 'neutral.surface.high',
});
const titleStyle = css({ fontSize: '16px', fontWeight: 'bold', mt: '24px', mb: '8px' });
const MigrationTable = ({ title, rows }: { title: string; rows: MigrationRow[] }) => (
<section>
<div className={titleStyle}>{title}</div>
<div className={tableStyle}>
{['케이스', 'Before (구버전)', 'After (신버전)', '결과'].map((label) => (
<div className={headingStyle} key={label}>
{label}
</div>
))}
{rows.map((row) => (
<Fragment key={row.scenario}>
<div className={cellStyle}>{row.scenario}</div>
<div className={cellStyle}>
<code className={codeStyle}>{row.before}</code>
</div>
<div className={cellStyle}>
<code className={codeStyle}>{row.after}</code>
</div>
<div className={cellStyle}>{row.preview}</div>
</Fragment>
))}
</div>
</section>
);
const migrationRows: { title: string; rows: MigrationRow[] }[] = [
{
title: '기본 위계 (color 없음)',
rows: [
{
scenario: 'Contained',
before: '<Button variant="contained">저장</Button>',
after: '<Button variant="primary">저장</Button>',
preview: <Button variant="primary">저장</Button>,
},
{
scenario: 'Outlined + strong (강조)',
before: '<Button variant="outlined" strong>취소</Button>',
after: '<Button variant="secondary">취소</Button>',
preview: <Button variant="secondary">취소</Button>,
},
{
scenario: 'Outlined (기본/옅음)',
before: '<Button variant="outlined">취소</Button>',
after: '<Button variant="tertiary">취소</Button>',
preview: <Button variant="tertiary">취소</Button>,
},
{
scenario: 'Text',
before: '<Button variant="text">더보기</Button>',
after: '<Button variant="quaternary">더보기</Button>',
preview: <Button variant="quaternary">더보기</Button>,
},
],
},
{
title: 'Destructive (color="error")',
rows: [
{
scenario: 'Contained + error',
before: '<Button variant="contained" color="error">삭제</Button>',
after: '<Button variant="primary" bg="critical.fill.base">삭제</Button>',
preview: (
<Button variant="primary" bg="critical.fill.base">
삭제
</Button>
),
},
{
scenario: 'Outlined + strong + error',
before: '<Button variant="outlined" strong color="error">삭제</Button>',
after:
'<Button\n variant="secondary"\n color="critical.text.base"\n iconColor="critical.fill.base"\n boxShadow="inset 0 0 0 1px token(colors.neutral.border.highest)"\n>삭제</Button>',
preview: (
<Button
variant="secondary"
color="critical.text.base"
iconColor="critical.fill.base"
boxShadow="inset 0 0 0 1px token(colors.neutral.border.highest)"
startIcon={<DeleteIcon />}
>
삭제
</Button>
),
},
{
scenario: 'Outlined + error (옅음)',
before: '<Button variant="outlined" color="error">삭제</Button>',
after: '<Button variant="tertiary" color="critical.text.base">삭제</Button>',
preview: (
<Button
variant="tertiary"
color="critical.text.base"
iconColor="critical.fill.base"
startIcon={<DeleteIcon />}
>
삭제
</Button>
),
},
{
scenario: 'Text + error',
before: '<Button variant="text" color="error">반려 사유</Button>',
after: '<Button variant="quaternary" color="critical.text.base">반려 사유</Button>',
preview: (
<Button
variant="quaternary"
color="critical.text.base"
iconColor="critical.fill.base"
startIcon={<DeleteIcon />}
>
반려 사유
</Button>
),
},
],
},
{
title: 'Success (color="secondary" — 구버전에선 초록 톤)',
rows: [
{
scenario: 'Contained + secondary',
before: '<Button variant="contained" color="secondary">확인</Button>',
after: '<Button variant="primary" bg="positive.fill.base">확인</Button>',
preview: (
<Button variant="primary" bg="positive.fill.base">
확인
</Button>
),
},
{
scenario: 'Outlined + strong + secondary',
before: '<Button variant="outlined" strong color="secondary">확인</Button>',
after:
'<Button\n variant="secondary"\n color="positive.text.base"\n iconColor="positive.fill.base"\n boxShadow="inset 0 0 0 1px token(colors.neutral.border.highest)"\n>확인</Button>',
preview: (
<Button
variant="secondary"
color="positive.text.base"
iconColor="positive.fill.base"
boxShadow="inset 0 0 0 1px token(colors.neutral.border.highest)"
>
확인
</Button>
),
},
{
scenario: 'Outlined + secondary (옅음)',
before: '<Button variant="outlined" color="secondary">확인</Button>',
after: '<Button variant="tertiary" color="positive.text.base">확인</Button>',
preview: (
<Button variant="tertiary" color="positive.text.base" iconColor="positive.fill.base">
확인
</Button>
),
},
{
scenario: 'Text + secondary',
before: '<Button variant="text" color="secondary">완료</Button>',
after: '<Button variant="quaternary" color="positive.text.base">완료</Button>',
preview: (
<Button variant="quaternary" color="positive.text.base" iconColor="positive.fill.base">
완료
</Button>
),
},
],
},
{
title: '기타 color',
rows: [
{
scenario: 'color="primary" (불필요)',
before: '<Button variant="contained" color="primary">저장</Button>',
after: '<Button variant="primary">저장</Button>',
preview: <Button variant="primary">저장</Button>,
},
{
scenario: 'color="impact" → variant="impact"',
before: '<Button variant="contained" color="impact">제출</Button>',
after: '<Button variant="impact">제출</Button>',
preview: <Button variant="impact">제출</Button>,
},
],
},
];
export default function ButtonMigrationGuideExample() {
return (
<VStack alignItems="flex-start" gap="8">
{migrationRows.map((table) => (
<MigrationTable key={table.title} {...table} />
))}
</VStack>
);
/* return (
<VStack alignItems="flex-start" gap="16">
<HStack gap="8" flexWrap="wrap">
<Button variant="primary">Contained → Primary</Button>
<Button variant="secondary">Outlined + strong → Secondary</Button>
<Button variant="tertiary">Outlined → Tertiary</Button>
<Button variant="quaternary">Text → Quaternary</Button>
</HStack>
<HStack gap="8" flexWrap="wrap">
<Button variant="primary" bg="critical.fill.base">
Contained + error
</Button>
<Button
variant="secondary"
color="critical.text.base"
iconColor="critical.fill.base"
boxShadow="inset 0 0 0 1px token(colors.neutral.border.highest)"
startIcon={<DeleteIcon />}
>
Outlined + error
</Button>
<Button
variant="tertiary"
color="critical.text.base"
iconColor="critical.fill.base"
startIcon={<DeleteIcon />}
>
Outlined + error
</Button>
<Button
variant="quaternary"
color="critical.text.base"
iconColor="critical.fill.base"
startIcon={<DeleteIcon />}
>
Text + error
</Button>
</HStack>
<HStack gap="8" flexWrap="wrap">
<Button variant="primary" bg="positive.fill.base">
Contained + secondary
</Button>
<Button
variant="secondary"
color="positive.text.base"
iconColor="positive.fill.base"
boxShadow="inset 0 0 0 1px token(colors.neutral.border.highest)"
>
Outlined + secondary
</Button>
<Button variant="tertiary" color="positive.text.base" iconColor="positive.fill.base">
Outlined + secondary
</Button>
<Button variant="quaternary" color="positive.text.base" iconColor="positive.fill.base">
Text + secondary
</Button>
</HStack>
<HStack gap="8">
<Button variant="primary">color="primary" (불필요)</Button>
<Button variant="impact">color="impact" → Impact</Button>
</HStack>
</VStack>
); */
}
Ref 사용
asChild로 실제 button ref를 연결하는 예제입니다.
import { useRef } from 'react';
import { Button, type ButtonProps } from '@mildang/design-system/Button';
export default function ButtonWithRefExample(args: ButtonProps = {}) {
const ref = useRef<HTMLButtonElement>(null);
const resolvedArgs = { variant: 'primary' as const, size: 'md' as const, ...args };
return (
<Button {...resolvedArgs} asChild>
<button ref={ref} onClick={() => console.log(ref.current)}>
Text123
</button>
</Button>
);
}