@present-day/drawer
v2.4.0
Published
A flexible drawer component with smooth animations and snap points
Maintainers
Readme
@present-day/drawer
A flexible and performant React drawer component with smooth animations and customizable snap points.
Features
- 🎯 Flexible Snap Points - Support for fractional heights, pixel values, and preset modes
- 🎬 Smooth Animations - Built with Framer Motion for 60fps animations
- 📱 Mobile Optimized - Touch-friendly drag gestures with proper momentum
- ♿ Accessible -
role="dialog",aria-modal, name/description, focus trap, and Escape to dismiss - 🎨 Customizable - Tailwind CSS classes with full style control
- 🔧 TypeScript - Full type safety and autocomplete support
Installation
bun add @present-day/drawer motion
# or
npm install @present-day/drawer motion
# or
yarn add @present-day/drawer motion
# or
pnpm add @present-day/drawer motionBasic Usage
import { Drawer } from '@present-day/drawer'
import { useState } from 'react'
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open Drawer</button>
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<Drawer.Content className="p-6">
<Drawer.Handle />
<h2>Hello World</h2>
<p>This is a drawer!</p>
</Drawer.Content>
</Drawer>
</>
)
}Advanced Usage with Snap Points
import { Drawer } from '@present-day/drawer'
function AdvancedExample() {
const [isOpen, setIsOpen] = useState(false)
return (
<Drawer
open={isOpen}
onOpenChange={setIsOpen}
snapPoints={[0.3, 0.6, 0.9]} // 30%, 60%, 90% of screen height
defaultSnapPoint={0.6}
>
<Drawer.Content className="p-6">
<Drawer.Handle />
<Drawer.Scrollable>
<h2>Scrollable Content</h2>
{/* Long content here */}
</Drawer.Scrollable>
</Drawer.Content>
</Drawer>
)
}Accessibility
The panel is exposed as a role="dialog" with aria-modal="true" when modal is true (the default for modal overlay + body scroll lock).
Name and description (recommended for all modal drawers, similar to Radix Dialog):
titleanddescription– Rendered in a screen-reader-only block (sr-only); the dialog is wired witharia-labelledbyandaria-describedbyso assistive technology gets a clear name and optional description even when you do not show a visible heading in the header chrome.ariaLabel– Use when you do not passtitlebut still need a short accessible name (e.g.ariaLabel="Filters").
Focus
- For
modal+open, a focus trap keeps keyboard focus in the panel by default, with focus returned to the previously focused element on close. SetfocusTrap={false}if you need to manage focus yourself (e.g. portaled sub-trees) or for non-modal behavior. - Clicks on the backdrop still work: outside clicks that dismiss are allowed; Escape closes the drawer when
dismissibleis true. Inside a non-empty text field, Escape clears the field first and only a second press dismisses (see Escape behavior in editable fields).
Example: hidden title and description (visual UI unchanged)
<Drawer
open={open}
onOpenChange={setOpen}
title="Account settings"
description="Update your email and password."
>
<Drawer.Content … />
</Drawer>Theming and chrome
Merge order for surface classes: internal defaults → Drawer’s slots (contentClassName, handleClassName, handleIndicatorClassName) → each part’s own className (and Drawer.Handle’s indicatorClassName for the default bar only).
data-* attributes (stable hooks for CSS):
| Attribute | Element |
| ----------------------------- | -------------------------------------------- |
| data-drawer-content | Drawer.Content root |
| data-drawer-handle | Drawer.Handle track |
| data-drawer-handle-indicator| Default handle bar (span) |
| data-drawer-scroll | Drawer.Scrollable root |
| data-drawer-no-drag | Opt out of panel drag (buttons, inputs, etc.) |
Drawer.Handle: use indicatorClassName to style the default pill without [&>span]:…. Pass children to replace the default bar entirely.
Overlay: when modal is true, pass overlayClassName on Drawer to extend or override the default dimmer (bg-black/50), e.g. translucent or stronger scrims.
Example: slots + overlay
<Drawer
open={open}
onOpenChange={setOpen}
overlayClassName="bg-black/30 backdrop-blur-sm"
slots={{
contentClassName: 'bg-zinc-900 text-white',
handleIndicatorClassName: 'bg-white/30',
}}
>
<Drawer.Content className="p-6">
<Drawer.Handle />
…
</Drawer.Content>
</Drawer>Product app (wrapper) checklist
This package does not include app-specific chrome (e.g. big close buttons). After upgrading to 1.1.0+:
- Wrap or re-export
Drawerfrom one module (e.g.shared/drawers/drawer) and add optional context for app-wide defaults merged withcnintoslots,overlayClassName, and per-drawer overrides. - Map
default/overlay/translucent(or similar) to presetslots+overlayClassNamevalues; merge explicit overrides on top withcn, do not replace entire presets unless intentional. - Document in the wrapper’s JSDoc which props are forwarded to
@present-day/drawervs app-only (close UI, variants, stripped legacy props). - Enforce a single import path for product code (ESLint
no-restricted-importsor a codemod from legacyui/drawer). - Add a short “Drawer recipes” comment block at the top of the wrapper file for agents (default sheet, overlay, translucent, custom chrome, custom close).
Mobile Safari, soft keyboard, and the visual viewport
When the drawer is open, it listens to window.visualViewport (resize and scroll). Snap heights and --drawer-available-height use the visual viewport height. The panel is position: fixed with an extra bottom offset so its bottom edge follows the visual viewport, not only the layout viewport. That avoids the common iOS case where a fixed bottom: 0 sheet stays behind the on-screen keyboard even though heights were already keyboard-aware. The offset is exposed as layoutBottomInset on the ViewportInfo object: max(0, window.innerHeight - visualViewport.height - visualViewport.offsetTop).
The keyboard transition is kept stable by three behaviors (v2.2+):
- The
bottomoffset is animated with the same spring as the height, so the panel rises smoothly above the keyboard instead of teleporting up while the height spring lags. - An instant
max-heightclamp (visual viewport height minustopInsetPx) is applied on the same render that observes the new viewport, so the panel top can never overshoot out of the visible viewport while springs settle. - While the keyboard is open and focus is inside the panel,
'auto'snap heights will not shrink below the current height. Clearing a search query often unmounts the results for a moment; without the floor, the sheet collapses mid-typing and the next tap lands on the dismiss overlay. Growth still passes through, and the floor releases when the keyboard hides. Transient zero content measurements are ignored while open for the same reason.
Optional useDrawerKeyboardSnapMobile (re-exported from the package) can be wired to onViewportChange to snap the sheet to its maximum height when a soft keyboard is detected on mobile, then restore the previous snap when the keyboard hides. That is separate from the built-in bottom anchoring: you do not need the hook for the sheet to sit above the keyboard, but the hook is useful if you want the sheet to expand for text fields in partial-height modes.
CSS on the motion panel: in addition to --drawer-height, --drawer-progress, and --drawer-available-height, the panel sets --drawer-layout-bottom-inset (same value as the animated bottom target used for anchoring) and --drawer-safe-area-bottom (see below).
Safe-area insets
By default the panel is padded with env(safe-area-inset-bottom, 0px) so content clears the home indicator, and 'auto' snap heights grow by the resolved inset so the padding doesn't squeeze measured content. Control it with the safeAreaBottom prop: pass a number for an explicit pixel inset, or false to opt out when your app already pads its drawer content. The active inset is exposed on the panel as --drawer-safe-area-bottom. The default topInsetPx (96px) keeps the sheet clear of the top notch/safe area.
Escape behavior in editable fields
Pressing Escape inside a non-empty input, textarea, or contenteditable does not dismiss the drawer — the field clears first (the browser's native type="search" behavior), and a second Escape on the now-empty field dismisses. The drawer also ignores Escape events whose default was prevented, so apps can claim Escape for their own shortcuts.
API Reference
Drawer Props
| Prop | Type | Default | Description |
| --------------------- | ------------------------------------------------- | ---------- | --------------------------------- |
| open | boolean | - | Controls the open state |
| onOpenChange | (open: boolean) => void | - | Called when open state changes |
| snapPoints | SnapPoint[] | ['auto'] | Snap stops; each entry is a fraction (≤ 1), pixel value (> 1), 'auto', or 'full' |
| defaultSnapPoint | SnapPoint | last stop | Initial snap point |
| activeSnapPoint | SnapPoint | - | Controlled active snap |
| setActiveSnapPoint | (point: SnapPoint, index: number) => void | - | Called when the active snap changes (drag, ref controls, programmatic). Name matches Vaul / shadcn drawer; the index is provided as a convenience |
| dismissible | boolean | true | Allow dismissing by dragging down; Escape closes when true |
| modal | boolean | true | Show overlay and lock body scroll |
| focusTrap | boolean | true | When modal, trap keyboard focus in the panel; set false to opt out |
| title | ReactNode | - | Screen-reader title (aria-labelledby); use for accessible name if you have no visible title |
| description | ReactNode | - | Optional; aria-describedby |
| ariaLabel | string | - | Alternative accessible name if title is omitted |
| overlayClassName | string | - | Merged with default modal overlay |
| slots | DrawerSlots | - | Optional class names for content / handle |
| topInsetPx | number | map inset | Pixels subtracted from visual height for snap math |
| safeAreaBottom | boolean \| number | true | Bottom safe-area padding: true uses env(safe-area-inset-bottom), a number forces pixels, false opts out |
| onViewportChange | (viewport: ViewportInfo) => void | - | Fired when the visual viewport updates while open; includes layoutBottomInset, height, offsetTop, keyboardHeight, isKeyboardOpen |
Components
Drawer.Content- Main content container (data-drawer-content)Drawer.Handle- Drag handle (optional); supportsindicatorClassNameandchildrenDrawer.Scrollable- Scrollable content areaDrawer.Overlay- Background overlay (auto-rendered when modal=true)
Migration
v1 → v2
The sizing prop and DRAWER_SIZING constant are removed in favor of a single, array-shaped snapPoints prop that aligns with Vaul and shadcn drawer. See MIGRATION.md for a full diff and codemod-friendly find/replace list.
Quick summary:
sizing="auto"→ omit (now the default) or passsnapPoints={['auto']}sizing="full"→snapPoints={['full']}sizing={[…]}→snapPoints={[…]}onSnapPointChange→setActiveSnapPoint(matches Vaul’s controlled-setter convention)SnapPointValuetype →SnapPointDrawerSizing/DrawerSizingPresettypes removedSNAP_POINT.FULL(was0.9) → eitherSNAP_POINT.NEAR_FULL(the same0.9value) orSNAP_POINT.MAX(1, full height). The newSNAP_POINT.FULLtoken resolves to'full'.
Earlier: BottomSheet → Drawer
The BottomSheet compatibility layer and BOTTOM_SHEET_* names were removed in v1. Use the Drawer API everywhere, and update any custom CSS or data-* hooks that targeted the old names.
- Components:
BottomSheet→Drawer,BottomSheetContent/Handle/Overlay/Scrollable→ the correspondingDrawer*exports, composed asDrawer.ContentandDrawer’s other static properties. - Context / hooks:
useBottomSheetContext→useDrawerContext,useBottomSheetDrag→useDrawerDrag,useBottomSheetSnap→useDrawerSnap,useBottomSheetKeyboardSnapMobile→useDrawerKeyboardSnapMobile. (There is no separateBottomSheetContextexport; the underlying context isDrawerContextif you need it in advanced code.) - Types: all
BottomSheet*types → the matchingDrawer*/*Drawer*names (e.g.BottomSheetProps→DrawerProps,BottomSheetRef→DrawerRef). - Constants:
BOTTOM_SHEET_TOP_INSET_PX→DRAWER_TOP_INSET_PX,BOTTOM_SHEET_CONTEXT_CONSUMER→DRAWER_CONTEXT_CONSUMER(kept in source; not re-exported from the packageindex— useDrawerand its parts in normal use),BOTTOM_SHEET_DRAG_SLOP_PX→DRAWER_DRAG_SLOP_PX(internal tuning constant). - Data attributes:
data-bottom-sheet-scroll→data-drawer-scroll,data-bottom-sheet-no-drag→data-drawer-no-drag. - CSS custom properties on the motion panel:
--bottom-sheet-height→--drawer-height,--bottom-sheet-progress→--drawer-progress,--bottom-sheet-available-height→--drawer-available-height. New:--drawer-layout-bottom-inset(visual-viewport bottom anchoring; see Mobile Safari, soft keyboard above).
License
MIT
