Badge

Feedback & Status

아이콘·아바타 우상단에 점이나 숫자를 얹는 배지.

Usage

아이콘·아바타 등 host 위에 알림 수·신규 상태를 작은 표식으로 덧붙일 때 호스트 없이 텍스트 옆에 인라인으로 신규 표식을 둘 때

import

import

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

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

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

API Reference

Badge Props

Prop

Type

Default

children

ReactNode

지정 안 함

className

string

지정 안 함

count

number

지정 안 함

label

string

지정 안 함

max

number

999

overlap

"rectangular" | "circular"

rectangular

show

boolean

지정 안 함

showZero

boolean

지정 안 함

size

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

md

variant

"dot" | "number"

dot

dot과 number

children이 있으면 host(아이콘·아바타 등) 우상단에 얹는 오버레이로, 없으면 인라인으로 렌더된다. 채팅 안 읽음 개수, 새 알림 표시, 목록 항목의 신규 표식 등에 쓴다.

variant용도표시 규칙
dot(기본)존재 여부만 표시 (신규 알림 있음/없음)show로 토글. 기본 표시
number수량 표시 (안 읽은 메시지 N개)count > 0일 때 표시. count > max(기본 999)면 {max}+. count가 0 이하면 자동 숨김(showZero로 override)

기본 사용

children 위에 dot 배지를 얹는 최소 형태입니다. 아이콘은 size에 맞춰 자동으로 크기가 조정됩니다.

import type { ComponentProps } from 'react';
import { Badge } from '@mildang/design-system/Badge';

// 실제 @mildang/icons처럼 em 기반으로 렌더 → 부모(Badge root)의 fontSize에 따라 자동 크기 조정된다.
// width를 넘기면 그 값이 우선한다(호출자 우선).
const DemoIcon = ({ size, round }: { size?: number; round?: boolean }) => (
  <span
    style={{
      display: 'inline-block',
      width: size ?? '1em',
      height: size ?? '1em',
      background: '#e5e7eb',
      borderRadius: round ? '50%' : 6,
    }}
  />
);

const STORY_DEFAULT_ARGS = { ...({}), ...({ variant: 'dot', size: 'md', show: true }) } as ComponentProps<typeof Badge>;

const BadgeDemoExampleRender = (args: ComponentProps<typeof Badge>) => (
    <Badge {...args}>
      <DemoIcon />
    </Badge>
  );

export default function BadgeDemoExample(props: Partial<ComponentProps<typeof Badge>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Badge>;
  return BadgeDemoExampleRender(mergedProps);
}

사이즈

size는 host(아이콘) 기준 크기다. @mildang/icons의 아이콘은 width/height="1em"으로 렌더되므로 이 size에 맞춰 자동으로 크기가 조정된다 — 아이콘에 크기를 따로 지정할 필요가 없다. Avatar나 raw px 자식은 자동 대상이 아니므로 아래 px에 맞춰 직접 크기를 준다.

size자동 아이콘 pxdot 지름number 지원
xs185px❌ (sm으로 폴백)
sm226px
md(기본값)246px
lg288px
xl328px

Sizes

xs부터 xl까지 host 기준 크기 스케일을 한 줄로 비교합니다.

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

const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;

// 실제 @mildang/icons처럼 em 기반으로 렌더 → 부모(Badge root)의 fontSize에 따라 자동 크기 조정된다.
// width를 넘기면 그 값이 우선한다(호출자 우선).
const DemoIcon = ({ size, round }: { size?: number; round?: boolean }) => (
  <span
    style={{
      display: 'inline-block',
      width: size ?? '1em',
      height: size ?? '1em',
      background: '#e5e7eb',
      borderRadius: round ? '50%' : 6,
    }}
  />
);

const BadgeDotSizesExample = () => (
    <div style={{ display: 'grid', gap: 24 }}>
      <div style={{ display: 'grid', gap: 8 }}>
        <span style={{ fontSize: 12, color: '#888' }}>rectangular · 사각 host (아이콘)</span>
        <div style={{ display: 'flex', gap: 32, alignItems: 'flex-end' }}>
          {sizes.map((s) => (
            <Badge key={s} variant="dot" size={s} show>
              <DemoIcon />
            </Badge>
          ))}
        </div>
      </div>
      <div style={{ display: 'grid', gap: 8 }}>
        <span style={{ fontSize: 12, color: '#888' }}>circular · 원형 host</span>
        <div style={{ display: 'flex', gap: 32, alignItems: 'flex-end' }}>
          {sizes.map((s) => (
            <Badge key={s} variant="dot" size={s} overlap="circular" show>
              <DemoIcon round />
            </Badge>
          ))}
        </div>
      </div>
    </div>
  );

export default BadgeDotSizesExample;

host 모양과 overlap

오버레이(children 있음) 시 host 모양에 맞춰 배지 위치를 정렬한다.

판단 기준은 host가 "정원"(너비=높이 + 완전히 둥근 모서리)이냐다. 맞으면 overlap="circular", 아니면 기본값인 rectangular를 쓴다. 정원은 실제 가장자리가 bounding box 코너에서 45° 방향으로 안쪽에 있어, 코너 기준(rectangular)으로 두면 배지가 원 밖으로 떠 보인다. Badge는 자식의 모양을 추론하지 못하므로 반드시 호출자가 지정한다.

host모양overlap
Avatar(circle) — number만정원circular (dot은 Avatar 자체 badge prop)
FloatingButton / 원형 프로필·썸네일정원circular
IconButton(educore 테마, 완전 라운드)정원circular
IconButton(기본 테마, 라운드 코너)rounded-rectrectangular
Button / TitleButton / MenuButtonrounded-rectrectangular
Icon(bell 등 glyph)사각 bboxrectangular
Avatar(square)라운드 사각rectangular
넓은 pill(너비≫높이, 완전 라운드)반원 arcrectangular(정원 아님)

같은 IconButton도 기본 테마는 rectangular, educore 테마는 circular로 갈린다 — 컴포넌트가 아니라 실제 렌더된 모양이 기준이다.

Avatar는 안 읽음 dot을 자체 badge prop으로 제공하므로, Avatar에 Badge를 씌울 땐 number 용도만 쓴다. 안 읽음 개수는 overlap="circular"를 준 Badge(variant="number")로 Avatar를 감싸고, 안 읽음 dot은 Avatar의 badge prop을 그대로 켠다.

host 모양에 맞추기

정원 host에 overlap을 생략했을 때와 circular를 지정했을 때를 비교합니다.

import { Badge } from '@mildang/design-system/Badge';
import { Avatar } from '@mildang/design-system/Avatar';
import { FloatingButton } from '@mildang/design-system/FloatingButton';
import Add from '@mildang/icons/react/add';

const numberSizes = ['sm', 'md', 'lg', 'xl'] as const;

// overlap 비교용 컬럼(원형 host, 한 shape: rectangular 생략 / circular):
// - Avatar: number만 (dot은 Avatar 자체 badge prop으로 처리하므로 Badge는 number 용도만) — 전 사이즈
// - FloatingButton: dot + number (자체 badge가 없어 Badge로 둘 다) — sm/md
const OverlapColumn = ({
  overlap,
  label,
  labelColor,
}: {
  overlap?: 'circular';
  label: string;
  labelColor: string;
}) => (
  <div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center' }}>
    {/* Avatar — number 전용 */}
    <div style={{ display: 'flex', gap: 28, alignItems: 'flex-end' }}>
      {numberSizes.map((s) => (
        <Badge key={`av-num-${s}`} variant="number" size={s} overlap={overlap} count={7}>
          <Avatar size={s} initial="밀당" />
        </Badge>
      ))}
    </div>
    {/* FloatingButton — dot + number */}
    <div style={{ display: 'flex', gap: 28, alignItems: 'flex-end' }}>
      {(['sm', 'md'] as const).map((s) => (
        <Badge key={`fab-dot-${s}`} variant="dot" size={s} overlap={overlap} show>
          <FloatingButton size={s} color="primary" icon={<Add />} />
        </Badge>
      ))}
      {(['sm', 'md'] as const).map((s) => (
        <Badge key={`fab-num-${s}`} variant="number" size={s} overlap={overlap} count={7}>
          <FloatingButton size={s} color="primary" icon={<Add />} />
        </Badge>
      ))}
    </div>
    <span style={{ fontSize: 12, color: labelColor }}>{label}</span>
  </div>
);

const BadgeOverlapComparisonExample = () => (
    <div style={{ display: 'flex', gap: 64, alignItems: 'flex-start', padding: 24 }}>
      <OverlapColumn label="❌ overlap 생략 (기본 rectangular)" labelColor="#d92d20" />
      <OverlapColumn overlap="circular" label={'✅ overlap="circular"'} labelColor="#079455" />
    </div>
  );

export default BadgeOverlapComparisonExample;

접근성

배지 본체는 aria-hidden="true"로 시각 표현만 담당한다. 상태는 별도의 role="status" + aria-live="polite" 영역에서 스크린리더로 전달된다.

label을 지정하지 않으면 로케일에 맞춘 번역 기본값이 쓰인다.

  • number — "읽지 않은 알림 개"
  • dot — "읽지 않은 알림이 있습니다"

배지가 나타내는 의미가 기본값과 다르면(예: 새 메시지가 아니라 오류 상태) label로 직접 설명을 제공한다.

기본 사용

children 위에 dot 배지를 얹는 최소 형태입니다. 아이콘은 size에 맞춰 자동으로 크기가 조정됩니다.

import type { ComponentProps } from 'react';
import { Badge } from '@mildang/design-system/Badge';

// 실제 @mildang/icons처럼 em 기반으로 렌더 → 부모(Badge root)의 fontSize에 따라 자동 크기 조정된다.
// width를 넘기면 그 값이 우선한다(호출자 우선).
const DemoIcon = ({ size, round }: { size?: number; round?: boolean }) => (
  <span
    style={{
      display: 'inline-block',
      width: size ?? '1em',
      height: size ?? '1em',
      background: '#e5e7eb',
      borderRadius: round ? '50%' : 6,
    }}
  />
);

const STORY_DEFAULT_ARGS = { ...({}), ...({ variant: 'dot', size: 'md', show: true }) } as ComponentProps<typeof Badge>;

const BadgeDemoExampleRender = (args: ComponentProps<typeof Badge>) => (
    <Badge {...args}>
      <DemoIcon />
    </Badge>
  );

export default function BadgeDemoExample(props: Partial<ComponentProps<typeof Badge>>) {
  const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as ComponentProps<typeof Badge>;
  return BadgeDemoExampleRender(mergedProps);
}

예제

인라인 배지

host 없이 텍스트 옆에 나란히 두는 인라인 사용입니다.

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

const BadgeInlineExample = () => (
    <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
      <span>알림</span>
      <Badge variant="dot" size="sm" />
      <Badge variant="number" size="sm" count={5} />
    </div>
  );

export default BadgeInlineExample;

NumberSizes

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

const numberSizes = ['sm', 'md', 'lg', 'xl'] as const;

// 실제 @mildang/icons처럼 em 기반으로 렌더 → 부모(Badge root)의 fontSize에 따라 자동 크기 조정된다.
// width를 넘기면 그 값이 우선한다(호출자 우선).
const DemoIcon = ({ size, round }: { size?: number; round?: boolean }) => (
  <span
    style={{
      display: 'inline-block',
      width: size ?? '1em',
      height: size ?? '1em',
      background: '#e5e7eb',
      borderRadius: round ? '50%' : 6,
    }}
  />
);

const BadgeNumberSizesExample = () => (
    <div style={{ display: 'grid', gap: 24 }}>
      <div style={{ display: 'grid', gap: 8 }}>
        <span style={{ fontSize: 12, color: '#888' }}>rectangular · 사각 host (아이콘)</span>
        <div style={{ display: 'flex', gap: 32, alignItems: 'flex-end' }}>
          {numberSizes.map((s) => (
            <Badge key={s} variant="number" size={s} count={999}>
              <DemoIcon />
            </Badge>
          ))}
        </div>
      </div>
      <div style={{ display: 'grid', gap: 8 }}>
        <span style={{ fontSize: 12, color: '#888' }}>circular · 원형 host (Avatar)</span>
        <div style={{ display: 'flex', gap: 32, alignItems: 'flex-end' }}>
          {numberSizes.map((s) => (
            <Badge key={s} variant="number" size={s} overlap="circular" count={999}>
              <Avatar size={s} initial="밀당" />
            </Badge>
          ))}
        </div>
      </div>
    </div>
  );

export default BadgeNumberSizesExample;

HostSizeMatrix

import { Badge } from '@mildang/design-system/Badge';
import { FloatingButton } from '@mildang/design-system/FloatingButton';
import Add from '@mildang/icons/react/add';
import { MenuButton } from '@mildang/design-system/MenuButton';
import { IconButton } from '@mildang/design-system/IconButton';
import MoreHoriz from '@mildang/icons/react/more-horiz';
import Bell from '@mildang/icons/react/bell';
import { Button } from '@mildang/design-system/Button';
import { TitleButton } from '@mildang/design-system/TitleButton';
import { Menu } from '@mildang/design-system/Menu';
import { ReactNode } from 'react';

const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;

const numberSizes = ['sm', 'md', 'lg', 'xl'] as const;

// host별·사이즈별 정렬 검증 매트릭스.
// - FloatingButton: 원형 → overlap="circular" (FAB는 sm/md만 존재).
// - MenuButton(IconButton 트리거): 기본 테마 rounded-rect → overlap="rectangular" (xs~xl).
//   number는 xs 미지원(sm 폴백)이라 number 행은 sm부터.
const Cell = ({ label, children }: { label: string; children: ReactNode }) => (
  <div style={{ display: 'grid', gap: 8, justifyItems: 'center', minWidth: 72 }}>
    {children}
    <span style={{ fontSize: 11, color: '#888' }}>{label}</span>
  </div>
);

const menuItems = (
  <Menu.Item>옵션</Menu.Item>
);

const BadgeHostSizeMatrixExample = () => (
    <div style={{ display: 'grid', gap: 40, padding: 24 }}>
      {/* FloatingButton — 원형 host → circular */}
      <section style={{ display: 'grid', gap: 12 }}>
        <h4 style={{ margin: 0 }}>FloatingButton (원형 → overlap=&quot;circular&quot;)</h4>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          {(['sm', 'md'] as const).map((s) => (
            <Cell key={`fab-num-${s}`} label={`${s} · number`}>
              <Badge variant="number" size={s} overlap="circular" count={100} max={99}>
                <FloatingButton size={s} color="primary" icon={<Add />} />
              </Badge>
            </Cell>
          ))}
          {(['sm', 'md'] as const).map((s) => (
            <Cell key={`fab-dot-${s}`} label={`${s} · dot`}>
              <Badge variant="dot" size={s} overlap="circular" show>
                <FloatingButton size={s} color="primary" icon={<Add />} />
              </Badge>
            </Cell>
          ))}
        </div>
      </section>

      {/* MenuButton(IconButton 트리거) — rounded-rect host → rectangular */}
      <section style={{ display: 'grid', gap: 12 }}>
        <h4 style={{ margin: 0 }}>MenuButton / IconButton (rounded-rect → overlap=&quot;rectangular&quot;)</h4>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          {(['sm', 'md', 'lg', 'xl'] as const).map((s) => (
            <Cell key={`mb-num-${s}`} label={`${s} · number`}>
              <Badge variant="number" size={s} count={100} max={99}>
                <MenuButton
                  trigger={
                    <IconButton size={s} variant="primary" aria-label="메뉴 열기">
                      <MoreHoriz />
                    </IconButton>
                  }
                >
                  {menuItems}
                </MenuButton>
              </Badge>
            </Cell>
          ))}
        </div>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          {(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((s) => (
            <Cell key={`mb-dot-${s}`} label={`${s} · dot`}>
              <Badge variant="dot" size={s} show>
                <MenuButton
                  trigger={
                    <IconButton size={s} variant="primary" aria-label="메뉴 열기">
                      <MoreHoriz />
                    </IconButton>
                  }
                >
                  {menuItems}
                </MenuButton>
              </Badge>
            </Cell>
          ))}
        </div>
      </section>

      {/* Icon (bell, plain em) — Figma icon_with_badge 정본 케이스. 사각 bounding box → rectangular */}
      <section style={{ display: 'grid', gap: 12 }}>
        <h4 style={{ margin: 0 }}>Icon (bell, plain em → overlap=&quot;rectangular&quot;, Figma 정본)</h4>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          {numberSizes.map((s) => (
            <Cell key={`icon-num-${s}`} label={`${s} · number`}>
              <Badge variant="number" size={s} count={100} max={99}>
                <Bell />
              </Badge>
            </Cell>
          ))}
        </div>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          {sizes.map((s) => (
            <Cell key={`icon-dot-${s}`} label={`${s} · dot`}>
              <Badge variant="dot" size={s} show>
                <Bell />
              </Badge>
            </Cell>
          ))}
        </div>
      </section>

      {/* 일반 Button (text, rounded-rect) → rectangular */}
      <section style={{ display: 'grid', gap: 12 }}>
        <h4 style={{ margin: 0 }}>Button (text, rounded-rect → overlap=&quot;rectangular&quot;)</h4>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          {(['sm', 'md', 'lg'] as const).map((s) => (
            <Cell key={`btn-num-${s}`} label={`${s} · number`}>
              <Badge variant="number" size={s} count={7}>
                <Button size={s} variant="primary">
                  저장
                </Button>
              </Badge>
            </Cell>
          ))}
          <Cell label="md · dot">
            <Badge variant="dot" size="md" show>
              <Button size="md" variant="secondary">
                알림
              </Button>
            </Badge>
          </Cell>
        </div>
      </section>

      {/* TitleButton (제목 드롭다운) → rectangular */}
      <section style={{ display: 'grid', gap: 12 }}>
        <h4 style={{ margin: 0 }}>TitleButton (rounded-rect → overlap=&quot;rectangular&quot;)</h4>
        <div style={{ display: 'flex', gap: 40, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          <Cell label="number">
            <Badge variant="number" size="md" count={7}>
              <TitleButton label="2학년 1반">{menuItems}</TitleButton>
            </Badge>
          </Cell>
          <Cell label="dot">
            <Badge variant="dot" size="md" show>
              <TitleButton label="공지사항">{menuItems}</TitleButton>
            </Badge>
          </Cell>
        </div>
      </section>

      {/* 아주 큰 host(120px) — 앵커가 코너/비율 기준이라 위치는 유지되고, 배지는 size(xl) 고정이라 작게 남는다.
          사각 host → rectangular(코너), 원형 host → circular(45° 가장자리). */}
      <section style={{ display: 'grid', gap: 12 }}>
        <h4 style={{ margin: 0 }}>아주 큰 host (120px) — 위치는 코너/가장자리 유지, 배지 크기는 size 고정</h4>
        <div style={{ display: 'flex', gap: 48, alignItems: 'flex-end', flexWrap: 'wrap' }}>
          <Cell label="사각 · number · rectangular">
            <Badge variant="number" size="xl" count={100} max={99}>
              <div style={{ width: 120, height: 120, borderRadius: 16, background: '#e5e7eb' }} />
            </Badge>
          </Cell>
          <Cell label="사각 · dot · rectangular">
            <Badge variant="dot" size="xl" show>
              <div style={{ width: 120, height: 120, borderRadius: 16, background: '#e5e7eb' }} />
            </Badge>
          </Cell>
          <Cell label="원형 · number · circular">
            <Badge variant="number" size="xl" overlap="circular" count={100} max={99}>
              <div style={{ width: 120, height: 120, borderRadius: '50%', background: '#e5e7eb' }} />
            </Badge>
          </Cell>
          <Cell label="원형 · dot · circular">
            <Badge variant="dot" size="xl" overlap="circular" show>
              <div style={{ width: 120, height: 120, borderRadius: '50%', background: '#e5e7eb' }} />
            </Badge>
          </Cell>
        </div>
      </section>
    </div>
  );

export default BadgeHostSizeMatrixExample;