Drawer
A swipeable panel that slides in from an edge of the screen — dismiss it by swiping, dragging the handle, or tapping outside. Built on Base UI's drawer primitive.
Last updated August 8, 2026
Copied from shadcn/ui and coss ui.
- Adds a logical position="start"/"end" on top of the physical top/right/bottom/left, resolved from the ambient direction measured at the trigger, and used to derive swipeDirection automatically
- DrawerMenuTrigger's chevron flips with rtl:-scale-x-100, and menu item padding/checkbox thumb travel use logical pe-/ps- and a rtl:-translate-x flip instead of physical left/right values
- Guards against a spurious open-then-instant-dismiss: in re-render-heavy apps, the interaction that opens the drawer can produce a follow-up click Base UI reads as an outside-press in the same tick. Any outside-press within 200ms of opening is treated as that ghost press and cancelled
- Fixed two positioning bugs: justify-[right]/justify-[left] silently failed to generate any CSS (those keywords aren't part of Tailwind's justify-content value scale for the shorthand form), so every non-bottom/top drawer rendered on the physical left regardless of position — replaced with the [justify-content:right]/[justify-content:left] arbitrary-property form. Separately, DrawerPopup reserves space for the drag bar via padding on itself when showBar is set, which inset every child including DrawerFooter's own background — DrawerFooter now cancels that inset with a matching negative margin so its background reaches the handle edge
Overview
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerDemoExample() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline">Open drawer</Button>} />
<DrawerPopup showBar>
<DrawerHeader>
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>
Make changes to your profile here. Click save when you're done.
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>
<p className="text-sm text-muted-foreground">
Swipe down, drag the handle, or tap outside to dismiss.
</p>
</DrawerPanel>
<DrawerFooter>
<Button type="submit">Save changes</Button>
<DrawerClose render={<Button variant="outline">Cancel</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
)
}No footer
DrawerFooter is optional — skip it for a purely informational sheet that only needs to be swiped away.
import { BadgeCheckIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerDescription,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerNoFooterExample() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline">View receipt</Button>} />
<DrawerPopup showBar>
<DrawerHeader>
<DrawerTitle>Payment successful</DrawerTitle>
<DrawerDescription>
Your subscription is active. A receipt was sent to your email.
</DrawerDescription>
</DrawerHeader>
<DrawerPanel className="flex items-center gap-3">
<BadgeCheckIcon className="size-8 shrink-0 text-primary" />
<p className="text-sm text-muted-foreground">
Nothing left to do here — swipe down, drag the handle, or tap
outside to dismiss.
</p>
</DrawerPanel>
</DrawerPopup>
</Drawer>
)
}Installation
$ npx shadcn@latest add https://ui.persian-labs.ir/r/drawer.jsonUsage
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function Example() {
return (
<Drawer>
<DrawerTrigger render={<Button variant="outline">Open</Button>} />
<DrawerPopup showBar>
<DrawerHeader>
<DrawerTitle>Edit profile</DrawerTitle>
<DrawerDescription>
Make changes to your profile here.
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>{/* content */}</DrawerPanel>
<DrawerFooter>
<Button type="submit">Save changes</Button>
<DrawerClose render={<Button variant="outline">Cancel</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
)
}Positions
Pass position to control which edge the drawer slides in from.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const positions = ["top", "right", "bottom", "left"] as const
const variants = ["default", "inset"] as const
export function DrawerPositionsExample() {
return (
<div className="flex flex-wrap gap-2">
{positions.flatMap((position) =>
variants.map((variant) => (
<Drawer key={`${position}-${variant}`} position={position}>
<DrawerTrigger
render={
<Button variant="outline" className="capitalize">
{position} {variant === "inset" && "(inset)"}
</Button>
}
/>
<DrawerPopup showBar variant={variant}>
<DrawerHeader>
<DrawerTitle className="capitalize">
{position} {variant} drawer
</DrawerTitle>
<DrawerDescription>
Slides in from the {position} edge of the screen.
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>
<p className="text-sm text-muted-foreground">
Swipe, drag the handle, or tap outside to dismiss.
</p>
</DrawerPanel>
<DrawerFooter>
<Button type="submit">Save changes</Button>
<DrawerClose
render={<Button variant="outline">Cancel</Button>}
/>
</DrawerFooter>
</DrawerPopup>
</Drawer>
))
)}
</div>
)
}Variants
default rounds the corners touching the screen edge, straight has no rounding and skips the nested-drawer scale-down effect, and inset floats with a margin from the edge on larger screens.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const variants = ["default", "straight", "inset"] as const
export function DrawerVariantsExample() {
return (
<div className="flex flex-wrap gap-2">
{variants.map((variant) => (
<Drawer key={variant}>
<DrawerTrigger
render={
<Button variant="outline" className="capitalize">
{variant}
</Button>
}
/>
<DrawerPopup variant={variant} showBar>
<DrawerHeader>
<DrawerTitle className="capitalize">
{variant} variant
</DrawerTitle>
<DrawerDescription>
{variant === "default" &&
"Rounded top corners, flush with the screen edge."}
{variant === "straight" &&
"No rounded corners and no scale-down stacking effect."}
{variant === "inset" &&
"Floats with a margin from the screen edge on larger screens."}
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>
<p className="text-sm text-muted-foreground">
Swipe down, drag the handle, or tap outside to dismiss.
</p>
</DrawerPanel>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Close</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
))}
</div>
)
}Scrollable content
DrawerHeader and DrawerFooter stay fixed outside the scroll container — DrawerPanel is the only part that scrolls.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const terms = Array.from({ length: 40 }, (_, i) => `Section ${i + 1}`)
export function DrawerScrollableExample() {
return (
<Drawer position="right">
<DrawerTrigger render={<Button variant="outline">View terms</Button>} />
<DrawerPopup showBar>
<DrawerHeader>
<DrawerTitle>Terms of Service</DrawerTitle>
<DrawerDescription>
The header and footer stay fixed — only this panel scrolls.
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>
<div className="flex flex-col gap-3">
{terms.map((term) => (
<div key={term} className="text-sm">
<p className="font-medium text-foreground">{term}</p>
<p className="text-muted-foreground">
Placeholder body text to give this section some height, so the
panel actually needs to scroll.
</p>
</div>
))}
</div>
</DrawerPanel>
<DrawerFooter>
<Button type="submit">Accept</Button>
<DrawerClose render={<Button variant="outline">Decline</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
)
}Snap points
Pass snapPoints to have the drawer settle at preset heights instead of following the drag freely. Numbers between 0 and 1 are fractions of the viewport height. Snap points only apply to position="bottom" drawers.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const snapPoints = [0.3, 0.6, 1]
const cards = Array.from({ length: 20 }, (_, i) => i + 1)
export function DrawerSnapPointsExample() {
const [snapPoint, setSnapPoint] = React.useState<number | string | null>(
snapPoints[0] ?? null
)
return (
<Drawer
snapPoints={snapPoints}
snapPoint={snapPoint}
onSnapPointChange={setSnapPoint}
>
<DrawerTrigger
render={<Button variant="outline">Open snap drawer</Button>}
/>
<DrawerPopup showBar>
<DrawerHeader>
<DrawerTitle>Snap points</DrawerTitle>
<DrawerDescription>
Drag the handle — it snaps to 30%, 60%, or full height instead of
following your finger freely. Current snap point:{" "}
{String(snapPoint)}
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>
<div className="grid gap-3">
{cards.map((card) => (
<div
key={card}
className="flex h-12 items-center rounded-md bg-muted px-4 text-sm text-muted-foreground"
>
Card {card}
</div>
))}
</div>
</DrawerPanel>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Close</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
)
}Nested drawers
A drawer opened from inside another already-open drawer stacks on top of it, scaling the one behind it down slightly. Pass modal="trap-focus" on the inner drawer — the outer one already owns the page-level scroll lock, so the inner one should only trap focus.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerNestedExample() {
return (
<Drawer>
<DrawerTrigger
render={<Button variant="outline">Delete account</Button>}
/>
<DrawerPopup showBar variant="inset" className="max-w-sm">
<DrawerHeader>
<DrawerTitle>Delete account</DrawerTitle>
<DrawerDescription>
This will permanently delete your account and all of its data.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
{/* Nested inside an already-open modal drawer, so it only traps
focus instead of re-applying a page-level scroll lock on top
of the outer one. */}
<Drawer modal="trap-focus">
<DrawerTrigger
render={<Button variant="destructive">Delete</Button>}
/>
<DrawerPopup showBar variant="inset" className="max-w-sm">
<DrawerHeader>
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
<DrawerDescription>
This action cannot be undone.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<Button variant="destructive">Yes, delete it</Button>
<DrawerClose
render={<Button variant="outline">Cancel</Button>}
/>
</DrawerFooter>
</DrawerPopup>
</Drawer>
<DrawerClose render={<Button variant="outline">Cancel</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
)
}RTL
position="start" opens from the reading-start edge — the right in RTL, the left in LTR — instead of a hardcoded side.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerPanel,
DrawerPopup,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
export function DrawerRtlExample() {
return (
<Drawer position="start">
<DrawerTrigger render={<Button variant="outline">باز کردن کشو</Button>} />
<DrawerPopup showBar>
<DrawerHeader>
<DrawerTitle>ویرایش پروفایل</DrawerTitle>
<DrawerDescription>
تغییرات را اعمال کنید و روی ذخیره کلیک کنید.
</DrawerDescription>
</DrawerHeader>
<DrawerPanel>
<p className="text-sm text-muted-foreground">
با کشیدن به سمت شروع صفحه یا کلیک بیرون، کشو بسته میشود.
</p>
</DrawerPanel>
<DrawerFooter>
<Button type="submit">ذخیره تغییرات</Button>
<DrawerClose render={<Button variant="outline">انصراف</Button>} />
</DrawerFooter>
</DrawerPopup>
</Drawer>
)
}