Drawer

Layout

본문 위에 겹쳐 여는 임시 패널.

Usage

화면 좌우 가장자리에서 열려 본문 위에 겹치는 임시 표면. 본문과 공간을 나눠 갖고 계속 열려 있어야 하면 SidePanel

import

import

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

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

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

API Reference

Drawer Props

Prop

Type

Default

direction

"left" | "right"

right

fullPageAt

DrawerFullPageAt

지정 안 함

modal

boolean

true

size

"lg" | "md" | "custom"

lg

width

string

지정 안 함

컨테이너 안에 가두기

container 에 엘리먼트를 넘기면 패널이 화면이 아니라 그 엘리먼트 안에서 열린다.

  • 패널 위치가 화면 기준(fixed)에서 엘리먼트 기준(absolute)으로 내려가면서, 100% 의 기준이 그 엘리먼트로 옮겨간다. 폭도, full page 전환 판정도 전부 컨테이너 폭 기준이다.
  • 컨테이너에는 position: relativeoverflow: hidden 이 필요하다.
  • 폭을 반드시 명시한다. 기본값 size="lg"(1280px)는 웬만한 카드보다 넓어서, 폭을 안 주면 패널이 카드를 통째로 덮는다.

컨테이너

`container` 엘리먼트 안에서 Drawer를 엽니다.

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';

const DrawerContainerSpecifiedExample = function ContainerSpecifiedDrawer() {
    const [containerElement, setContainerElement] = React.useState<HTMLDivElement | null>(null);

    return (
      <div
        ref={setContainerElement}
        className={css({
          position: 'relative',
          borderWidth: '1px',
          borderStyle: 'solid',
          borderColor: 'neutral.border.low',
          borderRadius: '12',
          p: '24',
          h: '420px',
          overflow: 'hidden',
          bg: 'neutral.surface.low',
        })}
      >
        <p className={css({ textStyle: 'body-lg', color: 'neutral.text.low', mb: '12px' })}>
          포털 대상이 되는 영역 (테두리 안)
        </p>
        {/* 폭 400 을 안 주면 기본값 lg(1280) 이 이 카드보다 넓어 패널이 카드를 통째로 덮는다. */}
        <Drawer container={containerElement ?? undefined} width="400px" modal={false}>
          <Drawer.Trigger asChild>
            <Button variant="tertiary">Open Drawer In Container</Button>
          </Drawer.Trigger>
          <Drawer.Content>
            <Drawer.Header>
              <Drawer.Title>Container Drawer</Drawer.Title>
              <Drawer.Description>이 테두리 안에서만 열립니다.</Drawer.Description>
            </Drawer.Header>
            <Drawer.Body>
              <p className={css({ textStyle: 'body-lg' })}>
                패널이 화면 오른쪽 끝이 아니라 컨테이너 오른쪽 끝에 붙습니다.
              </p>
            </Drawer.Body>
            <Drawer.Footer>
              <Drawer.Close asChild>
                <Drawer.Button variant="tertiary">Close</Drawer.Button>
              </Drawer.Close>
            </Drawer.Footer>
          </Drawer.Content>
        </Drawer>
      </div>
    );
  };

export default DrawerContainerSpecifiedExample;

뒤 화면을 다루는 modal

modaldim뒤 화면
true (기본)깔림완전히 잠긴다 — 클릭도 포커스도 넘어가지 않는다
false없음마우스로 뒤를 계속 클릭할 수 있다

dim 만 따로 끄는 prop 은 없다. 뒤를 계속 쓸 수 있는데 어둡게 깔면 거짓 신호이고, 잠가놓고 dim 만 빼면 왜 클릭이 안 되는지 알 수 없기 때문이다.

modal={false} 가 실제로 푸는 건 마우스 차단과 dim 뿐이다. 포커스 트랩과 배경 aria-hiddenmodal 값과 무관하게 항상 걸린다 — Drawer 가 감싸는 vaul 이 내부에서 Radix Dialog 를 만들 때 자신의 modal 값을 넘기지 않아서, Radix 쪽 modal 기본값 (true)이 그대로 남기 때문이다. 키보드와 스크린리더는 modal 과 무관하게 패널 안에 머문다.

닫히는 경로는 Escape · overlay 클릭 · Drawer.Close 세 가지뿐이다. 드래그로는 닫히지 않는다 — 패널 안 텍스트를 드래그로 선택할 수 있어야 하기 때문이다. modal={false} 면 overlay 자체가 없어 남는 경로는 Escape 와 Drawer.Close 뿐이다. 닫기 버튼을 꼭 둔다.

모달 동작

`modal` 값으로 배경 차단과 오버레이 동작을 선택합니다.

import React from 'react';
import { Flex } from '@mildang/styled-system/jsx';
import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

const DrawerModalityExample = () => {
    const ModalityDemo = () => {
      const [behindClicks, setBehindClicks] = React.useState(0);

      return (
        <Flex flexDirection="column" gap="16">
          <Flex gap="8" flexWrap="wrap">
            {([true, false] as const).map((modal) => (
              <Drawer key={String(modal)} size="md" modal={modal}>
                <Drawer.Trigger asChild>
                  <Button variant="tertiary">{`modal={${modal}}`}</Button>
                </Drawer.Trigger>
                <Drawer.Content>
                  <Drawer.Header>
                    <Drawer.Title>{`modal={${modal}}`}</Drawer.Title>
                    <Drawer.Description>
                      {modal
                        ? '뒤가 dim 되고 잠깁니다. 아래 행을 눌러도 클릭 수가 늘지 않습니다.'
                        : 'dim 이 없고 뒤가 살아 있습니다. 아래 행을 누르면 클릭 수가 늘어납니다.'}
                    </Drawer.Description>
                  </Drawer.Header>
                  <Drawer.Body>
                    <Text variant="body-lg">뒤 클릭 수: {behindClicks}</Text>
                  </Drawer.Body>
                  <Drawer.Footer>
                    <Drawer.Close asChild>
                      <Drawer.Button variant="tertiary">닫기</Drawer.Button>
                    </Drawer.Close>
                  </Drawer.Footer>
                </Drawer.Content>
              </Drawer>
            ))}
          </Flex>

          <Text variant="body-lg">뒤 클릭 수: {behindClicks}</Text>

          <Flex flexDirection="column" gap="4">
            {[1, 2, 3].map((row) => (
              <Button key={row} variant="tertiary" onClick={() => setBehindClicks((n) => n + 1)}>
                뒤 목록 {row} 행
              </Button>
            ))}
          </Flex>
        </Flex>
      );
    };

    return <ModalityDemo />;
  };

export default DrawerModalityExample;

구성 구조 — 언제 무엇을 조합하나

스크롤 주체는 Drawer.Body 하나뿐이다. Drawer.Header·Drawer.Footer 는 스크롤해도 각각 상단·하단에 고정된다. 둘 다 생략할 수 있고, Drawer.Footer 를 넣으면 내용이 짧아도 하단에 붙는다.

Drawer.Body 는 내부 스크롤(overflow: auto)을 쓰므로, 포털을 쓰지 않는 팝오버·툴팁을 그 안에 두면 잘려 보인다. DS 의 Select·Tooltip 은 자체적으로 포털을 쓰므로 영향이 없다.

Drawer.Trigger·Drawer.CloseasChild 로 임의 엘리먼트를 감싼다. Drawer.ButtonDrawer.Footer 용 버튼이고 size 기본값이 md 다.

Drawer.NestedRoot 로 Drawer 안에서 또 다른 Drawer 를 연다. Drawer Root 와 같은 prop 을 받지만 부모의 width·modal 을 물려받지 않는다 — 중첩 Drawer 는 각자 기본값에서 다시 시작하므로, 부모가 width="400px" 여도 자식에 똑같이 주지 않으면 자식은 기본 lg 로 열린다.

스크롤

긴 콘텐츠에서 본문 영역이 스크롤되는 구성을 확인합니다.

import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

const DrawerScrollableExample = () => (
    <Drawer>
      <Drawer.Trigger asChild>
        <Button variant="tertiary">Open Scrollable Drawer</Button>
      </Drawer.Trigger>
      <Drawer.Content>
        <Drawer.Header>
          <Drawer.Title>Header — 스크롤해도 상단 고정</Drawer.Title>
          <Drawer.Description>
            이 제목과 설명은 header 입니다. 아래 본문을 아무리 굴려도 여기는 움직이지 않습니다.
          </Drawer.Description>
        </Drawer.Header>

        <Drawer.Body>
          {Array.from({ length: 30 }, (_, i) => (
            <Text key={i} variant="body-lg" mb="12">
              {i + 1} / 30 — Body 만 독립적으로 스크롤됩니다. 이 문단이 위로 흘러가는 동안 Header 와 Footer 는
              그대로 붙어 있어야 합니다.
            </Text>
          ))}
        </Drawer.Body>

        <Drawer.Footer>
          <Text variant="body-lg" color="neutral.text.low" mr="auto" alignSelf="center">
            Footer — 스크롤해도 하단 고정
          </Text>
          <Drawer.Close asChild>
            <Drawer.Button variant="tertiary">Cancel</Drawer.Button>
          </Drawer.Close>
          <Drawer.Button variant="primary">Confirm</Drawer.Button>
        </Drawer.Footer>
      </Drawer.Content>
    </Drawer>
  );

export default DrawerScrollableExample;

중첩 Drawer

`NestedRoot`로 Drawer 안에서 다른 Drawer를 엽니다.

import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { css } from '@mildang/styled-system/css';

const DrawerNestedDrawersExample = () => (
    <Drawer>
      <Drawer.Trigger asChild>
        <Button variant="tertiary">Open First Drawer</Button>
      </Drawer.Trigger>
      <Drawer.Content>
        <Drawer.Header>
          <Drawer.Title>First Drawer</Drawer.Title>
        </Drawer.Header>

        <Drawer.Body className={css({ display: 'flex', flexDirection: 'column', gap: '16px' })}>
          <Drawer.Description>Click the button below to open a nested drawer.</Drawer.Description>
          <Drawer.NestedRoot>
            <Drawer.Trigger asChild>
              <Button variant="tertiary">Open Nested Drawer</Button>
            </Drawer.Trigger>
            <Drawer.Content>
              <Drawer.Header>
                <Drawer.Title>Nested Drawer</Drawer.Title>
              </Drawer.Header>

              <Drawer.Body>
                <Drawer.Description>This is a nested drawer inside another drawer.</Drawer.Description>
              </Drawer.Body>
            </Drawer.Content>
          </Drawer.NestedRoot>
        </Drawer.Body>
      </Drawer.Content>
    </Drawer>
  );

export default DrawerNestedDrawersExample;

예제

기본 사용

본문 위에 겹치는 임시 패널로 화면 오른쪽에서 열린다. 아래에서 올라오는 시트가 필요하면 BottomSheet 를 쓴다.

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

const DrawerDefaultExample = () => {
    return (
      <Drawer>
        <Drawer.Trigger asChild>
          <Button variant="tertiary">Open Drawer</Button>
        </Drawer.Trigger>
        <Drawer.Content>
          <Drawer.Header>
            <Drawer.Title>Drawer Title</Drawer.Title>
          </Drawer.Header>
          <Drawer.Body>
            <Drawer.Description>오른쪽에서 열리는 서랍형 패널입니다.</Drawer.Description>
          </Drawer.Body>

          <Drawer.Footer>
            <Drawer.Close asChild>
              <Drawer.Button variant="tertiary">Cancel</Drawer.Button>
            </Drawer.Close>
            <Drawer.Button variant="primary">Submit</Drawer.Button>
          </Drawer.Footer>
        </Drawer.Content>
      </Drawer>
    );
  };

export default DrawerDefaultExample;

크기

lg·md·custom 폭 상한을 비교합니다.

import { Flex } from '@mildang/styled-system/jsx';
import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

// 세 size 를 한 스토리에 모은다. 폭 상한이 1280/720 이라 나란히 열어 두면 서로 겹쳐서 비교가 안 되고,
// `container` 로 가두면 상한이 컨테이너 폭에 먹혀 셋이 똑같아진다 — 그래서 트리거만 나란히 둔다.
//
// `size` 는 recipe variant 라 **JSX 에 리터럴로 박혀 있어야** Panda 가 CSS 를 뽑는다.
// 배열을 돌며 `{...sample.props}` 로 넘기면 정적 추출이 끊겨 md 스타일이 통째로 사라진다.
const SizeSampleContent = ({ title, description }: { title: string; description: string }) => (
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>{title}</Drawer.Title>
      <Drawer.Description>{description}</Drawer.Description>
    </Drawer.Header>
    <Drawer.Body>
      <Text variant="body-lg">이 문단을 드래그해 보세요 — 드래그로 닫히지 않고 텍스트가 선택됩니다.</Text>
    </Drawer.Body>
    <Drawer.Footer>
      <Drawer.Close asChild>
        <Drawer.Button variant="tertiary">닫기</Drawer.Button>
      </Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
);

const DrawerSizesExample = () => (
    <Flex gap="8" flexWrap="wrap">
      <Drawer size="lg">
        <Drawer.Trigger asChild>
          <Button variant="tertiary">size=&quot;lg&quot; (기본)</Button>
        </Drawer.Trigger>
        <SizeSampleContent
          title='size="lg"'
          description="최대 1280px. 화면이 1280px 보다 좁으면 그대로 full page 입니다."
        />
      </Drawer>

      <Drawer size="md">
        <Drawer.Trigger asChild>
          <Button variant="tertiary">size=&quot;md&quot;</Button>
        </Drawer.Trigger>
        <SizeSampleContent title='size="md"' description="최대 720px. lg 로는 과한 상세 패널에 씁니다." />
      </Drawer>

      <Drawer width="min(70%, 900px)">
        <Drawer.Trigger asChild>
          <Button variant="tertiary">width=&quot;min(70%, 900px)&quot;</Button>
        </Drawer.Trigger>
        <SizeSampleContent
          title='size="custom"'
          description="화면의 70%, 단 900px 을 넘지 않습니다. sm(600px) 미만에서는 full page 입니다."
        />
      </Drawer>
    </Flex>
  );

export default DrawerSizesExample;

전체 화면 전환점

custom 폭에서 `fullPageAt` 기준으로 전체 화면 전환점을 조정합니다.

import { Flex } from '@mildang/styled-system/jsx';
import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';

// 세 size 를 한 스토리에 모은다. 폭 상한이 1280/720 이라 나란히 열어 두면 서로 겹쳐서 비교가 안 되고,
// `container` 로 가두면 상한이 컨테이너 폭에 먹혀 셋이 똑같아진다 — 그래서 트리거만 나란히 둔다.
//
// `size` 는 recipe variant 라 **JSX 에 리터럴로 박혀 있어야** Panda 가 CSS 를 뽑는다.
// 배열을 돌며 `{...sample.props}` 로 넘기면 정적 추출이 끊겨 md 스타일이 통째로 사라진다.
const SizeSampleContent = ({ title, description }: { title: string; description: string }) => (
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title>{title}</Drawer.Title>
      <Drawer.Description>{description}</Drawer.Description>
    </Drawer.Header>
    <Drawer.Body>
      <Text variant="body-lg">이 문단을 드래그해 보세요 — 드래그로 닫히지 않고 텍스트가 선택됩니다.</Text>
    </Drawer.Body>
    <Drawer.Footer>
      <Drawer.Close asChild>
        <Drawer.Button variant="tertiary">닫기</Drawer.Button>
      </Drawer.Close>
    </Drawer.Footer>
  </Drawer.Content>
);

const DrawerFullPageAtExample = () => (
    <Flex gap="8" flexWrap="wrap">
      <Drawer width="300px">
        <Drawer.Trigger asChild>
          <Button variant="tertiary">width=300 (기본 sm)</Button>
        </Drawer.Trigger>
        <SizeSampleContent
          title='width="300px"'
          description="전환점을 안 줬으므로 기본 sm(600px) 입니다 — 599px 이하에서 full page."
        />
      </Drawer>

      <Drawer width="300px" fullPageAt="460px">
        <Drawer.Trigger asChild>
          <Button variant="tertiary">width=300 · fullPageAt=460</Button>
        </Drawer.Trigger>
        <SizeSampleContent
          title='width="300px" fullPageAt="460px"'
          description="459px 이하에서만 full page. 460~598px 구간에서는 300px 을 유지합니다."
        />
      </Drawer>

      <Drawer width="900px" fullPageAt="md">
        <Drawer.Trigger asChild>
          <Button variant="tertiary">width=900 · fullPageAt=&quot;md&quot;</Button>
        </Drawer.Trigger>
        <SizeSampleContent
          title='width="900px" fullPageAt="md"'
          description="토큰도 받습니다 — md 는 960px 이라 959px 이하에서 full page 입니다."
        />
      </Drawer>
    </Flex>
  );

export default DrawerFullPageAtExample;

제어 컴포넌트

외부 상태로 열림을 제어합니다.

import React from 'react';
import { css } from '@mildang/styled-system/css';
import { Button } from '@mildang/design-system/Button';
import { Drawer } from '@mildang/design-system/Drawer';

const DrawerControlledDrawerExample = function ControlledDrawer() {
    const [open, setOpen] = React.useState(false);

    return (
      <div className={css({ display: 'flex', gap: '16px' })}>
        <Button variant="tertiary" onClick={() => setOpen(true)}>
          Open Controlled Drawer
        </Button>
        <Drawer open={open} onOpenChange={setOpen}>
          <Drawer.Content>
            <Drawer.Header>
              <Drawer.Title>Controlled Drawer</Drawer.Title>
              <Drawer.Description>This drawer is controlled by external state.</Drawer.Description>
            </Drawer.Header>

            <Drawer.Body>
              <p className={css({ textStyle: 'body-lg' })}>This drawer is controlled by external state.</p>
            </Drawer.Body>

            <Drawer.Footer>
              <Drawer.Button variant="primary" onClick={() => setOpen(false)}>
                Close
              </Drawer.Button>
            </Drawer.Footer>
          </Drawer.Content>
        </Drawer>
      </div>
    );
  };

export default DrawerControlledDrawerExample;

WithForm

import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { Grid } from '@mildang/styled-system/jsx';
import { Field } from '@mildang/design-system/Form';
import { FormLabel } from '@mildang/design-system/FormLabel';
import { Input } from '@mildang/design-system/Input';

const DrawerWithFormExample = () => {
    return (
      <Drawer>
        <Drawer.Trigger asChild>
          <Button variant="tertiary">Edit Profile</Button>
        </Drawer.Trigger>
        <Drawer.Content>
          <Drawer.Header>
            <Drawer.Title>프로필 수정</Drawer.Title>
            <Drawer.Description>프로필 정보를 수정합니다.</Drawer.Description>
          </Drawer.Header>

          <Drawer.Body>
            <Grid gap="20">
              <Field required>
                <FormLabel htmlFor="name">이름</FormLabel>
                <Input fullWidth id="name" defaultValue="John Doe" />
              </Field>
              <Field>
                <FormLabel htmlFor="username">닉네임</FormLabel>
                <Input fullWidth id="username" defaultValue="johndoe" />
              </Field>
            </Grid>
          </Drawer.Body>

          <Drawer.Footer>
            <Drawer.Close asChild>
              <Drawer.Button variant="tertiary">취소</Drawer.Button>
            </Drawer.Close>
            <Drawer.Button variant="primary">저장</Drawer.Button>
          </Drawer.Footer>
        </Drawer.Content>
      </Drawer>
    );
  };

export default DrawerWithFormExample;

MaxWidthContent

import { Drawer } from '@mildang/design-system/Drawer';
import { Button } from '@mildang/design-system/Button';
import { Flex } from '@mildang/styled-system/jsx';
import { css } from '@mildang/styled-system/css';

const DrawerMaxWidthContentExample = () => {
    const rows = Array.from({ length: 42 }, (_, i) => ({
      id: i + 1,
      name: `학습자 ${i + 1}`,
      course: `${['국어', '영어', '수학', '과학'][i % 4]} 심화 과정`,
      progress: `${Math.min(100, (i + 1) * 4)}%`,
      score: `${60 + ((i * 7) % 40)}점`,
    }));

    return (
      <Drawer>
        <Drawer.Trigger asChild>
          <Button variant="tertiary">Open Wide Drawer</Button>
        </Drawer.Trigger>
        <Drawer.Content>
          <Drawer.Header>
            <Drawer.Title>실시간 학습 현황</Drawer.Title>
            <Drawer.Description>
              넓은 화면에선 최대 너비(size=lg, 1280px)에서 멈추고, 화면이 좁아지면 카드가 줄바꿈되며 패널도
              함께 줄어듭니다 — 1280px 아래는 곧 full page 입니다. 세로로도 스크롤됩니다.
            </Drawer.Description>
          </Drawer.Header>

          <Drawer.Body>
            <Flex flexWrap="wrap" gap="16">
              {rows.map((row) => (
                <div
                  key={row.id}
                  className={css({
                    display: 'flex',
                    flexDirection: 'column',
                    gap: '8',
                    flex: '1 1 200px',
                    p: '16',
                    borderWidth: '1px',
                    borderStyle: 'solid',
                    borderColor: 'neutral.border.low',
                    borderRadius: '12',
                    bg: 'neutral.surface.high',
                  })}
                >
                  <span className={css({ textStyle: 'body-xl-semibold', color: 'neutral.text.base' })}>
                    {row.name}
                  </span>
                  <span className={css({ textStyle: 'body-lg', color: 'neutral.text.low' })}>
                    {row.course}
                  </span>
                  <div className={css({ display: 'flex', justifyContent: 'space-between', mt: '4' })}>
                    <span className={css({ textStyle: 'caption-lg', color: 'neutral.text.low' })}>
                      진도 {row.progress}
                    </span>
                    <span className={css({ textStyle: 'caption-lg-medium', color: 'neutral.text.base' })}>
                      {row.score}
                    </span>
                  </div>
                </div>
              ))}
            </Flex>
          </Drawer.Body>

          <Drawer.Footer>
            <Drawer.Close asChild>
              <Drawer.Button variant="tertiary">닫기</Drawer.Button>
            </Drawer.Close>
            <Drawer.Button variant="primary">전체 리포트 보기</Drawer.Button>
          </Drawer.Footer>
        </Drawer.Content>
      </Drawer>
    );
  };

export default DrawerMaxWidthContentExample;