Skip to content
ssntl/ui

Sheet

A bottom sheet you can drag away: it follows you, says when release will close it, and springs back.

Installation

$ pnpm dlx shadcn@latest add @ssntl/sheet

Usage

tsx
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "@/components/ui/sheet"

<Sheet>
  <SheetTrigger asChild><Button>Order details</Button></SheetTrigger>
  <SheetContent>
    <SheetTitle>Order GT-48210</SheetTitle>
    <SheetDescription>Ready for pickup today.</SheetDescription>
  </SheetContent>
</Sheet>

Rules

What this component expects from you, as it ships in components/ui/sheet.md next to the code. The full file adds when to use it, keyboard, the UI foundations it applies and every shared rule.

components/ui/sheet.md
## Rules for Sheet

- **Responsive gesture.** The sheet follows the pointer 1:1 from the first pixel; dragging up meets resistance.
- **Continuous gesture.** The pointer is captured, so the drag survives leaving the handle.
- **Anticipation.** Past the threshold the handle stretches and says “Release to close”.
- **Cancelable gesture.** It closes on release past the threshold or on a quick flick (above about 0.11px/ms); short of both, it springs back.
- **Friction and one finger.** Dragging up meets rising resistance, and a second touch during a drag is ignored.
- Everything a drag does has a button too: the close button, Escape, and focus that returns to the trigger.
- **Undo beats confirmation.** For a reversible action, run it and offer Undo in a Toast; keep Dialog for what can't be undone.
Open the full rules file (opens in a new tab)

Code

components/ui/sheet.tsx
"use client"

import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"
import { CloseButton } from "@/registry/ssntl/ui/close-button"

// A bottom sheet: a modal (Radix Dialog) that slides up from the edge, with
// the same focus trap, Escape and close button as Dialog. Drag the handle
// down to dismiss it:
// - Responsive gesture: the sheet follows the pointer 1:1 from the first
//   pixel; dragging up meets resistance.
// - Continuous gesture: the pointer is captured, so straying off the handle
//   keeps the drag alive.
// - Anticipation: past the threshold the handle stretches and says
//   "Release to close", so you know what letting go will do.
// - Cancelable gesture: it only closes on release past the threshold, or on
//   a quick flick (momentum: faster than 0.11px/ms on average); letting go
//   short of both springs back.
// - Friction, not walls: dragging up meets rising resistance.
// - One finger at a time: a second touch during a drag is ignored, so the
//   sheet never jumps.
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close

function SheetContent({
  className,
  children,
  closeLabel = "Close panel",
  releaseLabel = "Release to close",
  ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
  closeLabel?: string
  /** Shown past the drag threshold. */
  releaseLabel?: string
}) {
  const sheet = React.useRef<HTMLDivElement>(null)
  const close = React.useRef<HTMLButtonElement>(null)
  const drag = React.useRef<{
    id: number
    y: number
    t: number
    dy: number
  } | null>(null)
  const [armed, setArmed] = React.useState(false)

  const threshold = () =>
    Math.min(120, (sheet.current?.offsetHeight ?? 400) * 0.3)

  const move = (dy: number) => {
    const el = sheet.current
    if (!el) return
    // Down follows 1:1; up gives less the further you pull.
    const y = dy > 0 ? dy : -Math.sqrt(-dy) * 2
    el.style.translate = `0 ${y}px`
    setArmed(dy > threshold())
  }

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || drag.current) return
    event.currentTarget.setPointerCapture(event.pointerId)
    drag.current = {
      id: event.pointerId,
      y: event.clientY,
      t: performance.now(),
      dy: 0,
    }
    sheet.current?.style.setProperty("transition", "none")
  }
  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    if (drag.current?.id !== event.pointerId) return
    drag.current.dy = event.clientY - drag.current.y
    move(drag.current.dy)
  }
  const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
    const state = drag.current
    if (state?.id !== event.pointerId) return
    drag.current = null
    const el = sheet.current
    if (!state || !el) return
    const velocity = state.dy / (performance.now() - state.t)
    el.style.removeProperty("transition")
    const flick = state.dy > 8 && velocity > 0.11
    if (state.dy > threshold() || flick) {
      close.current?.click()
    } else {
      el.style.translate = "0 0"
      setArmed(false)
    }
  }

  return (
    <SheetPrimitive.Portal>
      <SheetPrimitive.Overlay
        data-slot="sheet-overlay"
        className="fixed inset-0 z-50 bg-black/40 data-[state=closed]:pop-out data-[state=open]:pop-in"
      />
      <SheetPrimitive.Content
        ref={sheet}
        data-slot="sheet-content"
        className={cn(
          "fixed inset-x-0 bottom-0 z-50 mx-auto grid max-h-[90dvh] w-full max-w-lg gap-2 overflow-y-auto overscroll-contain rounded-t-lg bg-card px-6 pb-8 text-left text-card-foreground elevation-4",
          "transition-[translate] duration-(--duration-menu) ease-out data-[state=closed]:sheet-out data-[state=open]:sheet-in",
          className
        )}
        {...props}
      >
        <div
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
          className="-mx-6 grid cursor-grab touch-none justify-items-center gap-1 pt-3 pb-2 active:cursor-grabbing"
        >
          <span
            aria-hidden
            data-armed={armed || undefined}
            className="h-1.5 w-10 rounded-full bg-input transition-[width,background-color] duration-(--duration-fast) ease-out data-armed:w-16 data-armed:bg-foreground"
          />
          <span
            aria-live="polite"
            className={cn(
              "text-caption text-muted-foreground transition-opacity duration-(--duration-fast)",
              armed ? "opacity-100" : "opacity-0"
            )}
          >
            {armed ? releaseLabel : ""}
          </span>
        </div>
        {children}
        <SheetPrimitive.Close asChild>
          <CloseButton
            ref={close}
            label={closeLabel}
            className="absolute end-2 top-2"
          />
        </SheetPrimitive.Close>
      </SheetPrimitive.Content>
    </SheetPrimitive.Portal>
  )
}

function SheetTitle({
  className,
  ...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
  return (
    <SheetPrimitive.Title
      data-slot="sheet-title"
      className={cn("pe-12 text-heading", className)}
      {...props}
    />
  )
}

function SheetDescription({
  className,
  ...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
  return (
    <SheetPrimitive.Description
      data-slot="sheet-description"
      className={cn("text-body text-pretty text-muted-foreground", className)}
      {...props}
    />
  )
}

export {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetTitle,
  SheetTrigger,
}