Drawer
A panel you can swipe away, from the bottom of a phone or any edge.
Vibra's drawer casts --shadow-modal, where shadcn's casts none, over the theme's --overlay scrim instead of 10% black, and sets its title in the display register at text-xl; a different register given to the title replaces that one whole. Its slide and fade run on --duration-slow and the emphasized ease rather than a fixed 450ms, and a flick's exit scales that token instead of 400ms, so the drawer appears and leaves at once under reduced motion. Swiping, snap points and nested drawers are Base UI's Drawer as shadcn wires it. It is a titled dialog that takes the focus to itself when it opens — not to a field, which on a phone would raise the keyboard over it; pass initialFocus for a form — and hands it back when it closes. A swipe is never the only way: a snap point wants a button that moves it too, and Escape and the scrim close it.
Install
npx shadcn@latest add @vibra/drawerNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const LINES = [
{ label: "Pro plan · 12 seats", amount: "$588.00" },
{ label: "Extra events · 40,000", amount: "$32.00" },
{ label: "VAT (23%)", amount: "$142.60" },
]
export default function DrawerDemo() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>Review the next invoice</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>October invoice</DrawerTitle>
<DrawerDescription>Charged to the card ending 4242 on October 1.</DrawerDescription>
</DrawerHeader>
<dl className="flex flex-col gap-2 p-4 text-sm">
{LINES.map((line) => (
<div key={line.label} className="flex justify-between gap-4">
<dt className="text-muted-foreground">{line.label}</dt>
<dd className="tabular-nums">{line.amount}</dd>
</div>
))}
<div className="flex justify-between gap-4 border-t border-rule pt-2 font-medium">
<dt>Total</dt>
<dd className="tabular-nums">$762.60</dd>
</div>
</dl>
<DrawerFooter>
<DrawerClose render={<Button />}>Looks right</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Mobile navigation
The pages a phone's tab bar has no room for, behind More: large targets in thumb's reach, the current one marked.
import { ChartColumnIcon, EllipsisIcon, LifeBuoyIcon, ReceiptTextIcon, SettingsIcon, UsersIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"
const PAGES = [
{ href: "/reports", label: "Reports", icon: ChartColumnIcon },
{ href: "/customers", label: "Customers", icon: UsersIcon },
{ href: "/billing/invoices", label: "Invoices", icon: ReceiptTextIcon },
{ href: "/settings", label: "Settings", icon: SettingsIcon },
{ href: "/help", label: "Help", icon: LifeBuoyIcon },
]
// A phone's tab bar has room for four; the rest live behind "More", in a
// drawer from the bottom that a thumb can reach and a swipe can dismiss. The
// pages are a grid of large targets; the current one is marked.
export default function DrawerNav({ current = "/customers" }: { current?: string }) {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>
<EllipsisIcon aria-hidden="true" data-icon="inline-start" />
More
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col gap-2 pb-4">
<DrawerHeader>
<DrawerTitle>More</DrawerTitle>
<DrawerDescription>Everything the tab bar leaves out.</DrawerDescription>
</DrawerHeader>
<nav aria-label="More pages" className="px-4">
<ul className="grid grid-cols-3 gap-2">
{PAGES.map(({ href, label, icon: Icon }) => (
<li key={href}>
<a
href={href}
aria-current={href === current ? "page" : undefined}
className="flex h-20 flex-col items-center justify-center gap-2 rounded-lg bg-surface text-sm ring-1 ring-border focus-ring hover:bg-muted aria-[current=page]:bg-brand-muted aria-[current=page]:font-medium"
>
<Icon aria-hidden="true" className="size-5" />
{label}
</a>
</li>
))}
</ul>
</nav>
</div>
</DrawerContent>
</Drawer>
)
}Snap points
Today's deliveries rest at a little under half the screen and open fully on a swipe up — or on the header's button, which says which way it will go.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"
import { StatusBadge } from "@/components/ui/status-badge"
const DELIVERIES = [
["NW-4821", "Harbour Logistics", "delivered"],
["NW-4822", "Keystone Dental", "in_progress"],
["NW-4823", "Riverside Florists", "in_progress"],
["NW-4824", "Lumen Studio", "pending"],
["NW-4825", "Alder & Co.", "pending"],
["NW-4826", "Kestrel Outdoor", "pending"],
["NW-4827", "Wavelength Health", "pending"],
["NW-4828", "Tidewater Books", "pending"],
] as const
const PEEK = 0.45
// Today's deliveries rest at a little under half the screen, so the map
// behind stays in view, and a swipe up opens them all. A swipe is not the
// only way: the button in the header moves between the two heights and says
// which it will do next.
export default function DrawerSnap() {
const [snap, setSnap] = React.useState<number | string | null>(PEEK)
const full = snap === 1
return (
<Drawer showSwipeHandle snapPoints={[PEEK, 1]} snapPoint={snap} onSnapPointChange={setSnap} onOpenChange={(open) => open && setSnap(PEEK)}>
<DrawerTrigger render={<Button variant="outline" />}>Today's deliveries</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="flex-row items-start justify-between gap-3 text-start">
<div className="flex flex-col gap-0.5">
<DrawerTitle>8 deliveries today</DrawerTitle>
<DrawerDescription>1 delivered, 2 on the road</DrawerDescription>
</div>
<Button variant="outline" size="sm" aria-expanded={full} onClick={() => setSnap(full ? PEEK : 1)}>
{full ? "Show fewer" : "Show all"}
</Button>
</DrawerHeader>
<ul className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pb-4">
{DELIVERIES.map(([id, customer, status]) => (
<li key={id} className="flex items-center gap-3 border-b border-border py-2.5 last:border-0">
<span className="font-mono text-xs">{id}</span>
<span className="min-w-0 flex-1 truncate text-sm">{customer}</span>
<StatusBadge status={status} size="sm" />
</li>
))}
</ul>
</DrawerContent>
</Drawer>
)
}From the side
A task form from the right edge, swiped away to the right, with initialFocus in its first field; adding the task closes it, says so, and returns the focus.
"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
const OWNERS = ["Saoirse Byrne", "Tomás Ortega", "Hiro Tanaka"]
// A task form from the right edge, swiped away to the right. A drawer
// focuses itself when it opens — on a phone a focused field would raise the
// keyboard over it — but this one is a form, so initialFocus puts the focus
// in its first field. A title the browser refuses keeps the drawer open; a
// good one closes it, says so, and the focus goes back to the button.
export default function DrawerSide() {
const [open, setOpen] = React.useState(false)
const [added, setAdded] = React.useState("")
const title = React.useRef<HTMLInputElement>(null)
function add(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setAdded(`Added “${new FormData(event.currentTarget).get("title")}” to Q4 launch.`)
setOpen(false)
}
return (
<div className="flex flex-col items-center gap-2">
<Drawer swipeDirection="right" open={open} onOpenChange={setOpen}>
<DrawerTrigger render={<Button variant="outline" />}>
<PlusIcon aria-hidden="true" data-icon="inline-start" />
Add a task
</DrawerTrigger>
<DrawerContent initialFocus={title}>
<form onSubmit={add} className="flex h-full w-full flex-col">
<DrawerHeader className="text-start">
<DrawerTitle>New task</DrawerTitle>
<DrawerDescription>Added to Q4 launch, in To do.</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-2">
<Label htmlFor="drawer-side-title">Title</Label>
<Input ref={title} id="drawer-side-title" name="title" required defaultValue="Send renewal terms to Harbour" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="drawer-side-owner">Owner</Label>
<NativeSelect id="drawer-side-owner" name="owner" defaultValue={OWNERS[0]}>
{OWNERS.map((owner) => (
<NativeSelectOption key={owner} value={owner}>
{owner}
</NativeSelectOption>
))}
</NativeSelect>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="drawer-side-due">Due</Label>
<Input id="drawer-side-due" name="due" type="date" defaultValue="2026-10-01" />
</div>
</div>
<DrawerFooter className="pt-4">
<Button type="submit">Add task</Button>
<DrawerClose render={<Button type="button" variant="outline" />}>Cancel</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
<p role="status" className="text-xs text-muted-foreground">
{added}
</p>
</div>
)
}Nested
Adding a recipient stacks a drawer on the list, which steps back behind it; closing the top one returns to the list with the new address in it.
"use client"
import * as React from "react"
import { MailIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// A drawer opened from a drawer: adding a recipient stacks on top of the
// list, which steps back behind it. Each is its own titled dialog; closing
// the top one returns to the one below with the focus on the button that
// opened it, and the new address is in the list.
export default function DrawerNested() {
const [recipients, setRecipients] = React.useState(["accounts@harbour.example"])
const [adding, setAdding] = React.useState(false)
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>Invoice recipients</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex w-full max-w-sm flex-col">
<DrawerHeader>
<DrawerTitle>Invoice recipients</DrawerTitle>
<DrawerDescription>Harbour Logistics' invoices go to every address here.</DrawerDescription>
</DrawerHeader>
<ul aria-label="Recipients" className="flex flex-col gap-2 p-4">
{recipients.map((address) => (
<li key={address} className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm ring-1 ring-border">
<MailIcon aria-hidden="true" className="size-4 text-muted-foreground" />
{address}
</li>
))}
</ul>
<DrawerFooter>
<Drawer showSwipeHandle open={adding} onOpenChange={setAdding}>
<DrawerTrigger render={<Button variant="outline" />}>
<PlusIcon aria-hidden="true" data-icon="inline-start" />
Add a recipient
</DrawerTrigger>
<DrawerContent>
<form
className="mx-auto flex w-full max-w-sm flex-col"
onSubmit={(event) => {
event.preventDefault()
const address = String(new FormData(event.currentTarget).get("email")).trim()
setRecipients((current) => (current.includes(address) ? current : [...current, address]))
setAdding(false)
}}
>
<DrawerHeader>
<DrawerTitle>Add a recipient</DrawerTitle>
<DrawerDescription>They get the next invoice, and every one after.</DrawerDescription>
</DrawerHeader>
<div className="flex flex-col gap-2 p-4">
<Label htmlFor="drawer-nested-email">Email</Label>
<Input id="drawer-nested-email" name="email" type="email" required defaultValue="finance@harbour.example" />
</div>
<DrawerFooter>
<Button type="submit">Add recipient</Button>
<DrawerClose render={<Button type="button" variant="outline" />}>Back</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Long content
An invoice's lines scroll in a named region that takes the focus, while the title stays at the top and the total at the foot.
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
const LINES = [
["Scale plan, 18 seats", 4320],
["Extra seats, 2", 480],
["Events over 1,000,000", 214.5],
["Forecasts add-on", 390],
["SSO add-on", 250],
["Priority support", 600],
["Data export, 4 runs", 96],
["Custom domain", 40],
["Sandbox workspace", 0],
["Onboarding session", 350],
["Loyalty credit", -500],
["VAT (23%)", 1447.05],
] as const
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
const total = LINES.reduce((sum, [, amount]) => sum + amount, 0)
// An invoice too long for a phone: the lines scroll in their own named region
// while the title stays at the top and the total and the answer at the foot.
// The region takes the focus, so it scrolls from the keyboard too, and the
// scroll stops at its ends rather than dragging the drawer.
export default function DrawerScroll() {
return (
<Drawer showSwipeHandle>
<DrawerTrigger render={<Button variant="outline" />}>Review invoice INV-2042</DrawerTrigger>
<DrawerContent>
<div className="mx-auto flex min-h-0 w-full max-w-sm flex-1 flex-col">
<DrawerHeader>
<DrawerTitle>Invoice INV-2042</DrawerTitle>
<DrawerDescription>Harbour Logistics, due October 1</DrawerDescription>
</DrawerHeader>
<div role="region" aria-label="Invoice lines" tabIndex={0} className="mx-4 mt-3 min-h-0 flex-1 overflow-y-auto overscroll-contain rounded-md focus-ring">
<dl className="flex flex-col text-sm">
{LINES.map(([label, amount]) => (
<div key={label} className="flex justify-between gap-4 border-b border-border py-2 last:border-0">
<dt className="text-muted-foreground">{label}</dt>
<dd className="tabular-nums">{money.format(amount)}</dd>
</div>
))}
</dl>
</div>
<div className="mx-4 flex justify-between gap-4 border-t border-rule py-3 text-sm font-medium">
<span>Total</span>
<span className="tabular-nums">{money.format(total)}</span>
</div>
<DrawerFooter>
<DrawerClose render={<Button />}>Approve for payment</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| swipeDirection | "down" | "up" | "left" | "right" | "down" | The direction that dismisses it — and so the edge it sits on. |
| showSwipeHandle | boolean | false | Draws the grab handle on the sheet's leading edge. |
| snapPoints | (number | string)[] | — | Heights the drawer rests at on the way open or shut: fractions of the screen, or px and rem. |
| DrawerContent.initialFocus | React.RefObject<HTMLElement> | — | Where the focus goes when it opens. The drawer itself by default; a form's first field for a form. |
| snapPoint / onSnapPointChange | number | string | null / (point) => void | — | The height it rests at, controlled — so a button can do what the swipe does. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "@base-ui/react/drawer"
import { cn } from "@/lib/utils"
type DrawerContextProps = {
hasSnapPoints: boolean
modal: DrawerPrimitive.Root.Props["modal"]
showSwipeHandle: boolean
swipeDirection: NonNullable<DrawerPrimitive.Root.Props["swipeDirection"]>
}
const DrawerContext = React.createContext<DrawerContextProps | null>(null)
function useDrawer() {
const context = React.useContext(DrawerContext)
if (!context) {
throw new Error("useDrawer must be used within a Drawer.")
}
return context
}
function Drawer({
modal = true,
showSwipeHandle = false,
snapPoints,
swipeDirection = "down",
...props
}: DrawerPrimitive.Root.Props & {
showSwipeHandle?: boolean
}) {
const hasSnapPoints = snapPoints != null && snapPoints.length > 0
const contextValue = React.useMemo(
() => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }),
[hasSnapPoints, modal, showSwipeHandle, swipeDirection]
)
return (
<DrawerContext.Provider value={contextValue}>
<DrawerPrimitive.Root
data-slot="drawer"
modal={modal}
snapPoints={snapPoints}
swipeDirection={swipeDirection}
{...props}
/>
</DrawerContext.Provider>
)
}
function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: DrawerPrimitive.Backdrop.Props) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 min-h-dvh bg-overlay opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-(--duration-slow) ease-(--ease-emphasized) select-none data-ending-style:pointer-events-none data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength)*var(--duration-slow))] data-snap-points:[--drawer-overlay-min-opacity:0.5] data-starting-style:opacity-0 data-swiping:duration-0 supports-backdrop-filter:backdrop-blur-xs supports-[-webkit-touch-callout:none]:absolute",
className
)}
{...props}
/>
)
}
function DrawerSwipeHandle({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-swipe-handle"
aria-hidden="true"
className={cn(
"relative z-10 flex shrink-0 cursor-grab transition-opacity duration-(--duration-base) group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end group-data-[swipe-direction=up]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:items-start after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 active:cursor-grabbing",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: DrawerPrimitive.Popup.Props) {
const { hasSnapPoints, modal, showSwipeHandle, swipeDirection } = useDrawer()
const swipeAxis =
swipeDirection === "down" || swipeDirection === "up" ? "y" : "x"
return (
<DrawerPortal data-slot="drawer-portal">
{modal === true && (
<DrawerOverlay data-snap-points={hasSnapPoints ? "" : undefined} />
)}
<DrawerPrimitive.Viewport
data-slot="drawer-viewport"
data-modal={modal}
className="pointer-events-none fixed inset-0 z-50 select-none data-[modal=true]:pointer-events-auto"
>
<DrawerPrimitive.Popup
data-slot="drawer-popup"
data-swipe-axis={swipeAxis}
data-snap-points={hasSnapPoints ? "" : undefined}
className={cn(
// Base.
"group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col bg-popover text-sm text-popover-foreground shadow-(--shadow-modal) transition-[transform,height,opacity,filter] duration-(--duration-slow) ease-(--ease-emphasized) will-change-transform outline-none select-none [interpolate-size:allow-keywords] data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b",
// Nested.
"data-nested-drawer-open:overflow-hidden data-nested-drawer-open:brightness-95",
// Bleed.
"after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full",
// Sizing.
"[--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=y]:data-snap-points:[--drawer-content-height:100dvh] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem]",
// Stack.
"[--bleed:3rem] [--peek:1rem] [--stack-height:var(--drawer-frontmost-height,var(--drawer-height,0px))] [--stack-peek-offset:max(0px,calc((var(--nested-drawers)-var(--stack-progress))*var(--peek)))] [--stack-progress:clamp(0,var(--drawer-swipe-progress),1)] [--stack-scale-base:max(0,calc(1-(var(--nested-drawers)*var(--stack-step))))] [--stack-scale:clamp(0,calc(var(--stack-scale-base)+(var(--stack-step)*var(--stack-progress))),1)] [--stack-shrink:calc(1-var(--stack-scale))] [--stack-step:0.05]",
// Transitions.
"data-ending-style:transform-(--closed-transform) data-ending-style:opacity-[0.9999] data-ending-style:duration-[calc(var(--drawer-swipe-strength)*var(--duration-slow))] data-nested-drawer-swiping:duration-0 data-ending-style:data-nested-drawer-swiping:duration-[calc(var(--drawer-swipe-strength)*var(--duration-slow))] data-starting-style:transform-(--closed-transform) data-swiping:duration-0 data-ending-style:data-swiping:duration-[calc(var(--drawer-swipe-strength)*var(--duration-slow))]",
// Axis: y.
"data-[swipe-axis=y]:inset-x-0 data-[swipe-axis=y]:data-nested-drawer-open:h-(--stack-height)",
// Axis: x.
"data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row",
// Direction: down.
"data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)] data-[swipe-direction=down]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)-var(--stack-peek-offset)-(var(--stack-shrink)*var(--stack-height)))]",
// Direction: up.
"data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)] data-[swipe-direction=up]:[--translate-y:calc(var(--drawer-snap-point-offset,0px)+var(--drawer-swipe-movement-y)+var(--stack-peek-offset)+(var(--stack-shrink)*var(--stack-height)))]",
// Direction: left.
"data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)] data-[swipe-direction=left]:[--translate-x:calc(var(--drawer-swipe-movement-x)+var(--stack-peek-offset)+(var(--stack-shrink)*100%))]",
// Direction: right.
"data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)] data-[swipe-direction=right]:[--translate-x:calc(var(--drawer-swipe-movement-x)-var(--stack-peek-offset)-(var(--stack-shrink)*100%))]",
className
)}
{...props}
>
{showSwipeHandle && <DrawerSwipeHandle />}
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-(--duration-slow) ease-(--ease-standard) select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none"
)}
>
{children}
</DrawerPrimitive.Content>
</DrawerPrimitive.Popup>
</DrawerPrimitive.Viewport>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex shrink-0 flex-col gap-0.5 p-4 pb-0 group-data-[swipe-axis=y]/drawer-popup:text-center md:gap-0.5 md:text-start",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex shrink-0 flex-col gap-2 p-4 pt-0", className)}
{...props}
/>
)
}
/**
* A class naming one of the theme's type registers — type-display and its kin.
* A register sets the family, the weight and the tracking in one class, which
* the class merge cannot see as replacing the title's own type, so the two
* stayed side by side and the stylesheet's order decided between them.
*/
const TYPE_REGISTER = /(?:^|\s)type-[a-z]/
function DrawerTitle({ className, ...props }: DrawerPrimitive.Title.Props) {
// The title's own type is a register too: one given to it replaces it
// whole — family, weight, size, tracking and ink — rather than sharing the
// element with it.
const ownType = typeof className === "string" && TYPE_REGISTER.test(className)
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
!ownType && "type-display text-xl text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: DrawerPrimitive.Description.Props) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-balance text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerSwipeHandle,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}