IconButton

Action

텍스트 없이 아이콘으로 하나의 행동을 실행하는 버튼.

Usage

공간이 좁고 아이콘의 의미가 명확한 도구 모음 닫기·검색·삭제처럼 익숙한 단일 행동

import

import

import { IconButton } from '@mildang/design-system/IconButton';

예제를 복사해 쓸 때 필요한 준비

@mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.

@mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.

API Reference

IconButton Props

Prop

Type

Default

asChild

boolean

false

className

string

지정 안 함

disabled

boolean

false

loading

boolean

false

size

IconButtonSize

md

variant

"impact" | "primary" | "secondary" | "tertiary"

tertiary

사용 가이드

권장

  • 모든 아이콘 버튼에 동작을 설명하는 aria-label을 제공한다.
  • 행동의 우선순위에 맞는 variant를 사용한다.

지양

  • 의미가 모호한 아이콘을 설명 없이 단독으로 사용하지 않는다.
  • 여러 행동을 하나의 아이콘 버튼에 결합하지 않는다.

예제

Demo

아이콘 버튼 args와 Star 아이콘을 함께 전달합니다.

import Star from '@mildang/icons/react/star-fill';
import { IconButton, type IconButtonProps } from '@mildang/design-system/IconButton';

export default function IconButtonDemoExample(args: IconButtonProps = {}) {
  return (
    <IconButton
      {...{
        variant: 'primary' as const,
        size: 'lg' as const,
        loading: false,
        'aria-label': '즐겨찾기',
        ...args,
      }}
    >
      <Star />
    </IconButton>
  );
}

위계별 상태

비활성·로딩 등 아이콘 버튼 상태를 비교합니다.

import Star from '@mildang/icons/react/star-fill';
import { IconButton, type IconButtonProps } from '@mildang/design-system/IconButton';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';

type StateConfig = { label: string; id?: string; props?: Partial<IconButtonProps> };
const createStates = (prefix: string): StateConfig[] => [
  { label: 'Inactive', id: `${prefix}-inactive`, props: {} },
  { label: 'Hover', id: `${prefix}-hover`, props: {} },
  { label: 'Pressed', id: `${prefix}-active`, props: {} },
  { label: 'Disabled', id: `${prefix}-disabled`, props: { disabled: true } },
  { label: 'Loading', id: `${prefix}-loading`, props: { loading: true } },
];

export default function IconButtonStateGridExample({
  states,
  args,
}: {
  states?: StateConfig[];
  args?: IconButtonProps;
}) {
  const resolvedStates = states ?? createStates('primary');
  const resolvedArgs = {
    variant: 'primary' as const,
    size: 'lg' as const,
    'aria-label': '즐겨찾기',
    ...(args ?? {}),
  };
  return (
    <div className={css({ display: 'flex', gap: '16px' })}>
      {resolvedStates.map((state) => (
        <div
          className={css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' })}
          key={state.label}
        >
          <Text variant="title-lg-medium" color="neutral.text.low">
            {state.label}
          </Text>
          <IconButton id={state.id} {...resolvedArgs} {...state.props}>
            <Star />
          </IconButton>
        </div>
      ))}
    </div>
  );
}

export function createIconButtonStates(prefix: string) {
  return createStates(prefix);
}

위계 비교

네 단계 위계를 한 화면에서 비교합니다.

import Star from '@mildang/icons/react/star-fill';
import { IconButton, type IconButtonProps } from '@mildang/design-system/IconButton';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const variants = ['impact', 'primary', 'secondary', 'tertiary'] as const;
const gridStyle = css({ display: 'flex', gap: '16px' });
const itemStyle = css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' });
export default function IconButtonHierarchyExample(args: IconButtonProps = {}) {
  const resolvedArgs = {
    variant: 'primary' as const,
    size: 'lg' as const,
    'aria-label': '즐겨찾기',
    ...args,
  };
  return (
    <div className={gridStyle}>
      {variants.map((variant) => (
        <div className={itemStyle} key={variant}>
          <Text variant="title-lg-medium" color="neutral.text.low">
            {variant}
          </Text>
          <IconButton {...resolvedArgs} variant={variant}>
            <Star />
          </IconButton>
        </div>
      ))}
    </div>
  );
}

Destructive

위계별 위험 액션 톤을 비교합니다.

import DeleteIcon from '@mildang/icons/react/delete';
import { IconButton, type IconButtonProps } from '@mildang/design-system/IconButton';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const variants = ['primary', 'secondary', 'tertiary'] as const;
const gridStyle = css({ display: 'flex', gap: '16px' });
const itemStyle = css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' });
export default function IconButtonDestructiveExample(args: IconButtonProps = {}) {
  const resolvedArgs = { variant: 'primary' as const, size: 'lg' as const, 'aria-label': '삭제', ...args };
  return (
    <div className={gridStyle}>
      {variants.map((variant) => (
        <div className={itemStyle} key={variant}>
          <Text variant="title-lg-medium" color="neutral.text.low">
            {variant}
          </Text>
          <IconButton
            {...resolvedArgs}
            variant={variant}
            {...(variant === 'primary' ? { bg: 'critical.fill.base' } : { color: 'critical.fill.base' })}
          >
            <DeleteIcon />
          </IconButton>
        </div>
      ))}
    </div>
  );
}

Success

위계별 긍정 액션 톤을 비교합니다.

import Star from '@mildang/icons/react/star-fill';
import { IconButton, type IconButtonProps } from '@mildang/design-system/IconButton';
import { Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
const variants = ['primary', 'secondary', 'tertiary'] as const;
const gridStyle = css({ display: 'flex', gap: '16px' });
const itemStyle = css({ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' });
export default function IconButtonSuccessExample(args: IconButtonProps = {}) {
  const resolvedArgs = { variant: 'primary' as const, size: 'lg' as const, 'aria-label': '완료', ...args };
  return (
    <div className={gridStyle}>
      {variants.map((variant) => (
        <div className={itemStyle} key={variant}>
          <Text variant="title-lg-medium" color="neutral.text.low">
            {variant}
          </Text>
          <IconButton
            {...resolvedArgs}
            variant={variant}
            {...(variant === 'primary' ? { bg: 'positive.fill.base' } : { color: 'positive.fill.base' })}
          >
            <Star />
          </IconButton>
        </div>
      ))}
    </div>
  );
}

Migration Guide

구버전 위계와 신규 위계의 대응을 확인합니다.

import Star from '@mildang/icons/react/star-fill';
import DeleteIcon from '@mildang/icons/react/delete';
import { IconButton } from '@mildang/design-system/IconButton';
import { VStack } from '@mildang/styled-system/jsx';
import { css } from '@mildang/styled-system/css';
import type { ReactNode } from 'react';

type MigrationRow = { scenario: string; before: string; after: string; preview: ReactNode };
const tableStyle = css({
  display: 'grid',
  gridTemplateColumns: 'auto 1fr 1fr auto',
  width: '100%',
  maxWidth: '1100px',
});
const cellStyle = css({
  px: '3',
  py: '3',
  borderBottom: '1px solid token(colors.neutral.border.low)',
  display: 'flex',
  alignItems: 'center',
});
const codeStyle = css({ fontFamily: 'mono', fontSize: 'sm', whiteSpace: 'pre', overflow: 'auto' });
const headingStyle = css({
  px: '3',
  py: '2',
  borderBottom: '2px solid token(colors.neutral.border.base)',
  fontWeight: 'bold',
  background: 'neutral.surface.high',
});
const titleStyle = css({ fontSize: 'lg', fontWeight: 'bold', mt: '6', mb: '2' });
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) => (
        <div className={css({ display: 'contents' })} 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>
        </div>
      ))}
    </div>
  </section>
);
const migrationRows: { title: string; rows: MigrationRow[] }[] = [
  {
    title: '기본 위계 (color 없음)',
    rows: [
      {
        scenario: 'Contained',
        before: '<IconButton variant="contained">Star</IconButton>',
        after: '<IconButton variant="primary">Star</IconButton>',
        preview: (
          <IconButton variant="primary" aria-label="Contained">
            <Star />
          </IconButton>
        ),
      },
      {
        scenario: 'Outlined',
        before: '<IconButton variant="outlined">Star</IconButton>',
        after: '<IconButton variant="secondary">Star</IconButton>',
        preview: (
          <IconButton variant="secondary" aria-label="Outlined">
            <Star />
          </IconButton>
        ),
      },
      {
        scenario: 'Text / Ghost / None',
        before: '<IconButton variant="text">Star</IconButton>',
        after: '<IconButton variant="tertiary">Star</IconButton>',
        preview: (
          <IconButton variant="tertiary" aria-label="Text">
            <Star />
          </IconButton>
        ),
      },
    ],
  },
  {
    title: 'Destructive (color="error")',
    rows: [
      {
        scenario: 'Primary + error',
        before: '<IconButton variant="contained" color="error">Delete</IconButton>',
        after: '<IconButton variant="primary" bg="critical.fill.base">Delete</IconButton>',
        preview: (
          <IconButton variant="primary" bg="critical.fill.base" aria-label="Error">
            <DeleteIcon />
          </IconButton>
        ),
      },
      {
        scenario: 'Secondary + error',
        before: '<IconButton variant="outlined" color="error">Delete</IconButton>',
        after: '<IconButton variant="secondary" color="critical.fill.base">Delete</IconButton>',
        preview: (
          <IconButton variant="secondary" color="critical.fill.base" aria-label="Error outline">
            <DeleteIcon />
          </IconButton>
        ),
      },
      {
        scenario: 'Tertiary + error',
        before: '<IconButton variant="text" color="error">Delete</IconButton>',
        after: '<IconButton variant="tertiary" color="critical.fill.base">Delete</IconButton>',
        preview: (
          <IconButton variant="tertiary" color="critical.fill.base" aria-label="Error text">
            <DeleteIcon />
          </IconButton>
        ),
      },
    ],
  },
  {
    title: 'Success (color="success")',
    rows: [
      {
        scenario: 'Primary + success',
        before: '<IconButton variant="contained" color="success">Star</IconButton>',
        after: '<IconButton variant="primary" bg="positive.fill.base">Star</IconButton>',
        preview: (
          <IconButton variant="primary" bg="positive.fill.base" aria-label="Success">
            <Star />
          </IconButton>
        ),
      },
      {
        scenario: 'Secondary + success',
        before: '<IconButton variant="outlined" color="success">Star</IconButton>',
        after: '<IconButton variant="secondary" color="positive.fill.base">Star</IconButton>',
        preview: (
          <IconButton variant="secondary" color="positive.fill.base" aria-label="Success outline">
            <Star />
          </IconButton>
        ),
      },
      {
        scenario: 'Tertiary + success',
        before: '<IconButton variant="text" color="success">Star</IconButton>',
        after: '<IconButton variant="tertiary" color="positive.fill.base">Star</IconButton>',
        preview: (
          <IconButton variant="tertiary" color="positive.fill.base" aria-label="Success text">
            <Star />
          </IconButton>
        ),
      },
    ],
  },
  {
    title: '기타 color',
    rows: [
      {
        scenario: 'color="impact" → variant="impact"',
        before: '<IconButton variant="contained" color="impact">Star</IconButton>',
        after: '<IconButton variant="impact">Star</IconButton>',
        preview: (
          <IconButton variant="impact" aria-label="Impact">
            <Star />
          </IconButton>
        ),
      },
      {
        scenario: 'color="gray" (Dialog close 등)',
        before: '<IconButton color="gray">Close</IconButton>',
        after: '<IconButton variant="tertiary">Close</IconButton>',
        preview: (
          <IconButton variant="tertiary" aria-label="Close">
            <Star />
          </IconButton>
        ),
      },
      {
        scenario: '커스텀 토큰',
        before: '<IconButton color={adaptive.red500}>Star</IconButton>',
        after: '<IconButton color="critical.fill.base">Star</IconButton>',
        preview: (
          <IconButton variant="tertiary" color="critical.fill.base" aria-label="Custom token">
            <Star />
          </IconButton>
        ),
      },
    ],
  },
];

export default function IconButtonMigrationGuideExample() {
  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">
        <IconButton variant="primary" aria-label="Contained">
          <Star />
        </IconButton>
        <IconButton variant="secondary" aria-label="Outlined">
          <Star />
        </IconButton>
        <IconButton variant="tertiary" aria-label="Text">
          <Star />
        </IconButton>
      </HStack>
      <HStack gap="8" flexWrap="wrap">
        <IconButton variant="primary" bg="critical.fill.base" aria-label="Error">
          <DeleteIcon />
        </IconButton>
        <IconButton variant="secondary" color="critical.fill.base" aria-label="Error outline">
          <DeleteIcon />
        </IconButton>
        <IconButton variant="tertiary" color="critical.fill.base" aria-label="Error text">
          <DeleteIcon />
        </IconButton>
      </HStack>
      <HStack gap="8" flexWrap="wrap">
        <IconButton variant="primary" bg="positive.fill.base" aria-label="Success">
          <Star />
        </IconButton>
        <IconButton variant="secondary" color="positive.fill.base" aria-label="Success outline">
          <Star />
        </IconButton>
        <IconButton variant="tertiary" color="positive.fill.base" aria-label="Success text">
          <Star />
        </IconButton>
      </HStack>
      <HStack gap="8" flexWrap="wrap">
        <IconButton variant="impact" aria-label="Impact">
          <Star />
        </IconButton>
        <IconButton variant="tertiary" aria-label="color gray">
          <Star />
        </IconButton>
        <IconButton variant="tertiary" color="critical.fill.base" aria-label="custom token">
          <Star />
        </IconButton>
      </HStack>
    </VStack>
  ); */
}

Ref 사용

asChild로 실제 button ref를 연결하는 예제입니다.

import { useRef } from 'react';
import Star from '@mildang/icons/react/star-fill';
import { IconButton, type IconButtonProps } from '@mildang/design-system/IconButton';

export default function IconButtonWithRefExample(args: IconButtonProps = {}) {
  const ref = useRef<HTMLButtonElement>(null);
  const resolvedArgs = {
    variant: 'primary' as const,
    size: 'md' as const,
    'aria-label': '즐겨찾기',
    ...args,
  };
  return (
    <IconButton {...resolvedArgs} asChild>
      <button ref={ref} onClick={() => console.log(ref.current)}>
        <Star />
      </button>
    </IconButton>
  );
}