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
"left" | "right"
right
DrawerFullPageAt
지정 안 함
boolean
true
"lg" | "md" | "custom"
lg
string
지정 안 함
컨테이너 안에 가두기
container 에 엘리먼트를 넘기면 패널이 화면이 아니라 그 엘리먼트 안에서 열린다.
- 패널 위치가 화면 기준(
fixed)에서 엘리먼트 기준(absolute)으로 내려가면서, 100% 의 기준이 그 엘리먼트로 옮겨간다. 폭도, full page 전환 판정도 전부 컨테이너 폭 기준이다. - 컨테이너에는
position: relative와overflow: 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
| modal | dim | 뒤 화면 |
|---|---|---|
true (기본) | 깔림 | 완전히 잠긴다 — 클릭도 포커스도 넘어가지 않는다 |
false | 없음 | 마우스로 뒤를 계속 클릭할 수 있다 |
dim 만 따로 끄는 prop 은 없다. 뒤를 계속 쓸 수 있는데 어둡게 깔면 거짓 신호이고, 잠가놓고 dim 만 빼면 왜 클릭이 안 되는지 알 수 없기 때문이다.
modal={false} 가 실제로 푸는 건 마우스 차단과 dim 뿐이다. 포커스 트랩과 배경
aria-hidden 은 modal 값과 무관하게 항상 걸린다 — 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.Close 는 asChild 로 임의 엘리먼트를 감싼다. Drawer.Button 은
Drawer.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="lg" (기본)</Button>
</Drawer.Trigger>
<SizeSampleContent
title='size="lg"'
description="최대 1280px. 화면이 1280px 보다 좁으면 그대로 full page 입니다."
/>
</Drawer>
<Drawer size="md">
<Drawer.Trigger asChild>
<Button variant="tertiary">size="md"</Button>
</Drawer.Trigger>
<SizeSampleContent title='size="md"' description="최대 720px. lg 로는 과한 상세 패널에 씁니다." />
</Drawer>
<Drawer width="min(70%, 900px)">
<Drawer.Trigger asChild>
<Button variant="tertiary">width="min(70%, 900px)"</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="md"</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;