Text
Foundation
일관된 타이포그래피 계층과 리듬을 적용하는 텍스트 컴포넌트.
Usage
제품 전반의 제목·본문·레이블을 정의된 타이포그래피 계층으로 표시할 때 사용한다.
import
import
import { Text } from '@mildang/design-system/Text';예제를 복사해 쓸 때 필요한 준비
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
• 외부 패키지를 따로 설치한다. DS 의 전이 의존성에 기대지 않는다.
API Reference
Text Props
Prop
Type
Default
any
지정 안 함
boolean
지정 안 함
"headline-lg" | "headline-md" | "headline-sm" | "title-2xl" | "title-xl" | "title-lg" | "title-lg-medium" | "title-md" | "title-md-medium" | "title-sm" | "title-sm-medium" | "title-xs" | "title-xs-medium" | "body-xl" | "body-lg" | "body-md" | "body-sm" | "body-xl-semibold" | "body-lg-semibold" | "body-md-semibold" | "body-sm-semibold" | "caption-lg" | "caption-lg-medium" | "caption-md" | "caption-md-medium" | "caption-sm" | "caption-sm-medium" | "contents-xl" | "contents-lg" | "contents-md" | "contents-sm" | "chat-subtitle" | "chat-body" | "chat-caption-M" | "chat-caption-R" | "chat-small-text-M" | "chat-small-text-R"
body-xl
같은 패밀리
@mildang/design-system/Text 에서 같이 내보내는 컴포넌트다.
타이포그래피와 스타일 덮어쓰기
variant 하나가 폰트·크기·행간·굵기를 한 세트로 정한다. 사용할 수 있는 이름은 body-md, title-lg 같은 사이즈 워드 계열뿐이며, 숫자 접미사(body1-R, h4)는 이 패키지에 없다.
색·정렬·여백은 Panda style prop으로 variant 위에 덮어쓴다. 컨트롤에 노출된 네 가지 외에도 코드에서는 textAlign, wordBreak, padding, bg, textStyle, sx, css 같은 Panda style prop을 사용할 수 있다.
기본 사용
variant 로 타이포 토큰을 골라 본문 한 줄을 씁니다 — 폰트 크기나 굵기를 직접 주지 않습니다.
import { default as Text } from '@mildang/design-system/Text';
const STORY_DEFAULT_ARGS = { ...({
variant: 'body-xl',
color: 'neutral.text.base',
as: 'p',
children: '다람쥐 헌 쳇바퀴에 타고파',
}), ...({}) } as import("@mildang/styled-system/recipes").TypographyVariantProps & import("react").HTMLAttributes<HTMLElement> & { as?: "s" | "p" | "div" | "span"; label?: string; };
const TextDemoExampleRender = ({ children, ...args }: import("@mildang/styled-system/recipes").TypographyVariantProps & import("react").HTMLAttributes<HTMLElement> & { as?: "s" | "p" | "div" | "span"; label?: string; }) => {
return <Text {...args}>{children}</Text>;
};
export default function TextDemoExample(props: Partial<import("@mildang/styled-system/recipes").TypographyVariantProps & import("react").HTMLAttributes<HTMLElement> & { as?: "s" | "p" | "div" | "span"; label?: string; }>) {
const mergedProps = { ...STORY_DEFAULT_ARGS, ...props } as import("@mildang/styled-system/recipes").TypographyVariantProps & import("react").HTMLAttributes<HTMLElement> & { as?: "s" | "p" | "div" | "span"; label?: string; };
return TextDemoExampleRender(mergedProps);
}
문장 안 일부 강조
문장 안 일부만 강조할 때는 전용 variant를 만들지 않고 <b>로 감싼다. 부분 굵기는 계열과 무관하게 bold(700) 하나를 사용한다.
문장 전체를 굵게 표시할 때는 <b> 대신 그 굵기를 이미 가진 -semibold 또는 -medium variant를 사용한다.
반응형 타이포그래피
신규 variant 중 md(960px) 경계에서 값이 바뀌는 것은 headline-lg, headline-md, headline-sm, title-2xl, title-xl 다섯 개다.
Panda는 mobile-first이므로 조건 없는 값은 Tablet·Mobile에, md 값은 Desktop에 적용된다.
예제
Responsive
import { default as Text } from '@mildang/design-system/Text';
import { css, cx } from '@mildang/styled-system/css';
/**
* Figma `🎨 New) IDS` 신규 타이포. **신규 화면은 여기서 고른다.**
*
* 구 `contents(old)/*` 계열은 여기 없다 — 정의 소유권이 superset(`apps/design-system`)
* 으로 넘어갔고 **이관표도 두지 않는다**(2026-08-13 결정. 값이 실제로 달라 이름만 바꿔치기하는
* 이관이 불가능하다 — 구 이름을 쓰던 화면은 superset 쪽에 그대로 남는다).
* `<Text variant>` 는 Panda 가 codegen 하는 타입 union 이라 variant 별로 `@deprecated`
* 를 붙일 수단이 없다 — superset 을 쓰는 앱에서는 구 이름이 그대로 자동완성에 뜬다.
* 여기서만 갈린다.
*
* `md`(960px) 표기가 있는 것은 Desktop 에서 값이 바뀌는 반응형이다. **2026-08-12 Figma
* 재동기화로 반응형은 `headline-*` 3 + `title-2xl` · `title-xl` 5개만 남았다** — `body-*`
* 8개가 Desktop 값으로 통일되면서 비반응형이 됐다.
*
* 그래서 `contents-*` 는 더 이상 "`body-*` 의 md 미적용 짝" 이 아니다. 이제 둘은 font-size
* 가 같고 **행간이 2px 넓은** 별개 세트다 (`contents-xl` 18/30 vs `body-xl` 18/28).
*/
const newDescription = {
'headline-lg': '헤드라인 - 대형. 32/44 → md 40/56',
'headline-md': '헤드라인 - 중형. 26/36 → md 32/44',
'headline-sm': '헤드라인 - 소형. 22/32 → md 28/40',
'title-2xl': '제목 - 2XL. 20/30 → md 24/36. 자동 줄바꿈(keep-all)',
'title-xl': '제목 - XL. 18/28 → md 20/30. 자동 줄바꿈(keep-all)',
'title-lg': '제목 - 대형. 18/28/600. 자동 줄바꿈(keep-all)',
'title-lg-medium': '제목 - 대형(중간 굵기). 18/28/500',
'title-md': '제목 - 중형. 16/26/600. 자동 줄바꿈(keep-all)',
'title-md-medium': '제목 - 중형(중간 굵기). 16/26/500',
'title-sm': '제목 - 소형. 15/24/600. 자동 줄바꿈(keep-all)',
'title-sm-medium': '제목 - 소형(중간 굵기). 15/24/500',
'title-xs': '제목 - 최소형. 14/22/600. 자동 줄바꿈(keep-all)',
'title-xs-medium': '제목 - 최소형(중간 굵기). 14/22/500',
'body-xl': '본문 - XL. 18/28/400',
'body-lg': '본문 - 대형. 16/26/400',
'body-md': '본문 - 중형. 15/24/400',
'body-sm': '본문 - 소형. 14/22/400',
'body-xl-semibold': '본문 XL 강조. 18/28/600',
'body-lg-semibold': '본문 대형 강조. 16/26/600',
'body-md-semibold': '본문 중형 강조. 15/24/600',
'body-sm-semibold': '본문 소형 강조. 14/22/600',
'caption-lg': '보조 - 대형. 13/22/400',
'caption-lg-medium': '보조 - 대형(중간 굵기). 13/22/500',
'caption-md': '보조 - 중형. 12/20/400',
'caption-md-medium': '보조 - 중형(중간 굵기). 12/20/500',
'caption-sm': '보조 - 최소형. 10/18/400',
'caption-sm-medium': '보조 - 최소형(중간 굵기). 10/18/500',
'contents-xl': '긴 글 - XL. 18/30/400. body-xl 보다 행간 2px 넓다',
'contents-lg': '긴 글 - 대형. 16/28/400. body-lg 보다 행간 2px 넓다',
'contents-md': '긴 글 - 중형. 15/26/400. body-md 보다 행간 2px 넓다',
'contents-sm': '긴 글 - 소형. 14/24/400. body-sm 보다 행간 2px 넓다',
};
/**
* 밀당 AI 채팅 전용. **정식 IDS 가 아니다** — chat 전용 Figma 시안에서
* 왔고 line-height 를 px 가 아니라 퍼센트로 잡는다(`14/142` → 19.88px). Tier 1 스케일
* (18~56) 밖인 건 값이 어긋나서가 아니라 축계가 다른 별개 시스템이기 때문이고, 그래서
* 신규 27개로 수렴시키지 못했다. 구 이름 중 **이 패키지에 정의가 남은 유일한 예외**다.
*
* 시안이 6개뿐이라 코드도 6개다. 코드 전용 7번째였던 `chat-body1-R`(15/22) 은 2026-08-12
* 에 superset(`apps/design-system`) 전용으로 옮겼다 — 쓰는 곳이 `apps/mildang-ui` 4곳뿐이다.
*/
const chatDescription = {
'chat-subtitle': '채팅에서만 사용되는 폰트입니다. 14/19.88/500',
'chat-body': '채팅에서만 사용되는 폰트입니다. 14/19.88/400',
'chat-caption-M': '채팅에서만 사용되는 폰트입니다. 13/18.46/500',
'chat-caption-R': '채팅에서만 사용되는 폰트입니다. 13/18.46/400',
'chat-small-text-M': '채팅에서만 사용되는 폰트입니다. 12/17.52/500',
'chat-small-text-R': '채팅에서만 사용되는 폰트입니다. 12/17.52/400',
};
/**
* **`md`(960px) 경계에서 값이 바뀌는 5개.** 신규 31개 중 이것뿐이고 나머지는 전 뷰포트 동일하다.
*
* Panda 는 mobile-first 라 조건 없는 값이 **Tablet·Mobile**, `md` 가 **Desktop** 이다
* (`min-width: 60rem`). 정의는 `preset/theme/text-styles.ts`.
*
* 2026-08-12 Figma 재동기화 전에는 `body-*` 8개도 반응형이었다. `Typography` 컬렉션의
* Desktop·Tablet·Mobile 3모드 값이 같아지면서 전 뷰포트가 Desktop 값으로 통일됐다.
*
* ⚠️ 반응형 이름을 recipe 안에서 쓰면 컴포넌트 높이가 960px 에서 흔들린다. DS 내부는
* Tier 1 축으로 풀어 쓰거나 비반응형 이름을 고른다 (`preset/recipes/index.ts` 헤더 참조).
*/
const responsiveTextStyles: {
variant: string;
mobile: string;
desktop: string;
note: string;
}[] = [
{ variant: 'headline-lg', mobile: '32 / 44', desktop: '40 / 56', note: '신규 세트 최대 단' },
{ variant: 'headline-md', mobile: '26 / 36', desktop: '32 / 44', note: '' },
{ variant: 'headline-sm', mobile: '22 / 32', desktop: '28 / 40', note: '' },
{
variant: 'title-2xl',
mobile: '20 / 30',
desktop: '24 / 36',
note: 'title-* 라 keep-all 포함',
},
{
variant: 'title-xl',
mobile: '18 / 28',
desktop: '20 / 30',
note: 'Desktop 값이 title-2xl 의 Tablet·Mobile 값과 같다',
},
];
export {
newDescription,
chatDescription,
responsiveTextStyles,
};
const infoTable = css({
width: '100%',
borderCollapse: 'collapse',
textAlign: 'left',
'& th': {
fontSize: '12',
lineHeight: '20',
fontWeight: 'medium',
fontFamily: 'default',
color: 'neutral.text.low',
paddingY: '8',
paddingRight: '16',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: 'neutral.border.low',
whiteSpace: 'nowrap',
},
'& td': {
verticalAlign: 'middle',
paddingY: '8',
paddingRight: '16',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: 'neutral.border.low',
},
});
/**
* 컨트롤 패널에 노출하는 props. `TextDemo` 가 아니라 **`Text` 기준**이다 — Primary 스토리가
* `<Text {...args}>` 를 그리므로 `TextDemo` 의 좁은 prop 타입을 쓰면 style prop 이 안 들어간다.
*
* 전부 optional 이다. `render: () => …` 로만 그리는 나머지 스토리가 args 를 안 넘긴다.
*/
/**
* 지금 뷰포트가 어느 쪽인지 보여주는 배지. **JS 없이 CSS 조건만으로** 갈린다 —
* 스토리 안에서 `md` 조건이 실제로 동작하는 걸 그대로 보여주는 게 목적이라 리사이즈
* 이벤트를 듣지 않는다. 두 배지 중 항상 하나만 보인다.
*/
const ViewportBadge = () => {
const badge = css({
display: 'inline-flex',
alignItems: 'center',
height: '24',
paddingX: '8',
borderRadius: '4',
textStyle: 'caption-md-medium',
});
return (
<span>
<span
className={cx(
badge,
css({
color: 'info.text.base',
background: 'info.ghostBg.base',
md: { display: 'none' },
}),
)}
>
지금: Tablet · Mobile (base)
</span>
<span
className={cx(
badge,
css({
display: 'none',
color: 'positive.text.base',
background: 'positive.ghostBg.base',
md: { display: 'inline-flex' },
}),
)}
>
지금: Desktop (md ≥ 960px)
</span>
</span>
);
};
const TextResponsiveExample = () => {
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24' })}>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8' })}>
<Text variant="headline-sm" color="info.text.base">
반응형 타이포 (md 960px)
</Text>
<ViewportBadge />
<Text variant="body-sm" color="neutral.text.low">
Panda 는 mobile-first 다 — 조건 없이 적은 값이 <b>Tablet · Mobile</b> 이고 `md` (min-width 60rem =
960px) 가 <b>Desktop</b> 이다. 신규 31개 중 아래 5개만 이 경계를 넘고 나머지는 전 뷰포트 동일하다.
캔버스 폭을 960px 앞뒤로 끌어 보면 배지와 아래 실물이 같이 바뀐다.
</Text>
</div>
<table className={infoTable}>
<thead>
<tr>
<th>이름</th>
<th>Tablet · Mobile</th>
<th>Desktop (md)</th>
<th>비고</th>
</tr>
</thead>
<tbody>
{responsiveTextStyles.map(({ variant, mobile, desktop, note }) => (
<tr key={variant}>
<td>
<Text variant="caption-md-medium">{variant}</Text>
</td>
<td>
<Text variant="caption-md" color="neutral.text.low">
{mobile}
</Text>
</td>
<td>
<Text variant="caption-md" color="neutral.text.low">
{desktop}
</Text>
</td>
<td>
<Text variant="caption-md" color="neutral.text.low">
{note}
</Text>
</td>
</tr>
))}
</tbody>
</table>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '16' })}>
<Text variant="title-md">실물</Text>
{responsiveTextStyles.map(({ variant, mobile, desktop }) => (
<div
key={variant}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '4',
paddingY: '12',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: 'neutral.border.low',
})}
>
<Text variant="caption-md" color="neutral.text.lowest">
{variant} — {mobile} → md {desktop}
</Text>
<Text variant={variant as any}>다람쥐 헌 쳇바퀴에 타고파</Text>
</div>
))}
</div>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8' })}>
<Text variant="title-md">비반응형 — 나머지 26개</Text>
<Text variant="body-sm" color="neutral.text.low">
`title-lg` 이하 · `body-*` 8개 · `caption-*` 6개 · `contents-*` 4개는 전 뷰포트에서 값이 같다.
`body-*` 는 2026-08-12 Figma 재동기화로 3모드 값이 통일되면서 비반응형이 됐다 — 그전에는 Tablet ·
Mobile 에서 행간이 2px 좁았다.
</Text>
<div className={css({ display: 'flex', gap: '24', flexWrap: 'wrap' })}>
<div>
<Text variant="caption-md" color="neutral.text.lowest">
body-xl (18/28, 고정)
</Text>
<Text variant="body-xl">다람쥐 헌 쳇바퀴에 타고파</Text>
</div>
<div>
<Text variant="caption-md" color="neutral.text.lowest">
contents-xl (18/30, 고정)
</Text>
<Text variant="contents-xl">다람쥐 헌 쳇바퀴에 타고파</Text>
</div>
</div>
</div>
<Text variant="caption-lg" color="neutral.text.lowest">
⚠️ DS 내부(`preset/recipes/*.ts`)에서는 반응형 이름을 쓰지 않는다 — 컴포넌트 높이가 960px 에서
흔들린다. Tier 1 축으로 풀어 쓰거나 비반응형 이름을 고른다.
</Text>
</div>
);
};
export default TextResponsiveExample;
Emphasis
import { default as Text } from '@mildang/design-system/Text';
import type { ReactNode } from 'react';
import { css, cx } from '@mildang/styled-system/css';
/**
* 코드와 렌더 결과를 한 카드 안에 나란히 놓는다.
*
* 스토리북 기본 `Show code` 는 접혀 있는 데다 render 함수의 레이아웃 래퍼까지 통째로
* 토해내서, 정작 봐야 할 두세 줄이 묻힌다. 여기서는 **보여줄 코드만** `code` 로 직접
* 적는다 — children 과 이중으로 적는 대가로, 클릭 없이 코드·결과가 한눈에 들어온다.
*/
export interface CodeExampleProps {
/** 카드 제목. 이 예시가 어떤 자리를 위한 건지 한 줄로. */
title: string;
/** 화면에 그대로 박히는 코드. children 과 의미가 같아야 한다. */
code: string;
/** 제목 아래 한 줄 보충. 없으면 생략된다. */
note?: string;
/** 실제 렌더 결과. */
children: ReactNode;
}
const card = css({
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.low',
borderRadius: '8',
overflow: 'hidden',
});
const head = css({
display: 'flex',
flexDirection: 'column',
gap: '2',
paddingX: '16',
paddingY: '12',
backgroundColor: 'neutral.surface.high',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: 'neutral.border.low',
});
const title = css({
fontFamily: 'default',
fontSize: '14',
lineHeight: '22',
fontWeight: 'semibold',
color: 'neutral.text.base',
});
const noteText = css({
fontFamily: 'default',
fontSize: '12',
lineHeight: '20',
fontWeight: 'regular',
color: 'neutral.text.low',
});
/** md(960px) 아래에서는 코드가 위, 결과가 아래로 쌓인다. */
const body = css({
display: 'grid',
gridTemplateColumns: '1fr',
md: { gridTemplateColumns: '1fr 1fr' },
});
const pane = css({
display: 'flex',
flexDirection: 'column',
gap: '8',
padding: '16',
// grid 칸 안에서 `overflow-x: auto` 가 먹으려면 min-width 를 풀어 줘야 한다.
minWidth: '0',
});
const paneLabel = css({
fontFamily: 'default',
fontSize: '10',
lineHeight: '18',
fontWeight: 'medium',
color: 'neutral.text.lowest',
});
const codeBlock = css({
margin: '0',
/**
* `fonts.mono` 토큰은 이 프리셋에 없다 — 참조하면 var() 가 안 풀려 선언이 통째로
* 무효가 되고 스토리북 전역 `pre` 스타일이 대신 먹는다. 스토리북 전용 조각이라
* raw 스택으로 직접 적는다.
*
* Pretendard 를 generic `monospace` **앞**에 둔다. 뒤에 두면 한글이 시스템 기본
* 모노스페이스(바탕 계열)로 떨어져 코드 예시의 한글만 튄다. 라틴 문자는 Menlo 에서
* 먼저 잡히므로 고정폭이 유지된다.
*/
fontFamily:
'[ui-monospace, SFMono-Regular, Menlo, Consolas, var(--font-pretendard-variable, Pretendard), monospace]',
fontSize: '12',
lineHeight: '20',
color: 'neutral.text.base',
whiteSpace: 'pre',
overflowX: 'auto',
});
/** 코드 칸과 결과 칸 사이 경계. 세로로 쌓일 때는 위쪽 선이 된다. */
const resultPane = css({
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'neutral.border.low',
md: {
borderTopWidth: '0',
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
},
});
export const CodeExample = ({ title: titleText, code, note, children }: CodeExampleProps) => (
<div className={card}>
<div className={head}>
<span className={title}>{titleText}</span>
{note ? <span className={noteText}>{note}</span> : null}
</div>
<div className={body}>
<div className={pane}>
<span className={paneLabel}>코드</span>
{/* `<pre>` 를 쓰지 않는다 — 스토리북이 `pre`·`code` 에 **레이어 없는** 전역 스타일을
넣어 두는데, 레이어 없는 CSS 는 어떤 @layer 보다 세서 Panda 클래스가 진다. */}
<div className={codeBlock}>{code}</div>
</div>
<div className={cx(pane, resultPane)}>
<span className={paneLabel}>결과</span>
{children}
</div>
</div>
</div>
);
/**
* 컨트롤 패널에 노출하는 props. `TextDemo` 가 아니라 **`Text` 기준**이다 — Primary 스토리가
* `<Text {...args}>` 를 그리므로 `TextDemo` 의 좁은 prop 타입을 쓰면 style prop 이 안 들어간다.
*
* 전부 optional 이다. `render: () => …` 로만 그리는 나머지 스토리가 args 를 안 넘긴다.
*/
const TextEmphasisExample = () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '16' })}>
<CodeExample
title="body-* 안의 <b>"
note="bold(700)"
code={'<Text variant="body-md">\n 수업은 <b>매주 화요일 오후 7시</b>에 진행됩니다.\n</Text>'}
>
<Text variant="body-md">
수업은 <b>매주 화요일 오후 7시</b>에 진행됩니다.
</Text>
</CodeExample>
<CodeExample
title="caption-* 안의 <b>"
note="bold(700) — 계열이 달라도 같다"
code={'<Text variant="caption-md">\n 정원 <b>8명</b> 중 3명 남음\n</Text>'}
>
<Text variant="caption-md">
정원 <b>8명</b> 중 3명 남음
</Text>
</CodeExample>
<CodeExample
title="문장 전체가 굵을 때는 <b> 가 아니다"
note="그 굵기를 가진 variant 를 고른다 — <b> 는 '일부만' 강조하는 표시다"
code={'<Text variant="body-md-semibold">학생 목록</Text>'}
>
<Text variant="body-md-semibold">학생 목록</Text>
</CodeExample>
</div>
);
export default TextEmphasisExample;
StyleProps
import { default as Text } from '@mildang/design-system/Text';
import type { ReactNode } from 'react';
import { css, cx } from '@mildang/styled-system/css';
/**
* 코드와 렌더 결과를 한 카드 안에 나란히 놓는다.
*
* 스토리북 기본 `Show code` 는 접혀 있는 데다 render 함수의 레이아웃 래퍼까지 통째로
* 토해내서, 정작 봐야 할 두세 줄이 묻힌다. 여기서는 **보여줄 코드만** `code` 로 직접
* 적는다 — children 과 이중으로 적는 대가로, 클릭 없이 코드·결과가 한눈에 들어온다.
*/
export interface CodeExampleProps {
/** 카드 제목. 이 예시가 어떤 자리를 위한 건지 한 줄로. */
title: string;
/** 화면에 그대로 박히는 코드. children 과 의미가 같아야 한다. */
code: string;
/** 제목 아래 한 줄 보충. 없으면 생략된다. */
note?: string;
/** 실제 렌더 결과. */
children: ReactNode;
}
const card = css({
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'neutral.border.low',
borderRadius: '8',
overflow: 'hidden',
});
const head = css({
display: 'flex',
flexDirection: 'column',
gap: '2',
paddingX: '16',
paddingY: '12',
backgroundColor: 'neutral.surface.high',
borderBottomWidth: '1px',
borderBottomStyle: 'solid',
borderBottomColor: 'neutral.border.low',
});
const title = css({
fontFamily: 'default',
fontSize: '14',
lineHeight: '22',
fontWeight: 'semibold',
color: 'neutral.text.base',
});
const noteText = css({
fontFamily: 'default',
fontSize: '12',
lineHeight: '20',
fontWeight: 'regular',
color: 'neutral.text.low',
});
/** md(960px) 아래에서는 코드가 위, 결과가 아래로 쌓인다. */
const body = css({
display: 'grid',
gridTemplateColumns: '1fr',
md: { gridTemplateColumns: '1fr 1fr' },
});
const pane = css({
display: 'flex',
flexDirection: 'column',
gap: '8',
padding: '16',
// grid 칸 안에서 `overflow-x: auto` 가 먹으려면 min-width 를 풀어 줘야 한다.
minWidth: '0',
});
const paneLabel = css({
fontFamily: 'default',
fontSize: '10',
lineHeight: '18',
fontWeight: 'medium',
color: 'neutral.text.lowest',
});
const codeBlock = css({
margin: '0',
/**
* `fonts.mono` 토큰은 이 프리셋에 없다 — 참조하면 var() 가 안 풀려 선언이 통째로
* 무효가 되고 스토리북 전역 `pre` 스타일이 대신 먹는다. 스토리북 전용 조각이라
* raw 스택으로 직접 적는다.
*
* Pretendard 를 generic `monospace` **앞**에 둔다. 뒤에 두면 한글이 시스템 기본
* 모노스페이스(바탕 계열)로 떨어져 코드 예시의 한글만 튄다. 라틴 문자는 Menlo 에서
* 먼저 잡히므로 고정폭이 유지된다.
*/
fontFamily:
'[ui-monospace, SFMono-Regular, Menlo, Consolas, var(--font-pretendard-variable, Pretendard), monospace]',
fontSize: '12',
lineHeight: '20',
color: 'neutral.text.base',
whiteSpace: 'pre',
overflowX: 'auto',
});
/** 코드 칸과 결과 칸 사이 경계. 세로로 쌓일 때는 위쪽 선이 된다. */
const resultPane = css({
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'neutral.border.low',
md: {
borderTopWidth: '0',
borderLeftWidth: '1px',
borderLeftStyle: 'solid',
borderLeftColor: 'neutral.border.low',
},
});
export const CodeExample = ({ title: titleText, code, note, children }: CodeExampleProps) => (
<div className={card}>
<div className={head}>
<span className={title}>{titleText}</span>
{note ? <span className={noteText}>{note}</span> : null}
</div>
<div className={body}>
<div className={pane}>
<span className={paneLabel}>코드</span>
{/* `<pre>` 를 쓰지 않는다 — 스토리북이 `pre`·`code` 에 **레이어 없는** 전역 스타일을
넣어 두는데, 레이어 없는 CSS 는 어떤 @layer 보다 세서 Panda 클래스가 진다. */}
<div className={codeBlock}>{code}</div>
</div>
<div className={cx(pane, resultPane)}>
<span className={paneLabel}>결과</span>
{children}
</div>
</div>
</div>
);
/**
* 컨트롤 패널에 노출하는 props. `TextDemo` 가 아니라 **`Text` 기준**이다 — Primary 스토리가
* `<Text {...args}>` 를 그리므로 `TextDemo` 의 좁은 prop 타입을 쓰면 style prop 이 안 들어간다.
*
* 전부 optional 이다. `render: () => …` 로만 그리는 나머지 스토리가 args 를 안 넘긴다.
*/
const TextStylePropsExample = () => (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '16' })}>
<CodeExample
title="색은 role 토큰으로"
note="글자색은 {role}.text.{level} 만 쓴다 — surface·border·fill 계열은 글자색이 아니다"
code={
'<Text variant="headline-md" color="critical.text.base">긴급 공지</Text>\n' +
'<Text variant="body-md" color="neutral.text.low">보조 설명</Text>'
}
>
<Text variant="headline-md" color="critical.text.base">
긴급 공지
</Text>
<Text variant="body-md" color="neutral.text.low">
보조 설명
</Text>
</CodeExample>
<CodeExample
title="variant 위에 덮어쓰기"
note="제목 13개는 wordBreak: keep-all 이 기본이라 normal 로 되돌릴 수 있다 — 다만 이건 브라우저 기본(음절 아무 데서나 끊음)이지 한 줄 고정이 아니다. 한 줄 고정은 whiteSpace='nowrap'"
code={
'<Text\n' +
' variant="title-md"\n' +
' as="h3"\n' +
' wordBreak="normal"\n' +
' textAlign="center"\n' +
'>\n' +
' 이번 주 학습 리포트\n' +
'</Text>'
}
>
<Text variant="title-md" as="h3" wordBreak="normal" textAlign="center">
이번 주 학습 리포트
</Text>
</CodeExample>
<CodeExample
title="as — 시각은 그대로, 의미만 바꾼다"
note="둘 다 title-lg 로 똑같이 보이지만 하나는 h2, 하나는 span 으로 나간다"
code={
'<Text variant="title-lg" as="h2">문서 제목 (h2)</Text>\n<Text variant="title-lg" as="span">같은 모양의 인라인 (span)</Text>'
}
>
<Text variant="title-lg" as="h2">
문서 제목 (h2)
</Text>
<Text variant="title-lg" as="span">
같은 모양의 인라인 (span)
</Text>
</CodeExample>
</div>
);
export default TextStylePropsExample;
WordBreakWidths
import { default as Text } from '@mildang/design-system/Text';
import { css } from '@mildang/styled-system/css';
import { ReactNode } from 'react';
/* ────────────────────────────────────────────────────────────────────────────
* 줄바꿈
*
* "제목이 어절째 안 넘어가고 한 글자씩 쪼개진다" 는 증상은 원인이 둘인데 화면상 구분이
* 안 된다.
*
* 1. **`keep-all` 이 애초에 안 닿았다** — 빌드에 안 실렸거나 배포가 낡았다.
* `word-break` 실측값이 `normal` 로 나온다. 한글은 `normal` 이 음절 아무 데서나
* 끊는 쪽(UAX #14)이라 그대로 한 글자씩 쪼개진다.
* 2. **`keep-all` 은 닿았는데 `overflow-wrap: break-word` 가 먼저 터졌다** — 어절 하나가
* 제 줄에 통째로 안 들어가면 `break-word` 가 음절 중간을 끊는다. → `WordBreakWidths`
*
* ①은 폭을 넓혀도 안 돌아오고 ②는 돌아온다. ① 판별은 아래 스토리의 play 가
* `getComputedStyle` 로 잠가 둔다 — 소스 가드레일(`new-text-styles.test.ts`)은 빌드된 CSS 가
* 브라우저까지 도달했는지는 못 보기 때문이다.
*
* ## `overflow-wrap` 은 제목 전용이 아니다
* Panda `reset` 레이어에 `p, h1~h6 { overflow-wrap: break-word }` 가 이미 있다.
* `Text` 는 기본 태그가 `p` 라 **모든 variant 가 이 값을 물고 있다** — 제목 13개의
* textStyle 에 실린 `overflowWrap` 은 `as="span"`·`as="div"` 로 뽑을 때만 추가로 일한다.
*
* ## 되돌리려면
* 실제로 건드릴 일은 거의 없지만 값별로 결과가 다르다. 완전 원복은 **둘 다** 껴야 하고
* (`wordBreak="normal" overflowWrap="normal"`), `wordBreak="normal"` 하나만 주면 브라우저
* 기본이라 오히려 음절 아무 데서나 끊긴다. **한 줄 고정은 `whiteSpace="nowrap"` 뿐이다.**
* ──────────────────────────────────────────────────────────────────────────── */
const WRAP_SAMPLE = '밀당영어는 AI 기술을 기반한 학습시스템';
/** 좁은 쪽부터 본다. 어느 폭에서 안전장치가 터지는지 보려는 것이라 촘촘하게 잡았다. */
const WRAP_WIDTHS = [480, 320, 240, 160, 120];
/** 폰트가 클수록 더 넓은 폭에서 터지므로 큰 쪽부터 본다. */
const WRAP_VARIANTS = ['headline-lg', 'title-2xl', 'title-md'] as const;
const wrapSection = css({ display: 'flex', flexDirection: 'column', gap: '16' });
/** 줄바꿈 예제의 점선 칸. 레이아웃만 담당하고 style prop 은 안 받는다. */
function WrapCell({ label, width, children }: { label: string; width: number; children: ReactNode }) {
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '4' })}>
<Text variant="caption-md-medium">{label}</Text>
<div
style={{ width }}
className={css({
borderWidth: '1px',
borderStyle: 'dashed',
borderColor: 'neutral.border.low',
padding: '8',
})}
>
{children}
</div>
</div>
);
}
const TextWordBreakWidthsExample = () => (
<div className={wrapSection}>
<Text variant="title-md">폭별 — 어디서부터 쪼개지나</Text>
<Text variant="body-sm" color="neutral.text.low">
폰트가 클수록 더 넓은 폭에서 이미 끊긴다. <b>headline-lg</b> 는 데스크톱에서 40px 라 어절 하나가 200px
를 먹는다 — 240px 칸에서도 이미 안전장치가 터진다.
</Text>
{WRAP_VARIANTS.map((variant) => (
<div key={variant} className={css({ display: 'flex', flexDirection: 'column', gap: '8' })}>
<Text variant="caption-md-medium" color="neutral.text.lowest">
{variant}
</Text>
<div className={css({ display: 'flex', gap: '16', flexWrap: 'wrap' })}>
{WRAP_WIDTHS.map((width) => (
<WrapCell key={width} label={`${width}px`} width={width}>
{/* play 가 variant 당 첫 칸만 실측한다 — `querySelector` 는 첫 매치를 준다 */}
<Text variant={variant} data-wrap={variant}>
{WRAP_SAMPLE}
</Text>
</WrapCell>
))}
</div>
</div>
))}
</div>
);
export default TextWordBreakWidthsExample;
AllDemo
코드
import type React from 'react';
import type { TextStyle } from '@pandacss/types';
import { css } from '@mildang/styled-system/css';
import { typography, type TypographyVariantProps } from '@mildang/styled-system/recipes';
import { flex } from '@mildang/styled-system/patterns';
import { token } from '@mildang/styled-system/tokens';
import Text from '@mildang/design-system/Text';
import { createPreset } from '@mildang/design-system/mildang-preset';
/**
* Figma `🎨 New) IDS` 신규 타이포. **신규 화면은 여기서 고른다.**
*
* 구 `contents(old)/*` 계열은 여기 없다 — 정의 소유권이 superset(`apps/design-system`)
* 으로 넘어갔고 **이관표도 두지 않는다**(2026-08-13 결정. 값이 실제로 달라 이름만 바꿔치기하는
* 이관이 불가능하다 — 구 이름을 쓰던 화면은 superset 쪽에 그대로 남는다).
* `<Text variant>` 는 Panda 가 codegen 하는 타입 union 이라 variant 별로 `@deprecated`
* 를 붙일 수단이 없다 — superset 을 쓰는 앱에서는 구 이름이 그대로 자동완성에 뜬다.
* 여기서만 갈린다.
*
* `md`(960px) 표기가 있는 것은 Desktop 에서 값이 바뀌는 반응형이다. **2026-08-12 Figma
* 재동기화로 반응형은 `headline-*` 3 + `title-2xl` · `title-xl` 5개만 남았다** — `body-*`
* 8개가 Desktop 값으로 통일되면서 비반응형이 됐다.
*
* 그래서 `contents-*` 는 더 이상 "`body-*` 의 md 미적용 짝" 이 아니다. 이제 둘은 font-size
* 가 같고 **행간이 2px 넓은** 별개 세트다 (`contents-xl` 18/30 vs `body-xl` 18/28).
*/
const newDescription = {
'headline-lg': '헤드라인 - 대형. 32/44 → md 40/56',
'headline-md': '헤드라인 - 중형. 26/36 → md 32/44',
'headline-sm': '헤드라인 - 소형. 22/32 → md 28/40',
'title-2xl': '제목 - 2XL. 20/30 → md 24/36. 자동 줄바꿈(keep-all)',
'title-xl': '제목 - XL. 18/28 → md 20/30. 자동 줄바꿈(keep-all)',
'title-lg': '제목 - 대형. 18/28/600. 자동 줄바꿈(keep-all)',
'title-lg-medium': '제목 - 대형(중간 굵기). 18/28/500',
'title-md': '제목 - 중형. 16/26/600. 자동 줄바꿈(keep-all)',
'title-md-medium': '제목 - 중형(중간 굵기). 16/26/500',
'title-sm': '제목 - 소형. 15/24/600. 자동 줄바꿈(keep-all)',
'title-sm-medium': '제목 - 소형(중간 굵기). 15/24/500',
'title-xs': '제목 - 최소형. 14/22/600. 자동 줄바꿈(keep-all)',
'title-xs-medium': '제목 - 최소형(중간 굵기). 14/22/500',
'body-xl': '본문 - XL. 18/28/400',
'body-lg': '본문 - 대형. 16/26/400',
'body-md': '본문 - 중형. 15/24/400',
'body-sm': '본문 - 소형. 14/22/400',
'body-xl-semibold': '본문 XL 강조. 18/28/600',
'body-lg-semibold': '본문 대형 강조. 16/26/600',
'body-md-semibold': '본문 중형 강조. 15/24/600',
'body-sm-semibold': '본문 소형 강조. 14/22/600',
'caption-lg': '보조 - 대형. 13/22/400',
'caption-lg-medium': '보조 - 대형(중간 굵기). 13/22/500',
'caption-md': '보조 - 중형. 12/20/400',
'caption-md-medium': '보조 - 중형(중간 굵기). 12/20/500',
'caption-sm': '보조 - 최소형. 10/18/400',
'caption-sm-medium': '보조 - 최소형(중간 굵기). 10/18/500',
'contents-xl': '긴 글 - XL. 18/30/400. body-xl 보다 행간 2px 넓다',
'contents-lg': '긴 글 - 대형. 16/28/400. body-lg 보다 행간 2px 넓다',
'contents-md': '긴 글 - 중형. 15/26/400. body-md 보다 행간 2px 넓다',
'contents-sm': '긴 글 - 소형. 14/24/400. body-sm 보다 행간 2px 넓다',
};
/**
* 밀당 AI 채팅 전용. **정식 IDS 가 아니다** — chat 전용 Figma 시안에서
* 왔고 line-height 를 px 가 아니라 퍼센트로 잡는다(`14/142` → 19.88px). Tier 1 스케일
* (18~56) 밖인 건 값이 어긋나서가 아니라 축계가 다른 별개 시스템이기 때문이고, 그래서
* 신규 27개로 수렴시키지 못했다. 구 이름 중 **이 패키지에 정의가 남은 유일한 예외**다.
*
* 시안이 6개뿐이라 코드도 6개다. 코드 전용 7번째였던 `chat-body1-R`(15/22) 은 2026-08-12
* 에 superset(`apps/design-system`) 전용으로 옮겼다 — 쓰는 곳이 `apps/mildang-ui` 4곳뿐이다.
*/
const chatDescription = {
'chat-subtitle': '채팅에서만 사용되는 폰트입니다. 14/19.88/500',
'chat-body': '채팅에서만 사용되는 폰트입니다. 14/19.88/400',
'chat-caption-M': '채팅에서만 사용되는 폰트입니다. 13/18.46/500',
'chat-caption-R': '채팅에서만 사용되는 폰트입니다. 13/18.46/400',
'chat-small-text-M': '채팅에서만 사용되는 폰트입니다. 12/17.52/500',
'chat-small-text-R': '채팅에서만 사용되는 폰트입니다. 12/17.52/400',
};
/**
* **`md`(960px) 경계에서 값이 바뀌는 5개.** 신규 31개 중 이것뿐이고 나머지는 전 뷰포트 동일하다.
*
* Panda 는 mobile-first 라 조건 없는 값이 **Tablet·Mobile**, `md` 가 **Desktop** 이다
* (`min-width: 60rem`). 정의는 `preset/theme/text-styles.ts`.
*
* 2026-08-12 Figma 재동기화 전에는 `body-*` 8개도 반응형이었다. `Typography` 컬렉션의
* Desktop·Tablet·Mobile 3모드 값이 같아지면서 전 뷰포트가 Desktop 값으로 통일됐다.
*
* ⚠️ 반응형 이름을 recipe 안에서 쓰면 컴포넌트 높이가 960px 에서 흔들린다. DS 내부는
* Tier 1 축으로 풀어 쓰거나 비반응형 이름을 고른다 (`preset/recipes/index.ts` 헤더 참조).
*/
const responsiveTextStyles: {
variant: string;
mobile: string;
desktop: string;
note: string;
}[] = [
{ variant: 'headline-lg', mobile: '32 / 44', desktop: '40 / 56', note: '신규 세트 최대 단' },
{ variant: 'headline-md', mobile: '26 / 36', desktop: '32 / 44', note: '' },
{ variant: 'headline-sm', mobile: '22 / 32', desktop: '28 / 40', note: '' },
{
variant: 'title-2xl',
mobile: '20 / 30',
desktop: '24 / 36',
note: 'title-* 라 keep-all 포함',
},
{
variant: 'title-xl',
mobile: '18 / 28',
desktop: '20 / 30',
note: 'Desktop 값이 title-2xl 의 Tablet·Mobile 값과 같다',
},
];
export {
newDescription,
chatDescription,
responsiveTextStyles,
};
const TextDemo = (() => {
const textStyles = (createPreset() as any).theme.extend.textStyles as Record<
string,
{ value: TextStyle }
>;
type TypographyHTMLProps = React.HTMLAttributes<HTMLElement>;
type TypographyElement = 'div' | 'p' | 'span' | 's';
type TextDemoProps = TypographyVariantProps &
TypographyHTMLProps & { as?: TypographyElement; label?: string };
function TextDemo({ label = '밀당영어는 AI 기술을 기반한 학습시스템', ...props }: TextDemoProps) {
const [variantProps, localProps] = typography.splitVariantProps(props);
const { as: Component = 'p' } = localProps;
const { variant } = variantProps;
const textStyle = Object.entries(textStyles).filter(([i]) => i === variant)[0]?.[1];
const remToPx = (value: string) => {
const remSize = parseFloat(value);
const pxSize = remSize * parseFloat(getComputedStyle(document.documentElement).fontSize || '16');
return Math.round(pxSize) + 'px';
};
const { fontWeight, lineHeight, fontSize } = textStyle?.value as TextStyle;
const safeFontWeight = fontWeight ?? 400;
const safeLineHeight = lineHeight ?? '1.5';
const safeFontSize = fontSize ?? '16';
const lineHeightPx = remToPx(String(safeLineHeight));
const variantString = variant?.toString();
const fontSizeToken = token(`fontSizes.${String(safeFontSize)}` as any);
return (
<div className={css({ mb: '[32px]' })}>
<div className={flex({ gap: '[12px]', align: 'center' })}>
<div className={css({ width: '[150px]' })}>{variantString}</div>
<div className={flex({ direction: 'column', width: '[200px]' })}>
<div>
{String(fontSizeToken)} / {lineHeightPx}
</div>
<div className={css({ color: 'mui.text.primary' })}>{String(safeFontWeight)}</div>
</div>
<div>
<Text variant={variant} as={Component}>
{label}
</Text>
</div>
</div>
</div>
);
}
return TextDemo;
})();
const TextAllDemoExample = () => {
return (
<div>
<Text variant="headline-lg" color="info.text.base">
신규 TEXT (New IDS)
</Text>
{Object.keys(newDescription).map((key) => (
<TextDemo
key={key}
variant={key as any}
label={newDescription[key as keyof typeof newDescription]}
/>
))}
<Text variant="headline-lg" color="info.text.base">
채팅 TEXT
</Text>
<Text variant="body-sm" color="neutral.text.low">
정식 IDS 가 아니라 chat 전용 시안에서 온 6개다. line-height 를 퍼센트로 잡아 축계가 달라 신규 세트로
수렴시키지 못했고, 구 이름 중 이 패키지에 정의가 남은 유일한 예외다.
</Text>
{Object.keys(chatDescription).map((key) => (
<TextDemo
key={key}
variant={key as any}
label={chatDescription[key as keyof typeof chatDescription]}
/>
))}
</div>
);
};
export default TextAllDemoExample;