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

asChild

boolean

false

children

ReactNode

지정 안 함

className

string

지정 안 함

disabled

boolean

false

endIcon

ReactNode

지정 안 함

fullWidth

boolean

false

iconColor

string

지정 안 함

loading

boolean

false

size

"xl" | "lg" | "md" | "sm" | "xs"

md

startIcon

ReactNode

지정 안 함

variant

"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=&quot;primary&quot; (불필요)</Button>
        <Button variant="impact">color=&quot;impact&quot; → 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>
  );
}