Drawer
A panel that slides in from the edge of the screen, supporting all four directions, click-the-overlay to close, and single-axis drag to swipe it away.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add drawer.enDrawer is a React drawer and side panel component built with Motion (motion/react) and Tailwind CSS. It slides in from the top, right, bottom or left, which also makes it a natural mobile bottom sheet. The panel is portaled to document.body and can be dragged away along its closing direction.
Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<Drawer/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
When to use
- A hamburger menu or category navigation on a mobile site, sliding in from the left or right
- A filter panel on an e-commerce listing page that closes and refreshes results when applied
- A mobile bottom sheet for actions such as share, sort or picking a product variant
- Opening record details or a quick-edit form from the right after clicking a row in an admin table
- Not a good fit: for an inspector that stays docked beside the main content instead of overlaying it, use Docked Inspector Rail
Installation
npx webberui@latest add https://webberui.com/r/drawer.en.jsonOr, once registries are configured in components.json, install it as @webberui/drawer.
Usage
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerClose,
} from "@/components/ui/drawer";
<Drawer>
<DrawerTrigger>Open menu</DrawerTrigger>
<DrawerContent side="right" className="w-72">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
<h2 className="text-base font-semibold">Menu</h2>
<DrawerClose />
</div>
<nav className="p-3">{/* items */}</nav>
</DrawerContent>
</Drawer>;DrawerContent's side decides the direction it slides in from ("top" | "right" | "bottom" | "left", default "right"). The panel slides in from the matching edge with a spring, and clicking the overlay or pressing Esc closes it.
Pass open and onOpenChange to switch to controlled mode:
const [open, setOpen] = React.useState(false);
<Drawer open={open} onOpenChange={setOpen}>
{/* ... */}
</Drawer>;Examples
Mobile bottom sheet
side="bottom" pins the panel to the bottom edge with a 85vh max height; add rounded corners and a grab-handle hint through className.
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerClose,
} from "@/components/ui/drawer";
<Drawer>
<DrawerTrigger>Choose size</DrawerTrigger>
<DrawerContent side="bottom" className="rounded-t-2xl">
<div className="mx-auto mt-3 h-1.5 w-10 rounded-full bg-neutral-300 dark:bg-neutral-700" />
<div className="flex items-center justify-between px-5 py-4">
<h2 className="text-base font-semibold">Choose size</h2>
<DrawerClose />
</div>
<div className="grid grid-cols-3 gap-2 px-5 pb-6">
{["S", "M", "L"].map((size) => (
<button key={size} className="rounded-lg border py-2 text-sm">
{size}
</button>
))}
</div>
</DrawerContent>
</Drawer>;Filter panel that closes on apply
In controlled mode, set open back to false yourself when the form is submitted.
"use client";
import * as React from "react";
import { Drawer, DrawerTrigger, DrawerContent } from "@/components/ui/drawer";
export function FilterDrawer({ onApply }: { onApply: (q: FormData) => void }) {
const [open, setOpen] = React.useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger>Filters</DrawerTrigger>
<DrawerContent side="left">
<form
className="flex flex-1 flex-col gap-4 p-5"
onSubmit={(e) => {
e.preventDefault();
onApply(new FormData(e.currentTarget));
setOpen(false);
}}
>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="inStock" /> In stock only
</label>
<button type="submit" className="mt-auto rounded-lg bg-neutral-900 py-2 text-sm text-white">
Apply filters
</button>
</form>
</DrawerContent>
</Drawer>
);
}Custom close button content
DrawerClose shows an X icon by default; pass children to turn it into a text button.
<DrawerContent side="right">
<div className="p-5">{/* order summary */}</div>
<div className="mt-auto border-t p-4">
<DrawerClose className="w-full py-2 text-sm">Continue shopping</DrawerClose>
</div>
</DrawerContent>Props
Drawer
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Content; must include DrawerTrigger and DrawerContent |
open | boolean | — | Open state for controlled mode; when omitted, the component manages it internally |
onOpenChange | (open: boolean) => void | — | Callback fired when the open state changes (in both controlled and uncontrolled modes) |
DrawerTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | — | Trigger content |
className | string | — | Appended to the trigger className |
DrawerContent
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "right" | The direction the drawer slides in from |
className | string | — | Appended to the panel container className |
children | React.ReactNode | — | Panel content |
DrawerClose
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | lucide-react X icon | Custom button content |
className | string | — | Appended to the close button className |
How it works
- Four directions:
sidemaps to the edge the panel snaps to and its initial off-screen offset. Left and right render as a full-height sidebar (w-80 max-w-[85vw]), top and bottom as a full-width banner (max-h-[85vh]); both sizes can be overridden withclassName. - Drag to close: the panel tracks the pointer with single-axis
drag, releasing the constraint only in the "closing" direction while the opening direction springs back with elastic damping. On release, a displacement past 80px or a velocity past 500px/s in the closing direction closes it, andAnimatePresence'sexitcontinues the slide-out from the current drag position; below the threshold,dragSnapToOriginsprings it back to place. - Portal to body: the panel and the overlay are rendered into
document.bodyviacreatePortal, so an ancestor'soverflowclipping or atransformestablishing a containing block cannot affectfixedpositioning. - Spring slide-in: the entrance uses a spring (
stiffness: 380, damping: 40), the same spring used by the drag spring back, so gestures and programmatic animation settle with the same feel. - Controlled mode: passing
openswitches to controlled mode and the component stops touching its internal state;onOpenChangefires in both controlled and uncontrolled modes, so it can be used to observe open/close events. - Tuning points: the color transition duration reads
var(--wb-duration-fast, 200ms), falling back to 200ms when undefined.
Accessibility
- The panel carries
role="dialog"andaria-modal="true"; focus moves into the panel on open (tabIndex={-1}), and focus returns to the trigger after close - The trigger is a native
<button>carryingaria-haspopup="dialog"and anaria-expandedstate, linked to the panel witharia-controlswhile open - Esc or a click on the overlay closes it; while open,
bodyis set tooverflow: hiddento lock background scrolling and restored to its previous value on close - When the user has "reduce motion" enabled at the system level, the slide-in and the drag degrade to a quick fade in / fade out with no sense of movement
- The component takes the lightweight route and does not build in a focus trap; if the panel holds several interactive elements and needs a complete keyboard focus cycle, pair it with a focus trap solution of your own
FAQ
Does it work with the Next.js App Router?
Yes. The file already starts with "use client", so import it from a Server Component page as usual. DrawerContent only creates its portal after mounting, so the server renders nothing for it and there is no hydration mismatch.
How do I change the panel width or height?
Pass className to DrawerContent. Left and right panels default to w-80 max-w-[85vw], top and bottom panels to max-h-[85vh]; override them with Tailwind classes such as className="w-96".
How far do I have to drag to close it?
It closes when the drag passes 80px in the closing direction or the release velocity exceeds 500px/s; otherwise it springs back. When the OS "reduce motion" setting is on, dragging is disabled and the panel simply fades.
What keyboard and screen reader support does it have?
The panel has role="dialog" and aria-modal="true", receives focus on open, returns focus to the trigger on close, and closes on Esc. There is no built-in focus trap, so add one yourself if the panel holds many interactive elements.
What are the dependencies?
motion, lucide-react, clsx and tailwind-merge. If you skip the CLI, the Manual tab in the installation section shows how to get the source.
Morphing Dialog
Click a card and it scales and morphs seamlessly into a centered dialog with a shared-element animation, then shrinks back to the original card position on close.
Dynamic Island Toast
An iOS Dynamic Island-style notification — an idle pill expands fluidly into a notification card, multiple notifications stack behind it with their edges peeking out, and a loading state can update in place to success.