Drawer
A panel that slides in from the edge of the screen.
Installation
$
Usage
import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerBody, DrawerFooter, DrawerTitle, DrawerDescription } from "@/components/ui/drawer"Examples
Default
The prose layout: a short form you fill in and dismiss. The panel pads itself and spaces its children, so no wrapper is needed. If the content can outgrow the screen, use panel instead.
Panel
The panel layout for a surface you work in: a fixed header bar, a DrawerBody that scrolls under it, and a footer pinned to the bottom. Set layout once on DrawerContent and every part reads it.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| sideDrawerContent | "top" | "right" | "bottom" | "left" | "right" | Edge of the screen the drawer slides in from. |
| sizeDrawerContent | "sm" | "md" | "lg" | "xl" | "2xl" | "sm" | Maximum width of the panel for left/right drawers; top/bottom drawers span the full width. |
| layoutDrawerContent | "prose" | "panel" | "prose" | prose is the short form: the popup pads itself, spaces its children, and floats a close in the corner. panel is the application surface: the popup owns no padding, DrawerHeader becomes a fixed 64px bar with a wall-to-wall hairline and an inline close, and each region pads its own contents. Set once here; the parts read it, so a panel needs no other overrides. |
| showCloseButtonDrawerContent | boolean | true, or false under layout="panel" | Shows the X close button floating in the top corner. Off by default in a panel, where the header bar carries an inline one instead. |
| closeDrawerHeader | ReactNode | - | Replaces the panel bar's stock close button, for a header that needs its own accessible name, icon, or analytics attribute. Supply the whole control including its trigger: <DrawerClose render={<MyButton />} />. Read under layout="panel" only; under prose the close belongs to DrawerContent. |
| showCancelButtonDrawerFooter | boolean | false | Appends a ghost Cancel button that closes the drawer. |
"use client"
import * as React from "react"
import { Dialog as DrawerPrimitive } from "@base-ui/react/dialog"
import { IconX as XIcon } from "@tabler/icons-react"
import { cn } from "@/registry/lib/utils"
import { Button } from "@/registry/ui/button"
import { blurredOverlayClass } from "@/registry/lib/tokens"
// @use-when a bottom-anchored panel, the mobile counterpart to a Sheet.
function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Backdrop>) {
return (
<DrawerPrimitive.Backdrop
data-slot="drawer-overlay"
className={cn(
blurredOverlayClass,
className
)}
{...props}
/>
)
}
const drawerSizeMap = {
sm: "sm:max-w-sm",
md: "sm:max-w-md",
lg: "sm:max-w-lg",
xl: "sm:max-w-xl",
"2xl": "sm:max-w-2xl",
} as const
type DrawerSize = keyof typeof drawerSizeMap
type DrawerLayout = "prose" | "panel"
// Layout travels by context rather than a prop on each part, because it is ONE
// decision and the parts have to agree on it. A `variant` on DrawerHeader alone
// cannot reach the padding, the gap or the stock close button, all of which live
// on DrawerContent, so a caller would still be neutralising three props to get
// the bar they asked for. That was the actual complaint.
const DrawerLayoutContext = React.createContext<DrawerLayout>("prose")
// Shared so the bar's close and the corner's close cannot drift apart. Not
// exported: a plain value exported from a "use client" module is a client
// reference under RSC, and this one has no reason to leave the file.
const drawerCloseClass =
"tap-target ring-offset-background focus-visible:ring-ring-focus rounded-xs opacity-70 transition-opacity ease-out hover:opacity-100 focus-visible:ring-[3px] focus-visible:ring-offset-2 outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
function DrawerContent({
className,
children,
side = "right",
size = "sm",
layout = "prose",
showCloseButton,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Popup> & {
side?: "top" | "right" | "bottom" | "left"
size?: DrawerSize
layout?: DrawerLayout
showCloseButton?: boolean
}) {
const sizeClass = drawerSizeMap[size]
const isPanel = layout === "panel"
// Under `panel` the header owns an inline close, so the floating corner one
// would be a second control for the same job. Still overridable in both
// directions, which is why this reads the prop rather than the layout alone.
const closeButton = showCloseButton ?? !isPanel
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Popup
data-slot="drawer-content"
data-layout={layout}
className={cn(
"bg-background transition-[translate] motion-reduce:transition-none ease-out duration-200 data-[ending-style]:duration-150 fixed z-50 flex flex-col shadow-ring-md outline-none",
// `prose` pads the popup itself and spaces its children, which is
// right for a short form. `panel` owns no padding at all: its regions
// run edge to edge so a header hairline and a footer hairline reach
// both walls, and each region pads its own contents. `overflow-hidden`
// is what keeps a scrolled body from painting over the rounded
// corners, which only matters once the body scrolls.
!isPanel && "gap-4 px-4 pb-4 pt-10",
isPanel && "overflow-hidden",
side === "right" &&
`data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full top-3 bottom-3 right-3 w-3/4 rounded-xl ${sizeClass}`,
side === "left" &&
`data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full top-3 bottom-3 left-3 w-3/4 rounded-xl ${sizeClass}`,
side === "top" &&
"data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full inset-x-3 top-3 h-auto rounded-xl",
side === "bottom" &&
"data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full inset-x-3 bottom-3 h-auto rounded-xl",
className
)}
{...props}
>
<DrawerLayoutContext.Provider value={layout}>
{children}
</DrawerLayoutContext.Provider>
{closeButton && (
<DrawerPrimitive.Close
data-slot="drawer-close"
className={cn("absolute top-3 right-3", drawerCloseClass)}
>
<XIcon />
<span className="sr-only">Close</span>
</DrawerPrimitive.Close>
)}
</DrawerPrimitive.Popup>
</DrawerPortal>
)
}
function DrawerHeader({
className,
style,
children,
close,
...props
}: React.ComponentProps<"div"> & {
/**
* Replaces the bar's stock close button, for a header that needs its own:
* a different accessible name, an analytics or sound attribute, a project's
* own icon button. Supply the whole control including its trigger, normally
* `<DrawerClose render={<YourButton />} />`.
*
* Read under `layout="panel"` only, because that is the layout where the
* header owns a close at all. Under `prose` the close belongs to
* `DrawerContent` and this prop is ignored.
*/
close?: React.ReactNode
}) {
const layout = React.useContext(DrawerLayoutContext)
if (layout === "panel") {
return (
<div
data-slot="drawer-header"
data-layout="panel"
// A fixed height, not padding around whatever is inside. A panel bar is
// chrome, and chrome that changes height when a subtitle appears reads
// as a different component. 64px is the same shape the rest of the kit
// gives a bar: one 32px control plus one control of air.
className={cn(
"flex h-16 shrink-0 items-center justify-between gap-4 border-b border-border px-4",
className
)}
style={style}
{...props}
>
{/* `min-w-0` so a long title truncates against the close instead of
pushing it off the edge. */}
<div className="flex min-w-0 items-center gap-2">{children}</div>
{close ?? (
<DrawerPrimitive.Close
data-slot="drawer-close"
className={cn("shrink-0", drawerCloseClass)}
>
<XIcon />
<span className="sr-only">Close</span>
</DrawerPrimitive.Close>
)}
</div>
)
}
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col pr-8", className)}
// trim-pair, not pair: the title carries `cap-trim`. See dialog.tsx.
style={{ ...style, gap: "var(--spacing-rhythm-trim-pair)" }}
{...props}
>
{children}
</div>
)
}
function DrawerBody({ className, ...props }: React.ComponentProps<"div">) {
const layout = React.useContext(DrawerLayoutContext)
return (
<div
data-slot="drawer-body"
// `min-h-0` is the load-bearing half. A flex child's default `min-height:
// auto` refuses to shrink below its content, so `flex-1 overflow-y-auto`
// alone grows the popup instead of scrolling inside it, and the footer
// walks off the bottom of the screen.
className={cn(
"min-h-0 flex-1 overflow-y-auto",
layout === "panel" && "p-4",
className
)}
{...props}
/>
)
}
function DrawerFooter({
className,
showCancelButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCancelButton?: boolean
}) {
const layout = React.useContext(DrawerLayoutContext)
return (
<div
data-slot="drawer-footer"
className={cn(
"mt-auto flex flex-col gap-2",
layout === "panel" && "shrink-0 border-t border-border p-4",
className
)}
{...props}
>
{children}
{showCancelButton && (
<DrawerPrimitive.Close render={<Button variant="ghost">Cancel</Button>} />
)}
</div>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
// leading-snug, not leading-none, because this title expects to wrap.
// text-pretty rather than text-balance: see dialog.tsx.
className={cn("cap-trim text-foreground text-lg leading-snug font-semibold text-pretty", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm text-pretty", className)}
{...props}
/>
)
}
// Two layouts, because a drawer is asked to be two different things.
//
// `prose` (default) is the short form: a title over a description, some fields,
// a footer. The popup pads itself, spaces its children, and floats a close in
// the corner. Nothing about it changed.
//
// `panel` is the application surface: a cart, an inspector, a filter rail. It
// wants a fixed-height bar with the title and its metadata left, the close
// right, and a hairline running wall to wall, then a body that scrolls under it
// while the footer stays pinned. Composed from the same parts:
//
// <DrawerContent layout="panel">
// <DrawerHeader><DrawerTitle>Cart</DrawerTitle><Badge>3</Badge></DrawerHeader>
// <DrawerBody>...</DrawerBody>
// <DrawerFooter><Button>Checkout</Button></DrawerFooter>
// </DrawerContent>
//
// The layout is set once, on DrawerContent, and reaches the parts by context.
// It is deliberately not a `variant` on DrawerHeader: the padding, the gap and
// the stock close all live on the content, so a header-only switch would still
// leave a caller passing `p-0 gap-0 showCloseButton={false}` to get the bar.
// One decision, one place.
export {
Drawer,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerBody,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}