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/sheetUsage
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.
## 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.Code
"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,
}