SheetDrawer
An edge-anchored Sheet at desktop that becomes a swipeable Drawer below 768px.
A drop-in replacement for the shadcn
Sheet: at 768px and up it
renders your Sheet unchanged; below, the same children render inside your
Drawer, anchored to the edge
you set with side. The breakpoint and the
override prop work the same for every pair, so this
page covers only what is specific to this one.
At 768px and up: a Sheet from the right edge. Resize the window across 768px to see the swap.
import {
SheetDrawer,
SheetDrawerClose,
SheetDrawerContent,
SheetDrawerDescription,
SheetDrawerFooter,
SheetDrawerHeader,
SheetDrawerTitle,
SheetDrawerTrigger,
} from "@/components/ui/sheet-drawer"
export function WorkspaceMenu() {
return (
<SheetDrawer>
<SheetDrawerTrigger>Open menu</SheetDrawerTrigger>
<SheetDrawerContent side="right">
<SheetDrawerHeader>
<SheetDrawerTitle>Workspace</SheetDrawerTitle>
<SheetDrawerDescription>
Go to any page in this workspace.
</SheetDrawerDescription>
</SheetDrawerHeader>
<nav>
{["Dashboard", "Projects", "Members", "Settings"].map((item) => (
<button key={item} type="button">
{item}
</button>
))}
</nav>
<SheetDrawerFooter>
<SheetDrawerClose>Close</SheetDrawerClose>
</SheetDrawerFooter>
</SheetDrawerContent>
</SheetDrawer>
)
}Installation
npx shadcn@latest add @alexandrejoly/sheet-drawerUpdate the import paths to match your project setup.
Migrating
Change the imports and insert Drawer after Sheet in each part name:
SheetContent becomes SheetDrawerContent, and so on, with the same props
and children (see Migrating). Then check
three things below 768px: width classes on the Content (see
Sizing classes), sheets that rely on the built-in close
button, and detached triggers (both in
Desktop-only props).
Usage
import {
SheetDrawer,
SheetDrawerClose,
SheetDrawerContent,
SheetDrawerDescription,
SheetDrawerFooter,
SheetDrawerHeader,
SheetDrawerTitle,
SheetDrawerTrigger,
} from "@/components/ui/sheet-drawer"<SheetDrawer>
<SheetDrawerTrigger>Open menu</SheetDrawerTrigger>
<SheetDrawerContent side="right">
<SheetDrawerHeader>
<SheetDrawerTitle>Title</SheetDrawerTitle>
<SheetDrawerDescription>Description</SheetDrawerDescription>
</SheetDrawerHeader>
<SheetDrawerFooter>
<SheetDrawerClose>Close</SheetDrawerClose>
</SheetDrawerFooter>
</SheetDrawerContent>
</SheetDrawer>Composition
SheetDrawer
├── SheetDrawerTrigger
└── SheetDrawerContent
├── SheetDrawerHeader
│ ├── SheetDrawerTitle
│ └── SheetDrawerDescription
└── SheetDrawerFooter
└── SheetDrawerCloseThe drawer edge
The Sheet sets its edge with side on the Content; the Drawer sets it on the
root, where it also drives the swipe gesture. SheetDrawerContent passes its
side up to the root, so below 768px the drawer opens from the same edge and
swipes the same way. This works wherever the Content sits: inside a Fragment,
behind a condition or in your own component.
At 768px and up: a Sheet from the left edge.
<SheetDrawer>
<SheetDrawerTrigger>Open menu</SheetDrawerTrigger>
{/* a left Sheet at 768px and up, a left Drawer below */}
<SheetDrawerContent side="left">{/* … */}</SheetDrawerContent>
</SheetDrawer>Without side, each component keeps its own default: the Sheet opens from
the right, the Drawer from the bottom. Write side="right" to get a right
drawer too.
To pick another edge below 768px only, set it on the root override. It wins
over side:
At 768px and up: a Sheet from the left edge.
<SheetDrawer drawer={{ swipeDirection: "down" }}>
{/* a left Sheet at 768px and up, a bottom Drawer below */}
<SheetDrawerContent side="left">{/* … */}</SheetDrawerContent>
</SheetDrawer>Sizing classes
The className on SheetDrawerContent applies to both sides. Your Sheet sets
its width under a data-[side=…] prefix, such as
data-[side=right]:sm:max-w-sm, so widen it with the same prefix: cn
replaces the matching class, and the drawer, which has no data-side, ignores
it. A width without that prefix, such as w-[400px], also narrows the drawer
below 768px. Reset it in the override with the same prefix.
At 768px and up: a wider Sheet from the right edge.
<SheetDrawerContent side="right" className="data-[side=right]:sm:max-w-lg">Opening on load
On the first render, before the viewport is known, the Sheet renders. A
defaultOpen sheet on a phone therefore shows briefly as a Sheet before it
becomes a Drawer. To open on load without that flash, control open and set
it after mount.
Desktop-only props
These props apply at 768px and up and are dropped below:
showCloseButtonon the Content. A drawer has no close button in the corner; it closes on swipe or on an overlay tap. If the built-in button is your only way to close the sheet, add aSheetDrawerClose.- Detached triggers on Base UI:
handle,triggerIdanddefaultTriggerIdon the root,handleandpayloadon the Trigger. Below 768px, the drawer opens only from aSheetDrawerTriggerplaced insideSheetDrawer, or from a controlledopen.
Nested sheets
A SheetDrawer inside another one renders a drawer inside a drawer below
768px. On Base UI, your Drawer styles the parent as a stacked drawer while the
child is open.
API reference
Each part takes every prop of its shadcn Sheet counterpart, plus a
drawer prop that takes the props of the matching Drawer part, except
children: drawer on the Content takes your DrawerContent props, and so
on. Only the parts below leave out more.
SheetDrawer
| Prop | Type | Default |
|---|---|---|
drawer | Omit<ComponentProps<typeof Drawer>, "children" | RootState> | - |
RootState stands for the keys that drawer leaves out because they belong
to the shared root: open and defaultOpen, since both sides share one open
state, and the detached-trigger props handle, triggerId and
defaultTriggerId. drawer.onOpenChange runs after the shared
onOpenChange and receives your Drawer's own arguments, such as a "swipe"
reason on Base UI.
SheetDrawerTrigger
| Prop | Type | Default |
|---|---|---|
drawer | Omit<ComponentProps<typeof DrawerTrigger>, "children" | "handle" | "payload"> | - |
Radix notes
drawer={{...}}is typed on Vaul. The edge for below 768px only isdrawer={{ direction }}, which takes the same four values asside.- Like Radix Dialog, the drawer moves focus inside when it opens. Pass
drawer={{ autoFocus: false }}to keep Vaul's own default. - Vaul nests drawers through
Drawer.NestedRoot, which your shadcn Drawer does not export: a nestedSheetDraweropens a second drawer on top, without Vaul's nested-drawer styling. - Vaul, the drawer library under the Radix Drawer, is no longer maintained. It still works; the Base UI Drawer is the maintained option.
Learn more
The underlying components are documented upstream: shadcn Sheet and Drawer, built on Base UI Dialog and Base UI Drawer, or on Radix Dialog and Vaul.