BottomSheet
Layout
화면 아래에서 올라오는 시트.
Usage
모바일 화면 아래에서 선택지나 짧은 작업 표면을 올려 보여 줄 때 사용한다.
import
import
import { BottomSheet } from '@mildang/design-system/BottomSheet';예제를 복사해 쓸 때 필요한 준비
• @mildang/styled-system 은 이 저장소에서 Panda 가 생성하는 산출물이다. 저장소 안에서는 turbo run ship 이후 쓸 수 있고, 패키지 소비자는 자기 Panda 산출물이나 다른 레이아웃 수단으로 바꿔야 한다.
API Reference
공개 Props가 없습니다.
셸 + 여백까지가 시트의 몫이다
BottomSheet 가 소유하는 건 위치·상단 radius·dim·드래그·safe area, Header/Body/Footer
의 영역 구분(고정·스크롤·고정), 그리고 세 영역의 여백이다. 여백은 Figma bottom_sheet 정본이고
SidePanel 과 같은 값을 쓴다.
| 슬롯 | 여백 | 비고 |
|---|---|---|
Header | py 12 · px 16(min-height 60) | 높이 60 의 한 줄이라 row · 가운데 정렬이다 |
Body | p 16 | 스크롤 영역이라 여백이 안쪽에 붙는다. 전체 폭 리스트는 padding="0" |
Footer | py 16 · px 16 | 우측 정렬 · gap 8 |
Drawer 만 px 24·Body p 24 로 넓다 — 그쪽은 본문 위에 겹치는 넓은 표면이라 그렇다. 배경·
타이포는 여전히 넣지 않는다(Title/Description 은 접근성 노드일 뿐이다). Header 가 row
라서 Description 은 헤더가 아니라 Body 첫 줄로 내린다(SidePanel 과 같은 규칙). 슬롯 없이
BottomSheet.Content 에 덩어리를 통째로 넣어도 된다 — 이때도 BottomSheet.Title 은 접근성
이름을 위해 넣어야 한다(시각적으로 숨겨도 된다).
Dialog isResponsive 의 모바일 분기가 바로 이 형태다. DialogStyleProvider 가 dialog recipe
의 슬롯 스타일을 공급해서 Dialog.Header/Dialog.Body/Dialog.Footer 가 BottomSheet 의
슬롯을 거치지 않고 그대로 들어간다 — 시트 여백과 모달 여백이 서로를 되돌리는 충돌이 구조적으로
나지 않는다.
Dialog 쪽에는 시트용 분기가 따로 없다. vaul 은 @radix-ui/react-dialog 를 감싼 라이브러리라
BottomSheet(vaul Root)가 내부적으로 DialogPrimitive.Root 를 렌더하고 Trigger/Title/
Description/Close 는 DialogPrimitive.* 를 그대로 재노출한다 — 시트 안에서도 Radix 슬롯이
같은 컨텍스트를 읽어 aria-labelledby/aria-describedby 가 연결된다. Radix 의 컨텍스트가
모듈 스코프라 두 패키지가 서로 다른 @radix-ui/react-dialog 사본을 보면 시트에서 슬롯이
throw 하는데, 이 참조 동일성은 vaul-radix-identity.test.ts 가 계속 지킨다.
snapPoints 와 드래그 핸들
snapPoints 를 넘기면 시트가 지정한 높이에서 멈춘다. 숫자는 뷰포트 비율(예: 0.4, 0.9),
문자열은 CSS 길이다.
- 드래그 핸들은 항상 나온다. 시트는
snapPoints유무와 무관하게 아래로 쓸어내려 닫히고, 핸들이 그 사실을 알리는 유일한 신호다 — 끄는 수단은 없다. - 드래그는 핸들 전용이 아니다. 시트 아무 데나 잡아도 닫힌다(
handleOnly를 쓰지 않는다). 그래서 핸들에cursor: grab을 주지 않는다 — 핸들은 드래그의 유일한 지점이 아니라 표식이다. 본문은user-select: none이다(선택 영역이 있으면 vaul 이 드래그를 취소한다). 텍스트를 고르게 해야 하는 화면은 시트가 아니라Drawer를 쓴다. - dim 은 첫 스냅부터 깔린다. vaul 기본값은 마지막 스냅(=전체 높이)에서만 어두워지는데,
단일 높이 시트와 동작이 달라 보여
fadeFromIndex를0으로 기본 설정했다. 직접 넘기면 그 값이 우선한다.
형태와 접근성
상단 코너는 테마의 dialog 콘텐츠 반경과 같은 값(mildang 20 / educore 24)이고, 최대 높이는
calc(100dvh - 56px) 이다(Figma 시트 정본의 상단 여백 56). 콘텐츠가 더 짧으면 콘텐츠 높이를
따른다. 전체 화면 시트처럼 코너를 없애야 하면
<BottomSheet.Content className={css({ borderTopRadius: "0" })}> 로 덮는다(panda 레이어
순서가 recipes → utilities 라 css() 가 recipe 를 이긴다). vh 가 아니라 dvh 인 이유 —
모바일 브라우저의 vh 는 주소창이 접힌 "가장 큰" 뷰포트라 주소창이 펼쳐진 상태에서 시트가
화면 밖으로 밀린다.
시트는 화면 바닥에 붙으므로 홈 인디케이터(safe area) 만큼 셸이 안쪽으로 물러난다 — 하단
여백을 직접 계산할 필요가 없다. container 를 넘기면 viewport 대신 그 엘리먼트 안에 시트를
가둔다.
BottomSheet.Title 은 넣어야 한다. 빠지면 accessible name 없음 경고가 난다. 시각적으로
제목이 없어야 하는 화면이라면 숨김 처리된 Title 을 넣는다.
dismissible={false} 는 스와이프·배경 탭·Esc 를 모두 막는다(vaul 이 Root 의
onOpenChange 에서 dismissible 이 아니면 닫힘 요청 자체를 무시하고, 스와이프는 별도로
dismissible 을 검사해 드래그 닫기를 건너뛴다). 닫는 수단을 시트 안에 꼭 둔다.
뒤 화면을 다루는 modal
BottomSheet 은 Drawer 와 같은 vaul Root/Overlay 를 감싸므로(direction 만 다르다) modal
동작도 Drawer 와 같다. Docs.mdx 는 이 축을 따로 설명하지 않지만, BottomSheetProps 가 vaul
Root 의 모든 props 를 통과시켜 modal 도 그대로 받는다.
| modal | dim | 뒤 화면 |
|---|---|---|
true (기본) | 깔림 | 완전히 잠긴다 — 클릭도 포커스도 넘어가지 않는다 |
false | 없음 | 마우스로 뒤를 계속 클릭할 수 있다 |
modal={false} 가 실제로 푸는 건 마우스 차단과 dim 뿐이다. 포커스 트랩과 배경
aria-hidden 은 modal 값과 무관하게 항상 걸린다 — vaul 이 내부에서 Radix Dialog 를 만들
때 자신의 modal 값을 넘기지 않아서, Radix 쪽 modal 기본값(true)이 그대로 남기 때문이다.
BottomSheet.modality.test.tsx 로 실측해 Drawer 배치와 같은 결론을 다시 확인했다: dim 없음 +
배경 aria-hidden 유지 + 포커스 트랩 유지 + body pointer-events 만 풀림.
예제
기본 사용
트리거로 열리는, 화면 아래에서 올라오는 시트다. 드래그로 닫히고 본문 텍스트는 선택 안 되며 핸들은 항상 보인다 — 셋은 옵션 없이 한 몸이다.
import { Button } from '@mildang/design-system/Button';
import { BottomSheet } from '@mildang/design-system/BottomSheet';
import { css } from '@mildang/styled-system/css';
import { Grid } from '@mildang/styled-system/jsx';
export default function BottomSheetExample() {
return (
<BottomSheet>
<BottomSheet.Trigger asChild>
<Button variant="tertiary">상세 보기</Button>
</BottomSheet.Trigger>
<BottomSheet.Content>
{/* Figma `bottom_sheet` header 는 높이 60 의 **한 줄**이다 — 그래서 recipe 가 row 다.
`Description` 은 여기 넣지 않고 Body 로 내린다 (SidePanel 과 같은 규칙). */}
<BottomSheet.Header>
<BottomSheet.Title>바텀시트</BottomSheet.Title>
</BottomSheet.Header>
<BottomSheet.Body>
<Grid gap="8">
<BottomSheet.Description>아래에서 올라오는 시트입니다.</BottomSheet.Description>
<p className={css({ textStyle: 'body-lg', color: 'neutral.text.low' })}>
이 문장을 드래그해 보세요 — 텍스트가 선택되는 대신 시트가 따라 내려옵니다. 손을 떼면 닫힙니다.
</p>
</Grid>
</BottomSheet.Body>
<BottomSheet.Footer>
<BottomSheet.Close asChild>
<Button size="md" variant="tertiary">
취소
</Button>
</BottomSheet.Close>
<Button size="md" variant="primary">
확인
</Button>
</BottomSheet.Footer>
</BottomSheet.Content>
</BottomSheet>
);
}
폼
입력 폼을 담은 모바일 바텀시트의 대표 용례다.
코드
import { BottomSheet } from '@mildang/design-system/BottomSheet';
import { Button } from '@mildang/design-system/Button';
import { Field } from '@mildang/design-system/Form';
import { FormLabel } from '@mildang/design-system/FormLabel';
import { Input } from '@mildang/design-system/Input';
import { Grid } from '@mildang/styled-system/jsx';
export default function BottomSheetWithFormExample() {
return (
<BottomSheet>
<BottomSheet.Trigger asChild>
<Button variant="tertiary">프로필 수정</Button>
</BottomSheet.Trigger>
<BottomSheet.Content>
<BottomSheet.Header>
<BottomSheet.Title>프로필 수정</BottomSheet.Title>
</BottomSheet.Header>
<BottomSheet.Body>
<Grid gap="20">
<Field required>
<FormLabel htmlFor="bs-name">이름</FormLabel>
<Input fullWidth id="bs-name" defaultValue="John Doe" />
</Field>
<Field>
<FormLabel htmlFor="bs-username">닉네임</FormLabel>
<Input fullWidth id="bs-username" defaultValue="johndoe" />
</Field>
</Grid>
</BottomSheet.Body>
<BottomSheet.Footer>
<BottomSheet.Close asChild>
<Button size="md" variant="tertiary">
취소
</Button>
</BottomSheet.Close>
<Button size="md" variant="primary">
저장
</Button>
</BottomSheet.Footer>
</BottomSheet.Content>
</BottomSheet>
);
}
긴 본문
Body만 스크롤되고 Header와 Footer는 고정되는 구조다.
코드
import { BottomSheet } from '@mildang/design-system/BottomSheet';
import { Button } from '@mildang/design-system/Button';
import { Text } from '@mildang/design-system/Text';
export default function BottomSheetScrollableExample() {
return (
<BottomSheet>
<BottomSheet.Trigger asChild>
<Button variant="tertiary">Open Scrollable Bottom Sheet</Button>
</BottomSheet.Trigger>
<BottomSheet.Content>
<BottomSheet.Header>
<BottomSheet.Title>Header — 스크롤해도 상단 고정</BottomSheet.Title>
</BottomSheet.Header>
<BottomSheet.Body>
<BottomSheet.Description mb="12">
이 설명은 Body 안입니다 — 아래 본문과 함께 스크롤됩니다. 제자리에 남는 건 Header 와 Footer
뿐입니다.
</BottomSheet.Description>
{Array.from({ length: 30 }, (_, i) => (
<Text key={i} variant="body-lg" mb="12">
{i + 1} / 30 — Body 만 독립적으로 스크롤됩니다. 이 문단이 위로 흘러가는 동안 Header 와 Footer 는
그대로 붙어 있어야 합니다.
</Text>
))}
</BottomSheet.Body>
<BottomSheet.Footer>
<Text variant="body-lg" color="neutral.text.low" mr="auto" alignSelf="center">
Footer — 스크롤해도 하단 고정
</Text>
<BottomSheet.Close asChild>
<Button size="md" variant="tertiary">
취소
</Button>
</BottomSheet.Close>
<Button size="md" variant="primary">
확인
</Button>
</BottomSheet.Footer>
</BottomSheet.Content>
</BottomSheet>
);
}
높이 단계
snapPoints로 시트의 높이 단계를 정의한다.
코드
'use client';
import { useState } from 'react';
import { BottomSheet } from '@mildang/design-system/BottomSheet';
import { Button } from '@mildang/design-system/Button';
import { css } from '@mildang/styled-system/css';
import { Grid } from '@mildang/styled-system/jsx';
export default function BottomSheetWithSnapPointsExample() {
const snapPoints = ['240px', 0.6, 1];
const [snap, setSnap] = useState<number | string | null>(snapPoints[0]);
return (
<BottomSheet snapPoints={snapPoints} activeSnapPoint={snap} setActiveSnapPoint={setSnap}>
<BottomSheet.Trigger asChild>
<Button variant="tertiary">Open Snap Sheet</Button>
</BottomSheet.Trigger>
<BottomSheet.Content>
<BottomSheet.Header>
<BottomSheet.Title>실시간 학습 현황</BottomSheet.Title>
</BottomSheet.Header>
<BottomSheet.Body>
<Grid gap="12">
{Array.from({ length: 20 }, (_, i) => (
<div
key={i}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
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' })}>
학습자 {i + 1}
</span>
<span className={css({ textStyle: 'caption-lg-medium', color: 'neutral.text.low' })}>
진도 {Math.min(100, (i + 1) * 5)}%
</span>
</div>
))}
</Grid>
</BottomSheet.Body>
</BottomSheet.Content>
</BottomSheet>
);
}
자유 구성
Header, Body, Footer 슬롯 없이 콘텐츠를 직접 구성한다.
코드
import { BottomSheet } from '@mildang/design-system/BottomSheet';
import { Button } from '@mildang/design-system/Button';
import { css } from '@mildang/styled-system/css';
export default function BottomSheetFreeformContentExample() {
return (
<BottomSheet>
<BottomSheet.Trigger asChild>
<Button variant="tertiary">Open Freeform Sheet</Button>
</BottomSheet.Trigger>
<BottomSheet.Content>
<div className={css({ display: 'grid', gap: '12', p: '24' })}>
<BottomSheet.Title>덩어리로 넣기</BottomSheet.Title>
<p className={css({ textStyle: 'body-lg', color: 'neutral.text.low' })}>
슬롯 없이 자식을 그대로 넣어도 됩니다. 시트가 관여하는 건 위치·상단 radius·dim·드래그·safe area
까지입니다.
</p>
</div>
</BottomSheet.Content>
</BottomSheet>
);
}