Feedback

Drawer

An edge-anchored drawer with drag-to-dismiss, snap points and scroll-aware pull-to-close — hand-rolled, no vaul.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The shake hint ships with the component: React 19 hoists <style href> into the
 * head and de-dupes by href, so ten drawers on a page still share one keyframes.
 *
 * The shake animates the `translate` property, not `transform`, on purpose — the
 * panel's own offset (snapping/dragging) owns the inline `transform` the whole
 * time. They are separate properties and compose automatically, so shaking never

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/drawer.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Drawer" component (lucide-react for the
close icon, react-dom's createPortal, no vaul / no Radix — the gesture layer is
the point and it has to be self-contained).

Contract
- Props: open: boolean; onOpenChange: (open: boolean) => void;
  side?: "bottom" | "right" | "left" | "top" (default "bottom");
  snapPoints?: number[] (0..1 screen-height fractions, default [1], honoured
  only for bottom/top); activeSnap?: number and onSnapChange?: (i: number) =>
  void (index into the sorted snapPoints; omit activeSnap and the component
  keeps its own, resetting to the smallest snap on every open);
  dismissible?: boolean (default true); title?: string; description?: string
  (both rendered in the header and wired to aria-labelledby /
  aria-describedby); showHandle?: boolean (default true, bottom/top only);
  footer?: ReactNode; children; className merged onto the panel via cn(); the
  rest of the div props are spread onto the panel.
- snapPoints are sanitised, not trusted: drop non-finite values, clamp each to
  [0.1, 1], de-duplicate, sort ascending, fall back to [1] when nothing
  survives. A 0 or negative snap would squash the panel to a sliver that can
  never be grabbed again.
- One snap point means the panel is content-sized with max-height at that
  fraction. Two or more means the panel is fixed at the largest snap's height
  and the smaller snaps are reached by translating it down — that keeps the
  snap math exact and the content layout stable while dragging.
- Fully controlled: a drag that decides to close only calls onOpenChange(false).
  If the consumer refuses, the drawer animates back to its current snap instead
  of being stranded off-screen.

Behavior
- Position is one number: `offset`, 0 = fully open at the largest snap, 1 =
  fully off-screen, expressed as a fraction of the panel's own size so it maps
  straight to translate3d percentages and needs no measurement to render.
  Sign flips per side (bottom/right translate positive, top/left negative).
- Gestures use pointer events with setPointerCapture and pointerId isolation:
  only the first pointer owns the drag, a second finger or a stray palm is
  ignored, and pointerup/pointercancel on a foreign pointerId is dropped.
- A press does not immediately become a drag. It becomes one after 3px of
  movement, and only if the movement along the drawer's axis exceeds the
  movement across it — otherwise the gesture is abandoned for good so a
  horizontal swipe inside a bottom sheet (or a vertical scroll inside a side
  sheet) is left to the content.
- Scroll-vs-close priority, the rule that makes it feel native:
  1. handle and header are always draggable (touch-action: none);
  2. from the content, a pull toward the closing edge only arms when the nearest
     scrollable ancestor is at scroll position 0 — otherwise the content scrolls;
  3. a pull toward the opening edge only arms when the drawer is not already at
     its largest snap, so at full height an upward pull scrolls the content;
  4. the content region is overflow-auto + overscroll-contain + touch-pan-y.
- Release decides by velocity AND displacement. Track a lightly smoothed
  px/ms velocity along the axis. Candidate resting offsets are the snap offsets
  plus, when dismissible, "off-screen". Above ±0.5 px/ms, jump to the next
  candidate in the fling direction (so a hard flick from full height skips the
  intermediate snap and closes). Below it, settle on the nearest candidate by
  distance — which is what makes "drag most of the way past the smallest snap"
  close, and "drag a little" spring back.
- Over-drag: hard clamp at the largest snap, and when dismissible is false the
  pull past the smallest snap is rubber-banded (32% of the excess, capped) so it
  moves under the finger but cannot tear off.
- dismissible=false also swallows Esc, the backdrop click and the close button
  (which is not rendered at all); each blocked attempt shakes the panel and
  flashes a ring. The shake animates the CSS `translate` property, not
  `transform`, because `transform` is permanently owned by the drag offset —
  the two properties compose instead of overwriting each other.
- Enter/exit/settle all run through one effect that pushes `offset` to its
  target inside a double requestAnimationFrame, so the off-screen frame is
  actually painted before the transition starts (otherwise the browser
  coalesces both style changes and nothing animates). A settle counter is part
  of that effect's deps so every gesture end re-runs it even when the target
  did not change. The portal stays mounted until the exit transition ends.
- prefers-reduced-motion (subscribed via matchMedia, not read once): all
  transitions become "none" and the exit unmounts immediately — dragging,
  snapping and dismissing all still work, they just teleport.
- Focus: on open, remember document.activeElement, focus the panel itself
  (tabIndex=-1) so the title and description are announced, and trap Tab inside
  the panel because aria-modal="true" claims the background is unreachable. On
  close, restore focus only if the remembered element is still `isConnected` —
  the row that opened the drawer is often gone by then.
- Escape is handled on the panel's own React onKeyDown, with
  event.stopPropagation() — never a window/document keydown listener. A window
  listener cannot tell which layer is on top, so one Esc press inside a drawer
  that currently has a popover (or a shortcuts sheet) open is received by both
  handlers and closes both layers, throwing the user out of the form they were
  filling. Focus is moved into the panel on open, so a panel-level handler
  reliably receives the key. Call the consumer's own onKeyDown first and bail
  on event.defaultPrevented, so consumers can add or veto keys instead of
  having theirs silently swallowed. Tab is trapped in the same handler.
- Body scroll lock — the reentrancy count AND the pre-lock snapshot live on
  `document.body` as data attributes (`body.dataset.zyScrollLocks`,
  `.zyScrollLockOverflow`, `.zyScrollLockPadding`), never in module-level
  variables. Lock: on the 0 → 1 edge snapshot body's current inline `overflow`
  and `paddingRight`, then freeze; every later lock just increments. Release:
  decrement, and only on 1 → 0 write the snapshot back and delete all three
  attributes. Module-level state looks equivalent and is not — every component
  here is installed as its own copy, so a page runs several independent copies
  of this same lock (this drawer, a popover, a loading overlay), each in a
  private module scope, blind to the others. Nest two of them: the outer
  snapshots "", the inner snapshots "hidden", the outer closes first and
  restores "", then the inner closes and writes "hidden" back — the page is
  frozen for good with no overlay left on screen to explain it. A body
  attribute is the one namespace independent copies already share, so keep the
  three names byte-identical wherever this code is pasted.
- Scrollbar compensation is MEASURED, not predicted: read
  `document.documentElement.clientWidth`, set `overflow: hidden`, read it
  again, and add the positive difference to body's computed `paddingRight`.
  The usual `innerWidth - clientWidth` shortcut is wrong on any page that sets
  `scrollbar-gutter: stable` — the gutter is permanent, no width is reclaimed,
  and padding ~15px anyway shifts the content LEFT exactly as the drawer
  opens, which is the same jump the compensation exists to remove. Measuring
  also self-disables on macOS overlay scrollbars (the difference is 0), which
  is why testing this on a Mac alone proves nothing.
- Cleanup on close and on unmount: pointer capture released, drag rAF
  cancelled, shake timer cleared, unmount timer cleared, scroll lock
  decremented. There is no global keydown listener to remove — the keyboard
  lives entirely on the panel's onKeyDown.

Rendering & styling
- Semantic tokens only: panel bg-popover / text-popover-foreground with border
  and shadow-2xl, backdrop bg-background/80 + backdrop-blur-sm, handle
  bg-muted-foreground/40, header description text-muted-foreground, focus rings
  focus-visible:ring-2 ring-ring. No hex, no rgb(), no palette class names.
- The portal root is fixed inset-x-0 top-0 h-dvh so a bottom sheet respects
  mobile browser chrome; the panel is absolutely positioned against it, which
  is also why it is never clipped by an overflow-hidden ancestor.
- Backdrop opacity is driven by drag progress (1 at the smallest snap, 0 fully
  closed), so the scrim fades out under the finger.
- Accessibility: role="dialog" aria-modal="true", aria-labelledby/-describedby
  from title/description, an aria-labelled close button, and — when there is
  more than one snap point — the handle is a real role="slider" with
  aria-valuemin/max/now/text and ArrowUp/ArrowDown/Home/End support, because a
  height that can only be changed by dragging is unusable by keyboard.
- Escape hatches for consumers: data-drawer-no-drag on any subtree that must
  never start a drag, data-drawer-drag-zone to make a custom header draggable.

Customization levers
- Edge and size: `side` picks the edge; side drawers default to
  w-full max-w-md, so override the width through className. Bottom/top height
  comes entirely from snapPoints.
- Detents: snapPoints is the whole snapping feature — [1] for a plain sheet,
  [0.4, 0.9] for a filter sheet, [0.25, 0.6, 1] for a three-stage player. Drive
  activeSnap/onSnapChange from your own state to persist the last height.
- Gesture feel: the 3px arm threshold, the 0.5 px/ms fling threshold, the 320ms
  settle duration and the cubic-bezier(0.32, 0.72, 0, 1) easing are four
  constants at the top of the file — raise the fling threshold for a stickier
  sheet, lower the duration for a snappier one.
- Chrome: showHandle removes the grab handle, omitting title/description/
  dismissible removes the header entirely (build your own inside children and
  mark it data-drawer-drag-zone), footer is a non-scrolling action bar.
- Modality: dismissible=false turns it into a required step; pair it with a
  footer button that is the only exit.
- Scrim: swap bg-background/80 + backdrop-blur-sm on the backdrop for a plain
  tint, or drop the opacity-follows-drag binding for a constant scrim.

Concepts

  • Snap point — a resting height expressed as a fraction of the screen. Two or more of them turn a sheet into a stateful surface (peek / full) instead of a binary open-closed overlay, and the handle becomes a real slider rather than decoration.
  • Scroll-then-drag priority — the single rule that separates a native-feeling sheet from a broken one: a downward pull belongs to the content until the content is at its top, and only then does it belong to the sheet. Getting this backwards makes long content impossible to read.
  • Velocity settling — a flick and a slow drag that end at the same pixel mean different things. Reading px/ms at release lets a short fast flick close the sheet while a long slow drag to the same place springs back.
  • Rubber banding — a non-dismissible drawer still moves under the finger, just with damped, capped travel. Absolute rigidity reads as "the app froze"; damping reads as "this one is not going anywhere".
  • Scroll lock counted on document.body — the count and the saved overflow / paddingRight are three data attributes on body, not module-level variables. That distinction is the whole point: every component here is installed as its own copy, so the popover open inside this drawer is running a different copy of the same lock with its own private counter. Two private counters cannot cooperate — the drawer closes first and restores "", the popover closes after and writes back the "hidden" it recorded as the original, and the page is frozen with nothing left on screen to blame. A DOM attribute is the one namespace independent copies already share.
  • Measured, not predicted, scrollbar compensation — the padding added while locked is the observed clientWidth difference before and after overflow: hidden, not innerWidth - clientWidth. Under scrollbar-gutter: stable the gutter never goes away, so the prediction pads width that was never reclaimed and the page jumps left as the drawer opens. It also means the branch is a no-op on macOS overlay scrollbars — the reason this class of bug survives Mac-only testing.
  • Innermost layer owns Escape — the key is handled on the panel's own onKeyDown with stopPropagation, not on window. A window listener has no idea which surface is on top, so a drawer with a popover inside it loses both layers to a single Esc; the consumer's onKeyDown is forwarded first (and defaultPrevented respected) so their keys are not swallowed either.
  • Focus restoration with a liveness check — the element that opened the drawer is frequently unmounted by the time it closes (the row was edited, the list re-rendered). Restoring blindly drops focus to body; checking isConnected first keeps the fallback honest.
  • Transform vs translate — the drag owns the transform property full-time, so the refusal shake animates the independent translate property; CSS composes them, which is how a "shake" can coexist with a "position" without either clobbering the other.

On This Page