SidePanel

Layout

본문과 공간을 나눠 쓰는 측면 패널.

Usage

본문과 공간을 나눠 갖는 레이아웃 표면(목록+상세, 편집 사이드바). 본문을 덮는 임시 표면은 Drawer

import

import

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

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

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

API Reference

SidePanel Props

Prop

Type

Default

divider

"line" | "gap"

line

폭과 반응형 전환

패널 폭은 Panel에서 정한다. size는 sm(360px)·md(720px) 스케일이고, width는 px 단위 custom 폭이다.

split과 Drawer의 전환점은 패널 폭에 본문 최소폭 600px을 더한 값이다. 좁아지면 패널은 Drawer로 바뀌고, 더 좁아지면 Drawer 규칙에 따라 full page가 된다.

drawerAt은 split 전환점을 덮어쓰며 본문 600px 하한도 뚫을 수 있다. fullPageAt은 custom width에서만 사용할 수 있고 패널 폭보다 낮아지지 않는다.

두 패널이 있으면 각 패널의 상태·폭·전환점은 독립적이며, Drawer 구간에서는 한 Root에 하나만 열린다. 전환은 렌더 트리 교체이므로 포커스와 패널 스크롤 위치가 초기화될 수 있다.

기본 사용

본문과 공간을 나눠 갖는 레이아웃형 패널이다. 겹쳐 뜨는 표면이 필요하면 Drawer, 화면이 좁아지면 자동으로 Drawer 로 전환된다.

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(위 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelDefaultExample() {
  return (
    <SidePanel className={shellStyle}>
      <SidePanel.Main>
        <MainContent label="패널 열기" panel={DEFAULT_PANEL_READOUT} />
      </SidePanel.Main>
      <SidePanel.Panel>
        <PanelContent title="Side Panel" />
      </SidePanel.Panel>
    </SidePanel>
  );
}

열고 닫기와 접근성

Trigger로 토글하고 Close로 닫는다. 패널이 둘이면 Trigger에 anchor를 지정한다. split에서는 패널이 본문 옆 영역이고, Drawer로 전환되면 modal dialog가 된다.

Drawer 접근 가능한 이름을 위해 Title은 항상 둔다. Description은 보조 설명으로 사용한다. Header, Body, Footer는 패널 내부 슬롯이며 Body만 스크롤된다.

닫힌 패널은 visibility가 숨겨져 탭 순서와 스크린리더에서 제외된다. split에서 닫으면 포커스가 Trigger로 돌아가고, Drawer에서는 dialog 동작이 포커스를 복원한다.

기본 사용

본문과 공간을 나눠 갖는 레이아웃형 패널이다. 겹쳐 뜨는 표면이 필요하면 Drawer, 화면이 좁아지면 자동으로 Drawer 로 전환된다.

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(위 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelDefaultExample() {
  return (
    <SidePanel className={shellStyle}>
      <SidePanel.Main>
        <MainContent label="패널 열기" panel={DEFAULT_PANEL_READOUT} />
      </SidePanel.Main>
      <SidePanel.Panel>
        <PanelContent title="Side Panel" />
      </SidePanel.Panel>
    </SidePanel>
  );
}

사용 가이드

권장

  • 본문과 패널을 계속 함께 다뤄야 하는 화면에서 사용한다.
  • 부모 높이를 확정하고 flex: 1; min-height: 0 체인을 구성한다.
  • Drawer 전환 시에도 필요한 폼 상태는 패널 바깥에서 관리한다.

지양

  • 집중이 필요한 단일 작업이나 잠깐 보는 상세에는 사용하지 않는다. Drawer 또는 Dialog를 사용한다.
  • 상시 열려 있고 여닫지 않는 내비게이션 영역을 SidePanel로 만들지 않는다.
  • controlled 패널에서 시스템의 onOpenChange(false)를 무시하지 않는다.

예제

구분 방식

divider 로 본문과 패널을 나누는 두 방식을 비교한다. line 은 맞닿는 면에 선을 긋고, gap 은 16px 여백과 양쪽 radius 를 만든다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(위 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

/** `gap` 용 셸 — 페이지 패딩과 회색 면만 준다. 면 2개와 그 사이 16px 은 컴포넌트가 만든다. */
const cardPageShellStyle = css({
  height: '[460px]',
  padding: '24',
  backgroundColor: 'neutral.surface.high',
});

export default function SidePanelDividerExample() {
  return (
    <Flex direction="column" gap="32">
      <VStack alignItems="stretch" gap="8">
        <Text variant="title-sm">line — 맞붙고 선 하나로 갈린다</Text>
        <SidePanel className={shellStyle}>
          <SidePanel.Main>
            <MainContent label="패널 닫기" panel={DEFAULT_PANEL_READOUT} />
          </SidePanel.Main>
          <SidePanel.Panel defaultOpen>
            <PanelContent title="line" />
          </SidePanel.Panel>
        </SidePanel>
      </VStack>

      <VStack alignItems="stretch" gap="8">
        <Text variant="title-sm">gap — 16px 여백으로 갈린다</Text>
        {/* 셸이 주는 건 페이지 패딩·배경뿐. 면과 그 사이 여백은 컴포넌트가 만든다. */}
        <SidePanel divider="gap" className={cardPageShellStyle}>
          <SidePanel.Main>
            <MainContent label="패널 닫기" panel={DEFAULT_PANEL_READOUT} />
          </SidePanel.Main>
          <SidePanel.Panel defaultOpen>
            <PanelContent title="gap" />
          </SidePanel.Panel>
        </SidePanel>
      </VStack>
    </Flex>
  );
}

패널 위치

anchor 로 패널의 시각 순서를 뒤집는다. DOM 순서는 항상 본문 → 패널이다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelAnchorExample() {
  return (
    <Flex direction="column" gap="24">
      {(['right', 'left'] as const).map((anchor) => (
        <SidePanel key={anchor} className={shellStyle}>
          <SidePanel.Main>
            <MainContent label={`anchor="${anchor}"`} panel={DEFAULT_PANEL_READOUT} />
          </SidePanel.Main>
          <SidePanel.Panel anchor={anchor} defaultOpen>
            <PanelContent title={anchor} />
          </SidePanel.Panel>
        </SidePanel>
      ))}
    </Flex>
  );
}

양쪽 패널

한 Root 안에 Panel 을 두 개 두고 각각 anchor 를 줘 좌우 양쪽에 패널을 배치하는 예시다. 열림 상태와 전환점은 패널마다 독립적이다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelAnchorBothExample() {
  // 한쪽만 controlled 로 두고 다른 쪽은 uncontrolled 로 남겨, 둘이 섞여도 되는 걸 같이 보여준다.
  const [leftOpen, setLeftOpen] = React.useState(true);

  return (
    <SidePanel className={shellStyle}>
      <SidePanel.Panel anchor="left" open={leftOpen} onOpenChange={setLeftOpen}>
        <PanelContent title="left" />
      </SidePanel.Panel>

      <SidePanel.Main>
        <MainContent
          anchors={['left', 'right']}
          panels={{ left: { panelWidth: 360, drawerAt: 1320 }, right: DEFAULT_PANEL_READOUT }}
        />
      </SidePanel.Main>

      <SidePanel.Panel anchor="right" defaultOpen>
        <PanelContent title="right" />
      </SidePanel.Panel>
    </SidePanel>
  );
}

크기

sm(360px)·md(720px) 두 스케일의 전환점 차이를 나란히 비교한다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelSizesExample() {
  return (
    <Flex direction="column" gap="24">
      {/* `md` 는 1320px 미만에서 Drawer 라, 기본으로 열어 두면 좁은 화면에서 위 예시까지 덮는다. */}
      {(
        [
          { size: 'sm', caption: 'size="sm" — 360px 패널 · 960px / 600px' },
          { size: 'md', caption: 'size="md" — 720px 패널 · 1320px / 720px' },
        ] as const
      ).map(({ size, caption }) => (
        <VStack key={size} alignItems="stretch" gap="8">
          <Text variant="title-sm">{caption}</Text>
          <SidePanel className={shellStyle}>
            <SidePanel.Main>
              <MainContent
                label={`size="${size}"`}
                panel={{ panelWidth: size === 'sm' ? 360 : 720, drawerAt: size === 'sm' ? 960 : 1320 }}
              />
            </SidePanel.Main>
            <SidePanel.Panel size={size} defaultOpen={size === 'sm'}>
              <PanelContent title={size} />
            </SidePanel.Panel>
          </SidePanel>
        </VStack>
      ))}
    </Flex>
  );
}

Custom 폭

width 로 px 단위 custom 폭을 지정하고, drawerAt·fullPageAt 으로 전환점을 직접 덮어쓰는 예시다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelCustomWidthExample() {
  return (
    <Flex direction="column" gap="24">
      <VStack alignItems="stretch" gap="8">
        <Text variant="title-sm">{'width="480px"'} — 도출값 1080px / 600px</Text>
        <SidePanel className={shellStyle}>
          <SidePanel.Main>
            <MainContent label="패널 열기" panel={{ panelWidth: 480, drawerAt: 1080 }} />
          </SidePanel.Main>
          <SidePanel.Panel width="480px">
            <PanelContent title="480px" />
          </SidePanel.Panel>
        </SidePanel>
      </VStack>

      <VStack alignItems="stretch" gap="8">
        <Text variant="title-sm">{'+ drawerAt="1200px"'} — Drawer 전환점만 올라간다</Text>
        <SidePanel className={shellStyle}>
          <SidePanel.Main>
            <MainContent label="패널 열기" panel={{ panelWidth: 480, drawerAt: 1200 }} />
          </SidePanel.Main>
          <SidePanel.Panel width="480px" drawerAt="1200px">
            <PanelContent title="480px + drawerAt" />
          </SidePanel.Panel>
        </SidePanel>
      </VStack>
    </Flex>
  );
}

Controlled

open 을 직접 넘겨 controlled 로 다룬다. 모든 닫힘 경로가 onOpenChange 하나로 들어온다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelControlledExample() {
  const [open, setOpen] = React.useState(false);

  return (
    <Flex direction="column" gap="12">
      <Flex gap="8" alignItems="center">
        <Button variant="tertiary" onClick={() => setOpen((prev) => !prev)}>
          {open ? '닫기' : '열기'}
        </Button>
        <Text variant="body-md" color="neutral.text.low">
          open: {String(open)}
        </Text>
      </Flex>
      <SidePanel className={shellStyle}>
        <SidePanel.Main>
          <MainContent label="패널 토글" panel={DEFAULT_PANEL_READOUT} />
        </SidePanel.Main>
        <SidePanel.Panel open={open} onOpenChange={setOpen}>
          <PanelContent title="Controlled" />
        </SidePanel.Panel>
      </SidePanel>
    </Flex>
  );
}

본문 폭 변화

같은 폭의 셸 둘을 닫힘/열림으로 나란히 놓아 패널이 열릴 때 본문 폭만 줄어드는 걸 보인다.

코드

'use client';

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Box, Flex, Grid, VStack } from '@mildang/styled-system/jsx';
import { SidePanel } from '@mildang/design-system/SidePanel';
import type { SidePanelAnchor } from '@mildang/design-system/SidePanel';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

/**
 * 본문 폭을 실시간으로 읽는다. "몇 px 로 줄었는지"가 가장 확실한 증거라서.
 *
 * `contentRect` 가 아니라 **border box** 를 잰다. 전환점 공식의 "본문 600px" 은 본문 슬롯이
 * 차지하는 자리를 말하는데, `contentRect` 는 이 데모의 `padding: 24` 를 뺀 안쪽이라 48px 씩 작게 나온다.
 * 실제로 딱 전환점(본문 600px)인 화면에서 배지가 `552px` 이라고 적어 "600 인데 왜 안 접히냐"로 읽혔다.
 */
const useMeasuredWidth = () => {
  const ref = React.useRef<HTMLDivElement>(null);
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof ResizeObserver === 'undefined') return undefined;

    const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.borderBoxSize[0].inlineSize)));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return [ref, width] as const;
};

/**
 * **창 폭**을 실시간으로 읽는다. 전환 판정이 보는 게 이 값이라서다.
 *
 * 본문 폭(아래 `useMeasuredWidth`)과 헷갈리면 안 된다 — Docs 캔버스처럼 좁은 자리에 놓이면
 * 본문은 400px 인데 창은 1600px 이라 아무것도 안 접힌다. "왜 안 바뀌지" 의 대부분이 이거다.
 */
const useViewportWidth = () => {
  const [width, setWidth] = React.useState(0);

  React.useEffect(() => {
    const update = () => setWidth(window.innerWidth);
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  return width;
};

const parsePx = (value: string) => {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed) && value.trim().endsWith('px') ? parsed : null;
};

const MODE_LABEL = {
  split: { text: '사이드바', role: 'positive' },
  drawer: { text: 'Drawer', role: 'warning' },
  fullPage: { text: '전체화면', role: 'critical' },
} as const;

/** `fullPageAt` 을 안 넘겼을 때 Drawer 가 쓰는 전환점 (`custom` 기본값 = breakpoint `sm`). */
const DRAWER_FULL_PAGE_AT = 600;

/**
 * full page 가 실제로 시작되는 폭. **`fullPageAt` 값 그대로가 아니다.**
 *
 * Drawer 의 `custom` 은 `width: 100%` + `maxWidth: max(패널폭, ramp(fullPageAt))` 이라
 * (`preset/recipes/drawer.ts`), 뷰포트가 패널폭보다 좁아지면 `fullPageAt` 과 무관하게 저절로 100% 다.
 * 그래서 720px 패널은 `fullPageAt` 이 기본값(600)이어도 **719px 이하부터** 화면 전체이고,
 * `fullPageAt` 을 패널폭 **아래로** 내려도 기점이 안 내려간다 — 올리는 쪽만 먹는다.
 */
const resolveFullPageAt = (panelWidth: number, fullPageAt: unknown) =>
  Math.max(panelWidth, (typeof fullPageAt === 'string' ? parsePx(fullPageAt) : null) ?? DRAWER_FULL_PAGE_AT);

/**
 * 지금 어느 갈래이고, 다음 전환점까지 몇 px 남았는지.
 *
 * 스토리에서 "언제 접히는지 모르겠다" 가 나오는 건 화면에 **전환점 숫자도 현재 모드도** 안 떠서다.
 * 셋(창 폭 · 모드 · 전환점)을 한 줄에 같이 놓으면 창을 줄이는 동안 배지가 순서대로 바뀐다.
 */
const ANCHOR_LABEL = { left: '왼쪽', right: '오른쪽' } as const;

type PanelReadout = { panelWidth: number; drawerAt: number; fullPageAt?: string };

const DEFAULT_PANEL_READOUT: PanelReadout = { panelWidth: 360, drawerAt: 960 };

const ModeReadout = ({ anchor, panel }: { anchor?: SidePanelAnchor; panel: PanelReadout }) => {
  const viewport = useViewportWidth();
  const fullPagePx = resolveFullPageAt(panel.panelWidth, panel.fullPageAt);
  const current = viewport >= panel.drawerAt ? 'split' : viewport < fullPagePx ? 'fullPage' : 'drawer';
  const { text, role } = MODE_LABEL[current];
  const nextAt = current === 'split' ? panel.drawerAt : current === 'drawer' ? fullPagePx : null;
  const remaining = nextAt == null ? null : viewport - nextAt + 1;

  return (
    <Flex alignItems="center" gap="8" flexWrap="wrap">
      {anchor && (
        <Text variant="caption-lg" color="neutral.text.base">
          {ANCHOR_LABEL[anchor]}
        </Text>
      )}

      <Box paddingX="10" paddingY="4" borderRadius="full" backgroundColor={role + '.fill.base'}>
        <Text variant="caption-lg" color="inverse.text.base">
          {text}
        </Text>
      </Box>

      <Text variant="caption-lg" color="neutral.text.low">
        창 {viewport}px
      </Text>

      <Text variant="caption-lg" color="neutral.text.lowest">
        전환점 · Drawer &lt; {panel.drawerAt}px · 전체화면 &lt; {fullPagePx}px
      </Text>

      {remaining != null && (
        <Text variant="caption-lg" color="neutral.text.low">
          {remaining}px 더 줄이면 {current === 'split' ? 'Drawer' : '전체화면'}
        </Text>
      )}
    </Flex>
  );
};

const STATS = [
  { label: '진도율', value: '72%' },
  { label: '정답률', value: '88%' },
  { label: '학습 시간', value: '4h 20m' },
  { label: '오답 노트', value: '12개' },
  { label: '남은 과제', value: '3개' },
  { label: '연속 학습', value: '9일' },
];

// 스크롤은 `SidePanel.Main` 이 이미 받는다. 여기서 또 받으면 스크롤 컨테이너가 두 겹이 된다.
const mainStyle = css({ padding: '24' });

const MainContent = ({
  label,
  anchors,
  panel,
  panels,
}: {
  label?: string;
  /** 짝 형태 전용. 주면 배지와 Trigger 가 쪽마다 하나씩 생긴다. */
  anchors?: readonly SidePanelAnchor[];
  panel?: PanelReadout;
  panels?: Partial<Record<SidePanelAnchor, PanelReadout>>;
}) => {
  const [ref, width] = useMeasuredWidth();

  return (
    <div ref={ref} className={mainStyle}>
      <VStack alignItems="stretch" gap="16">
        {/* 판정에 실제로 쓰이는 값들. 창을 줄이면 여기 배지가 순서대로 바뀐다. */}
        {anchors ? (
          anchors.map((anchor) => (
            <ModeReadout key={anchor} anchor={anchor} panel={panels?.[anchor] ?? DEFAULT_PANEL_READOUT} />
          ))
        ) : (
          <ModeReadout panel={panel ?? DEFAULT_PANEL_READOUT} />
        )}

        <Flex alignItems="center" justifyContent="space-between" gap="12">
          <Text variant="title-md">학습 리포트</Text>

          <Flex alignItems="center" gap="8" flexShrink="0">
            {/* 열고 닫을 때 이 숫자가 패널 폭만큼 그대로 빠진다.
                ⚠️ 이건 **결과**지 판정 기준이 아니다. 접힐지 말지는 위 배지의 창 폭이 정한다. */}
            <Box
              paddingX="10"
              paddingY="4"
              borderRadius="full"
              backgroundColor="neutral.surface.high"
              flexShrink="0"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                본문 {width}px
              </Text>
            </Box>

            {/* 짝 형태에서는 `anchor` 로 지목한다 — 본문 안에서는 어느 쪽인지 알 길이 없어서다. */}
            {anchors
              ? anchors.map((anchor) => (
                  <SidePanel.Trigger key={anchor} anchor={anchor} asChild>
                    <Button variant="tertiary" size="sm">
                      {ANCHOR_LABEL[anchor]}
                    </Button>
                  </SidePanel.Trigger>
                ))
              : label && (
                  <SidePanel.Trigger asChild>
                    <Button variant="tertiary" size="sm">
                      {label}
                    </Button>
                  </SidePanel.Trigger>
                )}
          </Flex>
        </Flex>

        {/* 폭이 줄면 열 수가 줄어든다 — 리플로우가 눈에 가장 먼저 걸리는 부분. */}
        <Grid gridTemplateColumns="[repeat(auto-fill, minmax(160px, 1fr))]" gap="12">
          {STATS.map(({ label: statLabel, value }) => (
            <VStack
              key={statLabel}
              alignItems="flex-start"
              gap="4"
              padding="16"
              borderRadius="12"
              borderWidth="1px"
              borderStyle="solid"
              borderColor="neutral.border.base"
            >
              <Text variant="caption-lg" color="neutral.text.low">
                {statLabel}
              </Text>
              <Text variant="title-sm">{value}</Text>
            </VStack>
          ))}
        </Grid>

        <Text variant="body-md" color="neutral.text.low">
          패널이 열려도 본문은 사라지지 않습니다. 폭만 그만큼 줄어들 뿐이라 계속 읽고, 스크롤하고, 클릭할 수
          있습니다. Drawer 처럼 dim 이 덮거나 포커스가 갇히지 않는 게 이 컴포넌트의 핵심 차이입니다.
        </Text>
      </VStack>
    </div>
  );
};

const PanelContent = ({ title }: { title: string }) => (
  <>
    <SidePanel.Header>
      <SidePanel.Title>{title}</SidePanel.Title>
      <SidePanel.Close asChild>
        <Button variant="tertiary" size="sm">
          닫기
        </Button>
      </SidePanel.Close>
    </SidePanel.Header>

    <SidePanel.Body>
      <VStack alignItems="stretch" gap="12">
        {/* Drawer 로 전환되면 패널이 dialog 가 되므로 Title·Description 이 곧 접근 가능한 이름과 설명이다. */}
        <SidePanel.Description>
          본문을 보면서 함께 쓰는 보조 영역입니다. 넘치면 이 영역만 스크롤됩니다.
        </SidePanel.Description>

        {['오답 3번 문항', '오답 7번 문항', '오답 11번 문항'].map((item) => (
          <VStack
            key={item}
            alignItems="flex-start"
            gap="4"
            padding="12"
            borderRadius="8"
            backgroundColor="neutral.surface.high"
          >
            <Text variant="body-sm">{item}</Text>
            <Text variant="caption-lg" color="neutral.text.low">
              다시 풀어보기
            </Text>
          </VStack>
        ))}
      </VStack>
    </SidePanel.Body>

    <SidePanel.Footer>
      <SidePanel.Button variant="primary">저장</SidePanel.Button>
    </SidePanel.Footer>
  </>
);

/** `line` 용 셸 — app layout 이 내준 자리. 본문과 패널이 여기 가장자리까지 꽉 찬다. */
const shellStyle = css({
  height: '[460px]',
  borderWidth: '1px',
  borderStyle: 'solid',
  borderColor: 'neutral.border.base',
  overflow: 'hidden',
});

export default function SidePanelSplitBehaviorExample() {
  return (
    <Flex direction="column" gap="24">
      {(
        [
          { label: '닫힘 — 본문이 자리를 다 쓴다', open: false, trigger: '패널 열기' },
          { label: '열림 — 셸은 그대로, 본문만 360px 줄었다', open: true, trigger: '패널 닫기' },
        ] as const
      ).map(({ label, open, trigger }) => (
        <VStack key={label} alignItems="stretch" gap="8">
          <Text variant="title-sm">{label}</Text>
          <SidePanel className={shellStyle}>
            <SidePanel.Main>
              <MainContent label={trigger} panel={DEFAULT_PANEL_READOUT} />
            </SidePanel.Main>
            <SidePanel.Panel defaultOpen={open}>
              <PanelContent title="Side Panel" />
            </SidePanel.Panel>
          </SidePanel>
        </VStack>
      ))}
    </Flex>
  );
}