junyeokk
Blog
React-Ecosystem·2025. 11. 20

vaul

모바일 웹에서 바텀 시트(Bottom Sheet)를 구현하는 건 생각보다 까다롭다. 단순히 아래에서 올라오는 패널을 만드는 게 아니라, 터치 드래그로 열고 닫고, 스냅 포인트에 걸리고, 뒤에 있는 콘텐츠가 자연스럽게 축소되는 그 느낌을 재현해야 한다. iOS의 네이티브 시트처럼.

직접 구현하면 어떻게 될까? 터치 이벤트 핸들링(touchstart, touchmove, touchend), 드래그 속도 계산, 스냅 포인트 로직, 스크롤 내부 콘텐츠와 드래그 제스처의 충돌 방지, 배경 스케일 애니메이션... 200줄짜리 컴포넌트가 금방 나온다. 그리고 엣지 케이스를 하나씩 만날 때마다 코드는 점점 복잡해진다.

vaul은 이 문제를 전문적으로 해결하는 라이브러리다. Emil Kowalski가 만들었고, Radix UI의 Dialog 프리미티브 위에 구축되어 있어서 접근성이 기본으로 보장된다.


직접 구현 vs vaul

바텀 시트를 직접 구현할 때 가장 큰 문제는 터치 제스처와 내부 스크롤의 충돌이다.

시트 안에 스크롤 가능한 콘텐츠가 있다고 하자. 사용자가 위로 스와이프하면 이게 콘텐츠를 스크롤하려는 건지, 시트를 닫으려는 건지 어떻게 구분할까? 콘텐츠가 스크롤 최상단에 있을 때만 시트 드래그로 전환해야 하고, 스크롤 중간이면 콘텐츠만 스크롤해야 한다.

tsx
// 직접 구현하면 이런 로직이 필요하다
const handleTouchMove = (e: TouchEvent) => {
  const scrollableContent = contentRef.current;
  const isAtTop = scrollableContent.scrollTop === 0;
  const isDraggingDown = currentY > startY;
  
  if (isAtTop && isDraggingDown) {
    // 시트를 아래로 드래그 (닫기)
    e.preventDefault();
    setTranslateY(currentY - startY);
  } else {
    // 콘텐츠 스크롤
  }
};

이것만으로도 부족하다. 드래그 속도(velocity)에 따라 스냅 여부를 결정해야 하고, 드래그 중에 스프링 애니메이션이 자연스러워야 하고, 시트가 열릴 때 배경이 약간 축소되는 효과도 있어야 한다.

vaul은 이 모든 걸 내부적으로 처리한다. 개발자는 그냥 컴포넌트를 조합하면 된다.


기본 구조

vaul의 API는 Radix UI 스타일의 Compound Component 패턴을 따른다.

tsx
import { Drawer } from 'vaul';

function BottomSheet() {
  return (
    <Drawer.Root>
      <Drawer.Trigger>열기</Drawer.Trigger>
      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-xl">
          <Drawer.Handle />
          <Drawer.Title>시트 제목</Drawer.Title>
          <Drawer.Description>설명 텍스트</Drawer.Description>
          {/* 내용 */}
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}

각 파트의 역할:

컴포넌트역할
Drawer.Root상태 관리 (열림/닫힘), 옵션 설정
Drawer.Trigger클릭하면 시트를 여는 버튼
Drawer.Portal시트를 DOM 트리 최상위에 렌더링
Drawer.Overlay반투명 배경 오버레이
Drawer.Content실제 시트 콘텐츠 영역
Drawer.Handle드래그 핸들 바 (시각적 + 터치 영역)
Drawer.Title접근성을 위한 제목 (aria-labelledby)
Drawer.Description접근성을 위한 설명 (aria-describedby)

Radix Dialog 기반이기 때문에 Drawer.TitleDrawer.Description은 스크린 리더가 시트의 목적을 파악하는 데 사용된다. 생략하면 콘솔에 경고가 뜬다. 시각적으로 숨기고 싶으면 VisuallyHidden으로 감싸면 된다.


핵심 Props

shouldScaleBackground

시트가 열릴 때 뒤에 있는 페이지를 약간 축소하는 효과다. iOS의 시트 동작과 동일하다.

tsx
<Drawer.Root shouldScaleBackground={true}>

이 효과가 동작하려면 앱의 루트 요소에 data-vaul-drawer-wrapper 속성을 추가해야 한다.

tsx
// layout.tsx
<body>
  <div data-vaul-drawer-wrapper>
    {children}
  </div>
</body>

내부적으로 vaul은 이 wrapper를 찾아서 scale(0.96) + border-radius 트랜지션을 적용한다. wrapper가 없으면 효과가 무시된다.

snapPoints

시트가 멈출 수 있는 높이 지점들을 정의한다. 사용자가 드래그하다 놓으면 가장 가까운 스냅 포인트에 걸린다.

tsx
<Drawer.Root snapPoints={[0.4, 1]}>

값은 0~1 사이 비율이거나 픽셀 문자열이다:

tsx
// 화면 높이의 40%, 100% 두 지점
snapPoints={[0.4, 1]}

// 200px, 400px 두 지점
snapPoints={["200px", "400px"]}

스냅 포인트를 설정하면 시트가 열릴 때 첫 번째 스냅 포인트 높이에서 시작한다. 사용자가 위로 드래그하면 다음 스냅 포인트로 확장된다.

activeSnapPointsetActiveSnapPoint로 현재 스냅 포인트를 제어할 수도 있다:

tsx
const [snap, setSnap] = useState<number | string | null>(0.4);

<Drawer.Root 
  snapPoints={[0.4, 1]} 
  activeSnapPoint={snap} 
  setActiveSnapPoint={setSnap}
>

기본적으로 vaul은 모달 모드로 동작한다. 시트가 열리면 오버레이가 깔리고, 뒤의 콘텐츠는 상호작용 불가능하다.

tsx
// 기본값: 모달
<Drawer.Root modal={true}>

// non-modal: 배경과 동시 상호작용 가능
<Drawer.Root modal={false}>

non-modal 모드는 지도 앱처럼 시트가 열려 있어도 뒤의 콘텐츠를 조작해야 하는 경우에 유용하다. 이 모드에서는 오버레이를 렌더링하지 않는 게 일반적이다.

direction

시트가 나타나는 방향을 제어한다.

tsx
<Drawer.Root direction="bottom">  {/* 기본값: 아래에서 위로 */}
<Drawer.Root direction="top">     {/* 위에서 아래로 */}
<Drawer.Root direction="left">    {/* 왼쪽에서 오른쪽으로 */}
<Drawer.Root direction="right">   {/* 오른쪽에서 왼쪽으로 */}

leftright는 사이드 패널/네비게이션 드로어로 사용할 수 있다. 방향에 따라 드래그 제스처 축도 자동으로 전환된다.

dismissible과 handleOnly

tsx
// 드래그로 닫기 비활성화
<Drawer.Root dismissible={false}>

// 핸들 바에서만 드래그 가능 (콘텐츠 영역 드래그 무시)
<Drawer.Root handleOnly={true}>

handleOnly는 시트 안에 드래그 가능한 요소(슬라이더, 캐러셀 등)가 있을 때 제스처 충돌을 방지하는 데 유용하다.


제어 모드 (Controlled)

외부에서 열림/닫힘 상태를 제어하려면 openonOpenChange를 사용한다.

tsx
const [open, setOpen] = useState(false);

<Drawer.Root open={open} onOpenChange={setOpen}>
  <Drawer.Portal>
    <Drawer.Overlay />
    <Drawer.Content>
      <button onClick={() => setOpen(false)}>닫기</button>
    </Drawer.Content>
  </Drawer.Portal>
</Drawer.Root>

{/* Trigger가 Content 밖에 있어도 됨 */}
<button onClick={() => setOpen(true)}>시트 열기</button>

이 패턴은 Drawer.Trigger를 사용하지 않고 외부 버튼이나 이벤트로 시트를 제어할 때 필요하다.


shadcn/ui와 함께 사용

shadcn/ui는 vaul을 감싸서 프로젝트에 맞는 스타일을 적용한 Drawer 컴포넌트를 제공한다. npx shadcn@latest add drawer로 추가하면 components/ui/drawer.tsx가 생성된다.

tsx
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer";

function Example() {
  return (
    <Drawer>
      <DrawerTrigger>열기</DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>설정</DrawerTitle>
          <DrawerDescription>앱 설정을 변경합니다.</DrawerDescription>
        </DrawerHeader>
        {/* 내용 */}
      </DrawerContent>
    </Drawer>
  );
}

shadcn/ui 래퍼가 해주는 것들:

  • shouldScaleBackground 기본값을 true로 설정
  • Overlay에 bg-black/80 스타일 적용
  • Content에 rounded-t-[10px], border, 핸들 바 스타일 포함
  • DrawerHeader, DrawerFooter 같은 레이아웃 유틸 컴포넌트 추가
  • noOverlay, hideHandle 같은 커스텀 prop 확장

shadcn/ui 방식의 장점은 프로젝트 안에 소스 코드가 직접 들어오기 때문에 자유롭게 수정할 수 있다는 것이다. vaul의 기본 동작은 그대로 유지하면서 스타일과 구조만 프로젝트에 맞게 조정할 수 있다.


내부 동작 원리

vaul이 자연스러운 제스처 경험을 만들어내는 핵심 메커니즘들을 살펴보자.

드래그 처리

vaul은 포인터 이벤트(pointerdown, pointermove, pointerup)를 사용한다. 터치와 마우스를 하나의 API로 처리할 수 있기 때문이다.

드래그 중에는 CSS transform: translateY()로 시트를 이동시킨다. top이나 height를 변경하면 매 프레임마다 레이아웃 재계산이 발생하지만, transform은 GPU 가속 compositing으로 처리되어 60fps를 유지할 수 있다.

스크롤 vs 드래그 판별

시트 내부에 스크롤 가능한 콘텐츠가 있을 때, vaul은 이렇게 판단한다:

  1. 터치 시작 시 스크롤 컨테이너의 scrollTop을 저장
  2. 아래로 드래그할 때: scrollTop === 0이면 시트 드래그, 아니면 콘텐츠 스크롤
  3. 위로 드래그할 때: 콘텐츠 스크롤이 우선, 스냅 포인트가 있으면 최대 스냅에서 더 위로는 불가

이 판별 로직 덕분에 긴 목록이 있는 시트에서도 스크롤과 닫기 제스처가 자연스럽게 공존한다.

스냅 애니메이션

드래그를 놓았을 때 vaul은 두 가지를 계산한다:

  • 현재 위치: 가장 가까운 스냅 포인트까지의 거리
  • 드래그 속도(velocity): 빠르게 스와이프하면 다음 스냅 포인트로, 천천히 움직이면 원래 위치로 복귀

이 velocity 기반 판단 덕분에 살짝만 빠르게 아래로 쓱 내리면 시트가 닫히고, 천천히 조금 내렸다가 놓으면 원래 위치로 돌아간다.

배경 스케일 효과

shouldScaleBackground가 활성화되면:

  1. [data-vaul-drawer-wrapper]를 찾는다
  2. 시트가 열릴 때 transform: scale(0.96) translateY(calc(env(safe-area-inset-top) + 14px))를 적용
  3. border-radius를 추가해서 둥근 모서리 효과
  4. transition으로 부드럽게 전환

env(safe-area-inset-top)은 아이폰 노치/다이나믹 아일랜드 영역을 고려한 것이다.


반응형 패턴: 데스크톱 Dialog + 모바일 Drawer

일반적인 패턴은 데스크톱에서는 Dialog(모달), 모바일에서는 Drawer(바텀 시트)를 사용하는 것이다. vaul과 Radix Dialog를 조합하면 이렇게 구현할 수 있다.

tsx
import { useMediaQuery } from 'usehooks-ts';
import { Drawer } from 'vaul';
import * as Dialog from '@radix-ui/react-dialog';

function ResponsiveModal({ children, open, onOpenChange }) {
  const isDesktop = useMediaQuery('(min-width: 768px)');

  if (isDesktop) {
    return (
      <Dialog.Root open={open} onOpenChange={onOpenChange}>
        <Dialog.Portal>
          <Dialog.Overlay className="fixed inset-0 bg-black/50" />
          <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6 w-[400px]">
            {children}
          </Dialog.Content>
        </Dialog.Portal>
      </Dialog.Root>
    );
  }

  return (
    <Drawer.Root open={open} onOpenChange={onOpenChange}>
      <Drawer.Portal>
        <Drawer.Overlay className="fixed inset-0 bg-black/40" />
        <Drawer.Content className="fixed bottom-0 left-0 right-0 bg-white rounded-t-xl p-4">
          <Drawer.Handle />
          {children}
        </Drawer.Content>
      </Drawer.Portal>
    </Drawer.Root>
  );
}

이 패턴은 vaul 공식 문서에서도 권장하는 방식이며, shadcn/ui에서도 비슷한 구조를 사용한다.


주의할 점

Portal 위치

Drawer.Portal은 기본적으로 document.body에 렌더링한다. Next.js의 layout.tsx에서 data-vaul-drawer-wrapper를 설정할 때, Portal이 wrapper 밖에 렌더링되기 때문에 배경 스케일 효과와 충돌하지 않는다. 만약 커스텀 Portal 컨테이너를 지정해야 한다면 container prop을 사용한다.

중첩 Drawer

vaul은 Drawer.NestedRoot로 중첩 시트를 지원한다.

tsx
<Drawer.Root>
  <Drawer.Portal>
    <Drawer.Content>
      <Drawer.NestedRoot>
        <Drawer.Trigger>내부 시트 열기</Drawer.Trigger>
        <Drawer.Portal>
          <Drawer.Overlay />
          <Drawer.Content>
            {/* 중첩된 시트 내용 */}
          </Drawer.Content>
        </Drawer.Portal>
      </Drawer.NestedRoot>
    </Drawer.Content>
  </Drawer.Portal>
</Drawer.Root>

접근성

vaul은 Radix Dialog를 기반으로 하므로:

  • Drawer.Contentrole="dialog"가 자동 적용
  • 포커스 트래핑: 시트가 열리면 포커스가 시트 안에 갇힘
  • ESC 키로 닫기
  • Drawer.Titlearia-labelledby, Drawer.Descriptionaria-describedby

Title이나 Description을 시각적으로 숨기려면 Radix의 VisuallyHidden을 사용한다:

tsx
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';

<VisuallyHidden>
  <Drawer.Title>댓글</Drawer.Title>
</VisuallyHidden>

왜 vaul인가

모바일 웹 바텀 시트 라이브러리는 여러 가지가 있다. react-spring-bottom-sheet는 react-spring 기반으로 물리 애니메이션이 자연스럽지만, 2023년부터 유지보수가 멈췄고 번들 크기가 크다. react-modal-sheet는 Framer Motion 기반이라 이미 Framer Motion을 쓰고 있다면 괜찮지만, 그렇지 않으면 의존성이 무겁다. @gorhom/bottom-sheet는 React Native 전용이라 웹에서는 사용할 수 없다.

vaul의 차별점은 Radix UI Dialog 위에 구축되어 접근성이 기본 보장되고, 번들 크기가 가볍고(~4KB gzipped), shadcn/ui 생태계와 자연스럽게 통합된다는 것이다. 특히 이미 Radix + shadcn/ui 기반 프로젝트라면 vaul이 가장 일관된 선택이다.


정리

  • vaul은 Radix Dialog 기반의 모바일 바텀 시트 라이브러리로, 터치 드래그·스냅 포인트·스크롤 충돌 같은 까다로운 제스처 처리를 자동화한다
  • shouldScaleBackground, snapPoints, direction 같은 선언적 Props로 iOS 네이티브 수준의 시트 UX를 재현할 수 있다
  • shadcn/ui와 함께 쓰면 프로젝트에 소스 코드가 직접 들어와서 스타일을 자유롭게 커스터마이징할 수 있다

관련 문서