Pick a delivery time
We'll prepare your order as soon as possible.
---
import { Badge } from "@/components/ui/badge"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
FieldTitle,
} from "@/components/ui/field"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Toaster } from "@/components/ui/toast"
const deliveryTimes = [
{
value: "asap",
id: "delivery-asap",
label: "Standard delivery",
description: "25–35 min · Driver assigned now",
badge: "Fastest",
},
{
value: "5-00",
id: "delivery-5-00",
label: "5:00 PM – 5:15 PM",
description: "Prep starts at 4:45 PM",
},
{
value: "5-30",
id: "delivery-5-30",
label: "5:30 PM – 5:45 PM",
description: "Good if you're heading home",
},
{
value: "6-00",
id: "delivery-6-00",
label: "6:00 PM – 6:15 PM",
description: "Most popular · High demand",
},
{
value: "6-30",
id: "delivery-6-30",
label: "6:30 PM – 6:45 PM",
description: "Last slot before kitchen closes",
},
]
---
<Drawer swipeDirection="right" data-drawer-demo data-drawer-responsive>
<DrawerTrigger variant="secondary">Open Drawer</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Pick a delivery time</DrawerTitle>
<DrawerDescription>
We'll prepare your order as soon as possible.
</DrawerDescription>
</DrawerHeader>
<div class="flex-1 scroll-fade overflow-y-auto p-4">
<RadioGroup defaultValue="asap" class="gap-2">
{deliveryTimes.map((time) => (
<FieldLabel>
<Field as="span" orientation="horizontal">
<FieldContent as="span">
<FieldTitle as="span" class="flex items-center gap-2">
{time.label}
{time.badge ? (
<Badge variant="secondary">{time.badge}</Badge>
) : null}
</FieldTitle>
<FieldDescription as="span">
{time.description}
</FieldDescription>
</FieldContent>
<RadioGroupItem
value={time.value}
id={time.id}
data-label={time.label}
/>
</Field>
</FieldLabel>
))}
</RadioGroup>
</div>
<DrawerFooter>
<DrawerClose class="h-[34px]" data-drawer-demo-confirm>
Confirm Delivery Time
</DrawerClose>
<DrawerClose variant="outline">Cancel</DrawerClose>
</DrawerFooter>
</DrawerContent>
<Toaster portal />
</Drawer>
<script>
import { createDrawer } from "@data-slot/drawer"
// Like useIsMobile: a bottom drawer with a swipe handle below 768px.
const query = matchMedia("(max-width: 767px)")
const triggerOf = (root: HTMLElement) =>
[...root.querySelectorAll('[data-slot="drawer-trigger"]')].find(
(element) => element.closest('[data-slot="drawer"]') === root
)
const update = () => {
const direction = query.matches ? "down" : "right"
const roots = [
...document.querySelectorAll<HTMLElement>("[data-drawer-responsive]"),
]
for (const root of roots) {
// An open popup lives in the body, outside its root.
const popup = document.getElementById(
triggerOf(root)?.getAttribute("aria-controls") ?? ""
)
for (const parent of [root, popup])
parent
?.querySelectorAll('[data-slot="drawer-handle"]')
.forEach((handle) => handle.toggleAttribute("hidden", !query.matches))
}
// Data Slot reads the direction when it binds, and restores it when
// destroyed, so rebind. Open drawers stay open: nested ones unbind first
// and bind last.
const stale = roots.filter(
(root) => root.dataset.swipeDirection !== direction
)
const open = stale.map((root) => createDrawer(root).isOpen)
for (const root of [...stale].reverse()) createDrawer(root).destroy()
stale.forEach((root, index) => {
root.dataset.swipeDirection = direction
const drawer = createDrawer(root, { open: open[index] })
// Reselect the trigger, which gets aria-expanded back.
const trigger = triggerOf(root)
if (open[index] && trigger?.id) drawer.open(trigger.id)
})
}
update()
document.addEventListener("astro:page-load", update)
query.addEventListener("change", update)
</script>
<script>
const initialize = () => {
for (const root of document.querySelectorAll<HTMLElement>(
"[data-drawer-demo]"
)) {
if (root.hasAttribute("data-bound")) continue
root.setAttribute("data-bound", "")
root.addEventListener("drawer:change", (event) => {
const { open, originalEvent } = (event as CustomEvent).detail
const target = originalEvent?.target
const confirm =
target instanceof Element
? target.closest("[data-drawer-demo-confirm]")
: null
if (open || !confirm) return
const selected = confirm
.closest('[data-slot="drawer-popup"]')
?.querySelector<HTMLElement>(
'[data-slot="radio-group-item"][aria-checked="true"]'
)
if (!selected) return
root.querySelector('[data-slot="toast"]')?.dispatchEvent(
new CustomEvent("toast:show", {
detail: {
title: "Delivery time confirmed",
description: selected.dataset.label,
},
})
)
})
}
}
initialize()
document.addEventListener("astro:page-load", initialize)
</script>Installation
npx shadcn@latest add @fulldev/drawer
Usage
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
<Drawer swipeDirection="down">
<DrawerTrigger>Open</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Drawer title</DrawerTitle>
<DrawerDescription>Drawer description</DrawerDescription>
</DrawerHeader>
<div class="p-4">Drawer content</div>
<DrawerFooter>
<DrawerClose variant="outline">Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
DrawerTrigger and DrawerClose render a Button and take its props.
Composition
DrawerContent renders the portal, backdrop, viewport, popup and inner content
wrapper. Use the lower-level exports to customize that structure.
Drawer
├── DrawerTrigger
└── DrawerContent
├── DrawerHeader
│ ├── DrawerTitle
│ └── DrawerDescription
├── content
└── DrawerFooter
└── DrawerClose
Custom Sizes
A vertical drawer sizes itself to its content, capped at calc(100dvh - 3rem).
A side drawer spans 75% of the viewport width, up to 24rem. Override with
h-*, max-h-*, w-* or max-w-* on DrawerContent. To make a region
scrollable, make it a flex item: <div class="flex-1 overflow-y-auto p-4">.
The popup exposes data-swipe-direction, so scope styles to a side with
data-[swipe-direction=down]: on DrawerContent or
group-data-[swipe-direction=down]/drawer-popup: on its descendants. It does not
set data-swipe-axis.
Position
Use swipeDirection on Drawer to set the side: up, right, down
(default) or left.
Move Goal
Set your daily activity goal.
---
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
---
<Drawer swipeDirection="left">
<DrawerTrigger variant="secondary">Open Left Drawer</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Move Goal</DrawerTitle>
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
</DrawerHeader>
<div class="flex-1 p-4">
<div class="size-full rounded-2xl bg-muted"></div>
</div>
<DrawerFooter>
<DrawerClose>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Swipe Handle
Use showSwipeHandle on DrawerContent to render a swipe handle.
Drawer
Drawer with a swipe handle.
---
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
---
<Drawer>
<DrawerTrigger variant="secondary">Open Drawer</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>Drawer with a swipe handle.</DrawerDescription>
</DrawerHeader>
<div class="flex-1 p-4">
<div class="rounded-2xl bg-muted group-data-[swipe-direction=down]/drawer-popup:h-80 group-data-[swipe-direction=down]/drawer-popup:w-full"></div>
</div>
<DrawerFooter>
<DrawerClose>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Nested
Place a complete Drawer inside another drawer’s content. Parent drawers stay
mounted behind the frontmost drawer, and Escape closes only the front one.
Drawer
Open another drawer from the same direction.
Nested Drawer
The parent drawer stays mounted behind this one.
Third Drawer
Two drawers are stacked behind this one.
Fourth Drawer
This is the frontmost drawer in the stack.
---
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const swipeDirection = "right"
const placeholder =
"bg-muted group-data-[swipe-direction=right]/drawer-popup:size-full group-data-[swipe-direction=down]/drawer-popup:aspect-video group-data-[swipe-direction=down]/drawer-popup:w-full"
---
<Drawer swipeDirection={swipeDirection} data-drawer-responsive>
<DrawerTrigger variant="secondary">Open Drawer</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Drawer</DrawerTitle>
<DrawerDescription>
Open another drawer from the same direction.
</DrawerDescription>
</DrawerHeader>
<div class="flex-1 p-4">
<div class={placeholder}></div>
</div>
<DrawerFooter>
<Drawer swipeDirection={swipeDirection} data-drawer-responsive>
<DrawerTrigger variant="outline">Open Nested Drawer</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Nested Drawer</DrawerTitle>
<DrawerDescription>
The parent drawer stays mounted behind this one.
</DrawerDescription>
</DrawerHeader>
<div class="flex-1 p-4">
<div class={placeholder}></div>
</div>
<DrawerFooter>
<Drawer swipeDirection={swipeDirection} data-drawer-responsive>
<DrawerTrigger variant="outline">Open Third Drawer</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Third Drawer</DrawerTitle>
<DrawerDescription>
Two drawers are stacked behind this one.
</DrawerDescription>
</DrawerHeader>
<div class="flex-1 p-4">
<div class={placeholder}></div>
</div>
<DrawerFooter>
<Drawer
swipeDirection={swipeDirection}
data-drawer-responsive
>
<DrawerTrigger variant="outline">
Open Fourth Drawer
</DrawerTrigger>
<DrawerContent showSwipeHandle>
<DrawerHeader>
<DrawerTitle>Fourth Drawer</DrawerTitle>
<DrawerDescription>
This is the frontmost drawer in the stack.
</DrawerDescription>
</DrawerHeader>
<div class="flex-1 p-4">
<div class={placeholder}></div>
</div>
<DrawerFooter>
<DrawerClose variant="outline">Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose variant="outline">Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose variant="outline">Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<DrawerClose variant="outline">Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<script>
import { createDrawer } from "@data-slot/drawer"
// Like useIsMobile: bottom drawers with a swipe handle below 768px.
const query = matchMedia("(max-width: 767px)")
const triggerOf = (root: HTMLElement) =>
[...root.querySelectorAll('[data-slot="drawer-trigger"]')].find(
(element) => element.closest('[data-slot="drawer"]') === root
)
const update = () => {
const direction = query.matches ? "down" : "right"
const roots = [
...document.querySelectorAll<HTMLElement>("[data-drawer-responsive]"),
]
for (const root of roots) {
// An open popup lives in the body, outside its root.
const popup = document.getElementById(
triggerOf(root)?.getAttribute("aria-controls") ?? ""
)
for (const parent of [root, popup])
parent
?.querySelectorAll('[data-slot="drawer-handle"]')
.forEach((handle) => handle.toggleAttribute("hidden", !query.matches))
}
// Data Slot reads the direction when it binds, and restores it when
// destroyed, so rebind. Open drawers stay open: nested ones unbind first
// and bind last.
const stale = roots.filter(
(root) => root.dataset.swipeDirection !== direction
)
const open = stale.map((root) => createDrawer(root).isOpen)
for (const root of [...stale].reverse()) createDrawer(root).destroy()
stale.forEach((root, index) => {
root.dataset.swipeDirection = direction
const drawer = createDrawer(root, { open: open[index] })
// Reselect the trigger, which gets aria-expanded back.
const trigger = triggerOf(root)
if (open[index] && trigger?.id) drawer.open(trigger.id)
})
}
update()
document.addEventListener("astro:page-load", update)
query.addEventListener("change", update)
</script>Non Modal
Set modal={false} and showOverlay={false} on DrawerContent to keep the
rest of the page interactive. Add disablePointerDismissal to keep the drawer
open on outside presses. modal="trap-focus" keeps focus inside the drawer
without making the page inert.
Non Modal Drawer
---
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
---
<Drawer modal={false} disablePointerDismissal swipeDirection="right">
<DrawerTrigger variant="outline">Non Modal</DrawerTrigger>
<DrawerContent showOverlay={false}>
<DrawerHeader>
<DrawerTitle>Non Modal Drawer</DrawerTitle>
</DrawerHeader>
<div class="flex-1 p-4">
<div class="rounded-2xl bg-muted group-data-[swipe-direction=right]/drawer-popup:size-full"></div>
</div>
<DrawerFooter>
<DrawerClose>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Snap Points
The official example moves between two snap points. Data Slot supports one
snapPoint (a fraction of the viewport, a pixel number, or a px or rem
string), so this example is in the React Drawer
docs only.
Responsive
Render a Dialog from md up and a Drawer below it, with hidden md:block
and md:hidden on the roots.
Edit profile
Make changes to your profile here. Click save when you're done.
Edit profile
Make changes to your profile here. Click save when you're done.
---
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
---
<!-- A Dialog from 768px (md), a Drawer below it. -->
<Dialog class="hidden md:block">
<DialogTrigger variant="outline">Edit Profile</DialogTrigger>
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're done.
</DialogDescription>
</DialogHeader>
<form class="grid items-start gap-6">
<div class="grid gap-3">
<Label for="email">Email</Label>
<Input type="email" id="email" value="shadcn@example.com" />
</div>
<div class="grid gap-3">
<Label for="username">Username</Label>
<Input id="username" value="@shadcn" />
</div>
<Button type="submit">Save changes</Button>
</form>
</DialogContent>
</Dialog>
<Drawer class="md:hidden">
<DrawerTrigger variant="outline">Edit Profile</DrawerTrigger>
<DrawerContent>
<DrawerHeader class="text-left">
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>
Make changes to your profile here. Click save when you're done.
</DrawerDescription>
</DrawerHeader>
<form class="grid items-start gap-6 p-4">
<div class="grid gap-3">
<Label for="email-drawer">Email</Label>
<Input type="email" id="email-drawer" value="shadcn@example.com" />
</div>
<div class="grid gap-3">
<Label for="username-drawer">Username</Label>
<Input id="username-drawer" value="@shadcn" />
</div>
<Button type="submit">Save changes</Button>
</form>
</DrawerContent>
</Drawer>Events
Use the drawer:open, drawer:close, drawer:toggle and drawer:set DOM
events for imperative changes. The root emits cancelable drawer:beforechange
and drawer:beforesnapchange events, followed by drawer:change (with
reason and originalEvent), drawer:change-complete and drawer:snapchange.
const drawer = document.querySelector('[data-slot="drawer"]')
drawer?.dispatchEvent(
new CustomEvent("drawer:set", {
detail: { open: true, snapPoint: "24rem" },
})
)
API Reference
Source: src/components/ui/drawer
Drawer prop | Type | Default |
|---|---|---|
swipeDirection | "up" | "right" | "down" | "left" | "down" |
modal | boolean | "trap-focus" | true |
disablePointerDismissal | boolean | false |
closeOnEscape | boolean | true |
defaultOpen | boolean | false |
snapPoint | number | string | null | null |
defaultSnapPoint | number | string | null | null |
triggerId | string | null | |
defaultTriggerId | string | null |
DrawerContent prop | Type | Default |
|---|---|---|
showOverlay | boolean | true |
showSwipeHandle | boolean | false |
container | string | |
keepMounted | boolean | |
initialFocus | boolean | string | |
finalFocus | boolean | string | |
overlayClass | string | |
viewportClass | string |
See the Data Slot docs for controllers, focus management, detached triggers and keyboard-aware viewports.