Spring drawer

Free · MIT

A bottom sheet with half and full snap points plus side drawers on a spring; drag the handle and the release speed picks the snap point or closes it, while the backdrop fades with the drag.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/drawer-spring.json

The file lands in components/goodui/drawer-spring.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/drawer-spring works too.

Usage

import * as React from "react"
import { CalendarClock, XCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DrawerSpring } from "@/components/goodui/drawer-spring"

export function BookingSheet() {
  const [open, setOpen] = React.useState(false)
  const [snap, setSnap] = React.useState(0)

  return (
    <>
      <Button onClick={() => setOpen(true)}>
        <CalendarClock /> Booking details
      </Button>
      <DrawerSpring
        open={open}
        onOpenChange={setOpen}
        snapPoints={[0.55, 1]}
        onSnapChange={setSnap}
        title="Sofia Martinez"
        description={`Balayage and gloss · Tue 14 Nov, 2:30 PM · ${snap === 1 ? "full" : "half"} height`}
        footer={
          <div className="flex gap-2">
            <Button variant="outline" className="flex-1" onClick={() => setOpen(false)}>
              <XCircle /> Cancel booking
            </Button>
            <Button className="flex-1" onClick={() => setOpen(false)}>
              Check in
            </Button>
          </div>
        }
      >
        <ul className="space-y-3 text-sm">
          <li>2 h 30 min with Maya Chen</li>
          <li>Deposit paid: $40 of $185</li>
          <li>Note: prefers a cooler tone than last time.</li>
        </ul>
      </DrawerSpring>
    </>
  )
}

When to use

Use it for
  • Detail panels on mobile, such as a booking or an order, that open at half height and drag up to full height.
  • Side forms that keep the page visible behind them: a new client, an invoice line, filters.
  • A left navigation drawer for small screens.
Reach for something else
  • A short confirmation or a single question: a centered dialog is faster to read; use morphing-dialog when it starts from a card.
  • A small form tied to one button: use popover-morph, which is non-modal and stays next to its trigger.
  • A list of actions on an item: use dropdown-stagger.
If you needUse
Mobile detail view that can growside="bottom" snapPoints={[0.5, 1]}
Sheet that fits its contentside="bottom" with no snapPoints (max 88dvh)
Form or editor next to the pageside="right" with footer for the actions
App navigation on small screensside="left"
Must not close by accident (unsaved work)dismissible={false}: no Esc, close button, backdrop close or drag-to-close

Examples

Side form drawer with a footer submit

<DrawerSpring
  side="right"
  open={open}
  onOpenChange={setOpen}
  title="New client"
  description="They get a welcome email with your booking link."
  footer={<Button type="submit" form="new-client" className="w-full">Save client</Button>}
>
  <form id="new-client" onSubmit={saveClient} className="space-y-3">
    <Input data-autofocus name="name" placeholder="Full name" />
    <Input name="email" type="email" placeholder="Email" />
  </form>
</DrawerSpring>

Navigation drawer from the left

<DrawerSpring side="left" open={nav} onOpenChange={setNav} title="Glow Studio" description="Hayes Valley">
  <nav className="flex flex-col gap-1">
    {["Calendar", "Clients", "Reports"].map((item) => (
      <button key={item} type="button" onClick={() => setNav(false)} className="rounded-lg px-3 py-2 text-start hover:bg-muted">
        {item}
      </button>
    ))}
  </nav>
</DrawerSpring>

API reference

PropTypeDefault
title*
Heading of the drawer; it is also the dialog's accessible name.
React.ReactNode—
open
Controlled open state. Pair with onOpenChange.
boolean—
defaultOpen
Initial open state when uncontrolled.
booleanfalse
onOpenChange
Called with false on Esc, the close button, a backdrop click or a drag past the lowest point.
(open: boolean) => void—
side
"bottom" is a sheet with optional snap points; "right" and "left" are full-height side drawers.
"bottom" | "right" | "left""bottom"
snapPoints
Bottom sheet only: visible fractions of the sheet height, e.g. [0.5, 1] for half and full.
number[]—
defaultSnap
Snap point index used when the sheet opens.
number0
onSnapChange
Called with the index into the sorted snapPoints after a drag or a handle press moves the sheet.
(index: number) => void—
description
Line under the title, linked to the dialog with aria-describedby.
React.ReactNode—
footer
Sticky area under the scrolling body, for actions.
React.ReactNode—
dismissible
When true (default), Esc, the close button, a backdrop click and dragging past the lowest point close the drawer. When false, none of them do and the close button is hidden: close it from your own controls (e.g. the footer).
booleantrue
className
Classes for the panel (width, height, radius).
string—
bodyClassName
Classes for the scrolling body that wraps children.
string—
children
Content.
React.ReactNode—
labels
Text for built-in controls and screen readers; pass translations here.
Partial<DrawerSpringLabels>—

Accessibility

EscCloses the drawer unless dismissible is false; focus returns to the element that had it before opening.
Tab / Shift+TabCycles through focusable elements inside the drawer; focus cannot leave it while open.
Enter / SpaceOn the grab handle of a bottom sheet with two or more snapPoints: moves to the next snap point, wrapping back to the lowest.
  • The panel has role="dialog" and aria-modal="true", named by title and described by description when you pass one.
  • On open, focus goes to the first [data-autofocus] element, else the first focusable element after the grab handle (usually the close button). Add data-autofocus to a field if typing comes first.
  • With snap points, the handle is a button labelled labels.expand or labels.collapse ("Expand sheet" / "Collapse sheet"); without them it is hidden from assistive tech and the tab order.
  • The close button is labelled labels.close (default "Close") and is hidden when dismissible is false. Page scroll is locked while open.

Motion

  • Open, close and snap use a spring (stiffness 380, damping 38, mass 0.9) that starts with the release velocity of the drag; closing uses a stiffer spring (420 / 42).
  • Dragging the header (handle, title, close row) moves the panel 1:1, with a rubber-band resistance past fully open. The body scrolls normally.
  • On release the panel goes to the snap point nearest to where the throw would land (position + velocity × 0.2 s); a flick faster than 700 px/s always moves at least one step. With dismissible, closed is one of the targets.
  • The backdrop opacity follows the panel between the lowest snap point and closed, so it fades while you drag down.
  • Reduced motion: open, close and snap jump instantly (duration 0); dragging still follows the pointer.
  • Dragging updates a motion value, so the panel and backdrop move without React re-renders until release.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • title is required; it is the dialog's accessible name. Use description for one line of context.
  • snapPoints only apply to side="bottom"; they are fractions of the sheet height (0 to 1) and are sorted ascending, so onSnapChange indexes refer to the sorted list.
  • Put actions in footer, not at the end of children, so they stay visible while the body scrolls.
  • It renders position: fixed without a portal: do not place it inside elements with transform or filter.
  • dismissible={false} disables Esc, backdrop click and drag-to-close and hides the close button, so always give the user an explicit way out (e.g. Cancel / Save in footer).

FAQ

Does onSnapChange fire when the sheet opens?

No. It fires after a drag release or a handle press moves the sheet. The opening snap point is defaultSnap.

Can I drag it from the body?

No. Only the header area drags, so scrolling content inside the body never fights the gesture.

Related