Spring drawer
Free · MITA 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.
Installation
$ npx shadcn@latest add https://goodui.dev/r/drawer-spring.jsonThe 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
- 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.
- 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 need | Use |
|---|---|
| Mobile detail view that can grow | side="bottom" snapPoints={[0.5, 1]} |
| Sheet that fits its content | side="bottom" with no snapPoints (max 88dvh) |
| Form or editor next to the page | side="right" with footer for the actions |
| App navigation on small screens | side="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
| Prop | Type | Default |
|---|---|---|
| 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. | boolean | false |
| 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. | number | 0 |
| 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). | boolean | true |
| 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
| Esc | Closes the drawer unless dismissible is false; focus returns to the element that had it before opening. |
| Tab / Shift+Tab | Cycles through focusable elements inside the drawer; focus cannot leave it while open. |
| Enter / Space | On 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
A service card expands into a booking dialog, with its image, title and price flying into place through a shared-layout spring, then folds back into the card on Esc or backdrop click.
A small button such as "Add note" morphs into a floating form panel, its label sliding into the header, and shrinks back into the button on save, Esc or an outside click.
A dropdown menu springs open while its items stagger in, with a highlight that glides between items, submenus, checkbox and radio items, and full keyboard navigation.
Mobile bottom bar where a pill glides to the selected tab and its icon bounces, in a labelled or expanding variant, with popping badges and an optional raised center action.