Command Palette

Search for a command to run...

2
Components

Snap Sheet

A bottom sheet you drag between snap points, with detent haptics, a rubber band, and flick to close.

Loading…

Features

  • Drags between snap points such as peek, half, and full, sized in px or as fractions of the viewport height.
  • Projects the release from its velocity, springs into the nearest snap point, rubber-bands past full height, and closes on a flick down.
  • Clicks into each new snap point with a sound and a selection haptic, pops at full height, and ticks a soft haptic for every snap point crossed mid-drag.
  • Hands gestures to scrollable content when it should scroll, and locks to the vertical axis so carousels inside keep working.
  • Dims the overlay and scales the page back as it rises, all from motion values with no re-render per frame.
  • Built on Base UI Dialog for the portal, focus trap, Esc, scroll lock, and returned focus. Can render as a dialog or side sheet on desktop.

Installation

pnpm dlx shadcn@latest add @tentui/snap-sheet

Usage

import { SnapSheet } from "@/components/snap-sheet";
<SnapSheet snapPoints={["148px", 0.5, 1]}>
  <SnapSheet.Trigger>Find a ride</SnapSheet.Trigger>
  <SnapSheet.Content>
    <SnapSheet.Handle />
    <SnapSheet.Title>3 rides nearby</SnapSheet.Title>
    <SnapSheet.Description>Pickup in 2 min</SnapSheet.Description>
    <div className="min-h-0 flex-1 overflow-y-auto">{/* … */}</div>
  </SnapSheet.Content>
</SnapSheet>

Snap points are listed lowest first. Numbers are fractions of the viewport height and strings such as "148px" are fixed heights. 1 is as tall as the sheet gets, which leaves a 24 px gap at the top.

Put long content in a child with overflow-y-auto and min-h-0 flex-1. A drag inside it moves the sheet until the sheet is at full height; from then on it scrolls the content, and a drag down only moves the sheet once the content is scrolled to the top.

Controlled

const [open, setOpen] = useState(false);
const [snap, setSnap] = useState(0);
 
<SnapSheet
  open={open}
  onOpenChange={setOpen}
  activeSnap={snap}
  onSnapChange={setSnap}
  snapPoints={[0.4, 1]}
>
  {/* … */}
</SnapSheet>

Changes that come from open and activeSnap animate silently. Pass soundOnProgrammatic to play the cues for them too. A drag in progress always wins over an activeSnap change.

Non-modal

Set modal={false} for map- or player-style sheets. The page stays interactive, focus isn't trapped, scroll isn't locked, and clicking outside doesn't close the sheet. The overlay only fades in between the last two snap points.

<SnapSheet modal={false} defaultOpen snapPoints={["120px", 0.5, 1]}>
  <SnapSheet.Content variant="floating">{/* … */}</SnapSheet.Content>
</SnapSheet>

Background scale

Add data-sheet-wrapper to the element that wraps your page and set scaleBackground. It scales to 0.94 with rounded corners as the sheet rises to full height. Give body a dark background so the gap behind it looks like iOS.

<body className="bg-black">
  <div data-sheet-wrapper className="min-h-dvh bg-background">
    {children}
  </div>
</body>

Inside a container

Pass container to render the sheet inside an element, such as a phone frame, instead of the viewport. Snap points are then sized from that element. Give it position: relative and overflow: clip.

const screenRef = useRef<HTMLDivElement>(null);
 
<div ref={screenRef} className="relative h-[540px] overflow-clip">
  <SnapSheet modal={false} defaultOpen>
    <SnapSheet.Content container={screenRef}>{/* … */}</SnapSheet.Content>
  </SnapSheet>
</div>

Desktop

From the md breakpoint up, desktop="dialog" renders a centered dialog and desktop="sheet" a side sheet. Neither has snap points or a handle.

<SnapSheet desktop="dialog">{/* … */}</SnapSheet>

Migrating from Vaul

Vaul is no longer maintained. Most props map one to one.

VaulSnap Sheet
Drawer.RootSnapSheet
Drawer.TriggerSnapSheet.Trigger
Drawer.Portal + Drawer.OverlayBuilt into SnapSheet.Content
Drawer.ContentSnapSheet.Content
Drawer.HandleSnapSheet.Handle
Drawer.Title / Drawer.DescriptionSnapSheet.Title / .Description
Drawer.CloseSnapSheet.Close
open / onOpenChangeopen / onOpenChange
snapPoints={[0.5, 1]}snapPoints={[0.5, 1]}
snapPoints={["148px", 1]}snapPoints={["148px", 1]}
activeSnapPoint={0.5} (a value)activeSnap={0} (an index)
setActiveSnapPointonSnapChange
modalmodal
dismissibledismissible
handleOnlyhandleOnly
shouldScaleBackgroundscaleBackground
[data-vaul-drawer-wrapper][data-sheet-wrapper]
containerSnapSheet.Content container
fadeFromIndexAutomatic: modal fades from closed, non-modal from the second-to-last snap
direction="right"desktop="sheet" (desktop only)
[data-vaul-no-drag][data-sheet-no-drag]

Accessibility

The sheet is a Base UI dialog, so it gets role="dialog", aria-modal, a focus trap, scroll lock, Esc to close, and focus returned to the trigger. modal={false} turns off the trap and the lock. Always render a SnapSheet.Title, and a SnapSheet.Close in modal sheets so touch screen readers can leave.

SnapSheet.Handle is a focusable slider labelled "Resize sheet" with an aria-valuetext such as "Half height". ArrowUp and ArrowDown move one snap point, Home and End jump to the ends, and Enter or a tap cycles through them. A polite live region announces each snap change.

With reduced motion enabled the springs become a 120 ms tween and the background no longer scales. Dragging still works, and sound and haptics stay on because they confirm each snap rather than decorate movement; turn them off with sound={false} and haptics={false}.

API reference

PropType

SnapSheet.Content

PropType

Command Palette

Search for a command to run...