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

as

any

지정 안 함

bold

boolean

지정 안 함

variant

"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;