Badge
Feedback & Status
아이콘·아바타 우상단에 점이나 숫자를 얹는 배지.
Usage
아이콘·아바타 등 host 위에 알림 수·신규 상태를 작은 표식으로 덧붙일 때 호스트 없이 텍스트 옆에 인라인으로 신규 표식을 둘 때
import
import
import { Badge } from '@mildang/design-system/Badge';예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
API Reference
Badge Props
Prop
Type
Default
ReactNode
지정 안 함
string
지정 안 함
number
지정 안 함
string
지정 안 함
number
999
"rectangular" | "circular"
rectangular
boolean
지정 안 함
boolean
지정 안 함
"xs" | "sm" | "md" | "lg" | "xl"
md
"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 | 자동 아이콘 px | dot 지름 | number 지원 |
|---|---|---|---|
xs | 18 | 5px | ❌ (sm으로 폴백) |
sm | 22 | 6px | ✅ |
md(기본값) | 24 | 6px | ✅ |
lg | 28 | 8px | ✅ |
xl | 32 | 8px | ✅ |
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-rect | rectangular |
| Button / TitleButton / MenuButton | rounded-rect | rectangular |
| Icon(bell 등 glyph) | 사각 bbox | rectangular |
| Avatar(square) | 라운드 사각 | rectangular |
| 넓은 pill(너비≫높이, 완전 라운드) | 반원 arc | rectangular(정원 아님) |
같은 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="circular")</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="rectangular")</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="rectangular", 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="rectangular")</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="rectangular")</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;