All components

Drag Sheet

Overlays

A bottom sheet with snap points that feels native rather than like a `<div>` with a transition — three formulas and a state machine. Snapping is velocity-projected, so a flick and a slow drag past the same point resolve differently; bounds use Apple's rubber-band curve; and the drag-vs-scroll decision is made on the first `pointermove` past a 4px threshold and locked for the whole gesture, which is what stops a sheet from stealing a scroll halfway through.

  • Arbitration: scroll the list down, then drag down from inside it — the sheet does not move. Scroll back to the top and the same gesture drags the sheet.
  • Locked for the gesture: start a downward drag mid-list and keep going past the top. The sheet still doesn't grab it — the decision was made on the first move.
  • Rubber band: pull the handle up past the tallest detent.

Installation

Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/drag-sheet.json

Props

PropTypeDefaultDescription
open / onOpenChangeboolean / (open: boolean) => voidControlled open state. The sheet stays mounted and goes `inert` when closed, so opening and closing is one continuous animation on a single motion value rather than an exit-animation dance.
snapPointsnumber[][0.45, 0.92]Detent heights, ascending. Values ≤ 1 are fractions of the container's height; larger values are pixels. The two may be mixed.
initialSnapnumber0Detent index used the first time the sheet opens. Reopening keeps whichever detent it was left at, as native sheets do.
dismissiblebooleantrueAllow dragging down past the smallest detent to close. Dismissal is just another snap target, so a downward flick can carry into it.
decaynumber0.35How far ahead the velocity projection looks, in seconds. This is the number that decides whether a flick carries through — raise it and the sheet feels eager, drop it to 0 and it becomes plain nearest-snap.
resistancenumber0.55Rubber-band constant `c`. Lower is stiffer. Displacement is asymptotic to the sheet height, so it always resists more the further you pull and never runs away.
position"fixed" | "absolute""fixed"`fixed` covers the viewport; `absolute` scopes the sheet to the nearest positioned ancestor, which is what makes it embeddable in a card or a device frame.
titlestringRequired. Labels the `role="dialog"` via `aria-labelledby`; the sheet also traps Tab, closes on Escape, and restores focus on close.

Dependencies

motionclsxtailwind-merge