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 과 같은 값을 쓴다.

슬롯여백비고
Headerpy 12 · px 16(min-height 60)높이 60 의 한 줄이라 row · 가운데 정렬이다
Bodyp 16스크롤 영역이라 여백이 안쪽에 붙는다. 전체 폭 리스트는 padding="0"
Footerpy 16 · px 16우측 정렬 · gap 8

Drawer 만 px 24·Body p 24 로 넓다 — 그쪽은 본문 위에 겹치는 넓은 표면이라 그렇다. 배경· 타이포는 여전히 넣지 않는다(Title/Description 은 접근성 노드일 뿐이다). Headerrow 라서 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/CloseDialogPrimitive.* 를 그대로 재노출한다 — 시트 안에서도 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 기본값은 마지막 스냅(=전체 높이)에서만 어두워지는데, 단일 높이 시트와 동작이 달라 보여 fadeFromIndex0 으로 기본 설정했다. 직접 넘기면 그 값이 우선한다.

형태와 접근성

상단 코너는 테마의 dialog 콘텐츠 반경과 같은 값(mildang 20 / educore 24)이고, 최대 높이는 calc(100dvh - 56px) 이다(Figma 시트 정본의 상단 여백 56). 콘텐츠가 더 짧으면 콘텐츠 높이를 따른다. 전체 화면 시트처럼 코너를 없애야 하면 <BottomSheet.Content className={css({ borderTopRadius: "0" })}> 로 덮는다(panda 레이어 순서가 recipesutilitiescss() 가 recipe 를 이긴다). vh 가 아니라 dvh 인 이유 — 모바일 브라우저의 vh 는 주소창이 접힌 "가장 큰" 뷰포트라 주소창이 펼쳐진 상태에서 시트가 화면 밖으로 밀린다.

시트는 화면 바닥에 붙으므로 홈 인디케이터(safe area) 만큼 셸이 안쪽으로 물러난다 — 하단 여백을 직접 계산할 필요가 없다. container 를 넘기면 viewport 대신 그 엘리먼트 안에 시트를 가둔다.

BottomSheet.Title 은 넣어야 한다. 빠지면 accessible name 없음 경고가 난다. 시각적으로 제목이 없어야 하는 화면이라면 숨김 처리된 Title 을 넣는다.

dismissible={false} 는 스와이프·배경 탭·Esc 를 모두 막는다(vaul 이 RootonOpenChange 에서 dismissible 이 아니면 닫힘 요청 자체를 무시하고, 스와이프는 별도로 dismissible 을 검사해 드래그 닫기를 건너뛴다). 닫는 수단을 시트 안에 꼭 둔다.

뒤 화면을 다루는 modal

BottomSheet 은 Drawer 와 같은 vaul Root/Overlay 를 감싸므로(direction 만 다르다) modal 동작도 Drawer 와 같다. Docs.mdx 는 이 축을 따로 설명하지 않지만, BottomSheetProps 가 vaul Root 의 모든 props 를 통과시켜 modal 도 그대로 받는다.

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

modal={false} 가 실제로 푸는 건 마우스 차단과 dim 뿐이다. 포커스 트랩과 배경 aria-hiddenmodal 값과 무관하게 항상 걸린다 — 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>
  );
}