Drawer
A slide-in panel that overlays the main content.
import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';
const DrawerPreview = () => {
return (
<Drawer>
<Drawer.Trigger asChild>
<Button>Open Drawer</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Drawer Title</Drawer.Title>
</Drawer.Header>
<Drawer.Description>Drawer content goes here.</Drawer.Description>
<Drawer.Actions className="flex gap-2">
<Drawer.Close asChild>
<Button variant="primary" className="grow">
Submit
</Button>
</Drawer.Close>
<Drawer.Close asChild>
<Button className="grow" variant="outline">
Close
</Button>
</Drawer.Close>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
);
};
export default DrawerPreview; Dependencies
Source Code
import { Modal } from '@/components/modal';
import { cn, cva } from '@/lib/utils/classnames';
type DrawerProps = React.ComponentProps<typeof Modal>;
const Drawer = (props: DrawerProps) => {
return <Modal {...props} />;
};
type DrawerSide = 'left' | 'right' | 'bottom' | 'left-bottom' | 'right-bottom';
const drawerContentStyle = cva({
base: [
'[--drawer-detach:calc(var(--radius)*3*var(--radius-bump))] [--drawer-p:--spacing(4)] [--drawer-stack:--spacing(4)]',
'overflow-x-hidden! flex w-full max-w-screen flex-col overflow-y-auto border border-border bg-background p-(--drawer-p) *:shrink-0',
'has-[[data-modal-focus-catcher]:first-child+[data-drawer-header],[data-drawer-header]:first-child]:pt-0 has-[[data-drawer-actions]:last-child]:pb-0',
'rounded-2xl',
'has-data-[status=open]:opacity-80 [&>:not(dialog)]:transition-opacity has-data-[status=open]:[&>:not(dialog)]:opacity-0',
'backdrop:bg-black/20 in-data-[status=open]:backdrop:opacity-0 not-data-[status=open]:backdrop:opacity-0 backdrop:backdrop-blur-sm',
'transition-[translate,margin,opacity] ease-emphasized-decelerate not-data-[status=open]:ease-emphasized-accelerate',
'backdrop:transition-opacity backdrop:ease-in-out',
'motion-reduce:transition-none motion-reduce:backdrop:transition-none',
'duration-400 not-data-[status=open]:duration-250 not-data-[status=open]:backdrop:delay-150 not-data-[status=open]:backdrop:duration-200',
],
variants: {
side: {
right:
'my-(--drawer-detach) mr-(--drawer-detach) ml-auto h-[calc(100dvh-2*var(--drawer-detach))] max-h-none max-w-lg not-data-[status=open]:translate-x-full has-data-[status=open]:mr-[calc(var(--drawer-detach)+var(--drawer-stack))]',
left: 'my-(--drawer-detach) mr-auto ml-(--drawer-detach) h-[calc(100dvh-2*var(--drawer-detach))] max-h-none max-w-lg not-data-[status=open]:-translate-x-full has-data-[status=open]:ml-[calc(var(--drawer-detach)+var(--drawer-stack))]',
bottom:
'mx-(--drawer-detach) mt-auto mb-(--drawer-detach) max-h-[calc(100svh-(--spacing(16)))] min-h-[50svh] w-[calc(100%-2*var(--drawer-detach))] not-data-[status=open]:translate-y-full has-data-[status=open]:mb-[calc(var(--drawer-detach)+var(--drawer-stack))]',
'right-bottom': [
'md:my-(--drawer-detach) md:mr-(--drawer-detach) md:ml-auto md:h-[calc(100dvh-2*var(--drawer-detach))] md:max-h-none md:max-w-lg md:not-data-[status=open]:translate-x-full md:has-data-[status=open]:mr-[calc(var(--drawer-detach)+var(--drawer-stack))]',
'max-md:mx-(--drawer-detach) max-md:mt-auto max-md:mb-(--drawer-detach) max-md:max-h-[calc(100svh-(--spacing(16)))] max-md:min-h-[50svh] max-md:w-[calc(100%-2*var(--drawer-detach))] max-md:not-data-[status=open]:translate-y-full max-md:has-data-[status=open]:mb-[calc(var(--drawer-detach)+var(--drawer-stack))]',
],
'left-bottom': [
'md:my-(--drawer-detach) md:mr-auto md:ml-(--drawer-detach) md:h-[calc(100dvh-2*var(--drawer-detach))] md:max-h-none md:max-w-lg md:not-data-[status=open]:-translate-x-full md:has-data-[status=open]:ml-[calc(var(--drawer-detach)+var(--drawer-stack))]',
'max-md:mx-(--drawer-detach) max-md:mt-auto max-md:mb-(--drawer-detach) max-md:max-h-[calc(100svh-(--spacing(16)))] max-md:min-h-[50svh] max-md:w-[calc(100%-2*var(--drawer-detach))] max-md:not-data-[status=open]:translate-y-full max-md:has-data-[status=open]:mb-[calc(var(--drawer-detach)+var(--drawer-stack))]',
],
} satisfies Record<DrawerSide, string | string[]>,
},
defaultVariants: {
side: 'right-bottom',
},
});
interface DrawerContentProps
extends React.ComponentProps<typeof Modal.Content> {
side?: DrawerSide;
}
const DrawerContent = ({
className,
children,
side = 'right-bottom',
...props
}: DrawerContentProps) => {
return (
<Modal.Content
data-side={side}
className={cn(drawerContentStyle({ side }), className)}
{...props}
>
{children}
</Modal.Content>
);
};
const DrawerTrigger = Modal.Trigger;
const DrawerClose = Modal.Close;
const DrawerTitle = ({
children,
className,
...props
}: React.ComponentProps<typeof Modal.Title>) => {
return (
<Modal.Title className={cn('font-semibold', className)} {...props}>
{children}
</Modal.Title>
);
};
const DrawerDescription = ({
children,
className,
...props
}: React.ComponentProps<typeof Modal.Description>) => {
return (
<Modal.Description className={cn('pb-2', className)} {...props}>
{children}
</Modal.Description>
);
};
const DrawerBleed = ({
className,
...props
}: React.ComponentPropsWithRef<'div'>) => {
return <div className={cn('-mx-(--drawer-p)', className)} {...props} />;
};
const DrawerHeader = ({
className,
children,
...props
}: React.ComponentPropsWithRef<'div'>) => {
return (
<DrawerBleed
className={cn(
'sticky top-0 z-10 mb-(--drawer-p) border-border border-b bg-background p-(--drawer-p)',
className
)}
{...props}
data-drawer-header=""
>
{children}
</DrawerBleed>
);
};
const DrawerActions = ({
className,
children,
...props
}: React.ComponentPropsWithRef<'div'>) => {
return (
<DrawerBleed
className={cn(
'sticky bottom-0 mt-auto flex gap-(--drawer-p) border-border border-t bg-background p-(--drawer-p)',
className
)}
{...props}
data-drawer-actions=""
>
{children}
</DrawerBleed>
);
};
const CompoundDrawer = Object.assign(Drawer, {
Content: DrawerContent,
Header: DrawerHeader,
Title: DrawerTitle,
Description: DrawerDescription,
Actions: DrawerActions,
Bleed: DrawerBleed,
Trigger: DrawerTrigger,
Close: DrawerClose,
});
export { CompoundDrawer as Drawer }; Features
- Edge Placement: Anchors to any edge via
side, with responsive desktop-panel / mobile-sheet pairings built in - Radius-aware: Detachment and corner radius derive from the
--radiusdial — flush and square at--radius: 0, floating and rounded as roundness increases - Stackable: Drawers open on top of one another using the native dialog top-layer; covered drawers recede and their backdrops don’t compound
- Focus Management: Automatically traps focus within the dialog (via
Modal) - Controlled & Uncontrolled: Supports both controlled and uncontrolled modes
- Customizable Actions: Built-in support for common dialog actions
Anatomy
<Drawer>
<Drawer.Trigger />
<Drawer.Content>
<Drawer.Header>
<Drawer.Title />
</Drawer.Header>
<Drawer.Description />
<Drawer.Actions>
<Drawer.Close />
</Drawer.Actions>
</Drawer.Content>
</Drawer>
API Reference
Drawer
Extends the Modal component.
Drawer.Content
Extends the Modal.Content component.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "left" | "right" | "bottom" | "left-bottom" | "right-bottom" | "right-bottom" | Edge the drawer anchors to. |
Single-edge values (left, right, bottom) pin to that edge at every
breakpoint. Hyphenated values are {desktop}-{mobile}: right-bottom is a
right-hand panel on desktop that becomes a bottom sheet on mobile.
Detachment and corner radius are not props — they derive from the global
--radius dial. At --radius: 0 the drawer is flush to the edge and square; as
roundness increases it floats away from the edge with matching rounded corners.
Drawer.Trigger
Extends the Modal.Trigger component.
Drawer.Close
Extends the Modal.Close component.
Drawer.Title
Extends the Modal.Title component.
Drawer.Description
Extends the Modal.Description component.
Drawer.Header
Extends the div element.
Drawer.Actions
Extends the div element.
Examples
Simple
import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';
const DrawerPreview = () => {
return (
<Drawer>
<Drawer.Trigger asChild>
<Button>Open Drawer</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Drawer Title</Drawer.Title>
</Drawer.Header>
<Drawer.Description>Drawer content goes here.</Drawer.Description>
<Drawer.Actions className="flex gap-2">
<Drawer.Close asChild>
<Button variant="primary" className="grow">
Submit
</Button>
</Drawer.Close>
<Drawer.Close asChild>
<Button className="grow" variant="outline">
Close
</Button>
</Drawer.Close>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
);
};
export default DrawerPreview; Tall Content
import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';
const DrawerTallContent = () => {
return (
<Drawer>
<Drawer.Trigger asChild>
<Button>Open Drawer</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Drawer Title</Drawer.Title>
</Drawer.Header>
{Array(24)
.fill(null)
.map((_, index) => (
<p key={index} className="mb-4">
This is paragraph {index + 1}.
</p>
))}
<Drawer.Actions className="flex gap-2">
<Drawer.Close asChild>
<Button className="grow">Submit</Button>
</Drawer.Close>
<Drawer.Close asChild>
<Button className="grow" variant="outline">
Close
</Button>
</Drawer.Close>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
);
};
export default DrawerTallContent; Controlled
import { useState } from 'react';
import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';
const DrawerControlled = () => {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = () => {
setIsSubmitting(true);
setTimeout(() => {
setIsSubmitting(false);
setOpen(false);
}, 3000);
};
const handleCancel = () => {
setOpen(false);
};
return (
<Drawer
open={open}
onOpenChange={(isOpen) => setOpen(isSubmitting ? true : isOpen)}
>
<Button onClick={() => setOpen(true)}>Open Drawer</Button>
<Drawer.Content inert={isSubmitting}>
<Drawer.Header>
<Drawer.Title>Drawer Title</Drawer.Title>
</Drawer.Header>
<p>Drawer dangerous content goes here.</p>
<Drawer.Actions>
<Button
className="grow"
variant="destructive"
isLoading={isSubmitting}
onClick={handleSubmit}
disabled={isSubmitting}
>
Delete Everything
</Button>
<Button
className="grow"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
I'm not sure
</Button>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
);
};
export default DrawerControlled; Placement
import { useState } from 'react';
import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';
const SIDES = [
'left',
'right',
'bottom',
'left-bottom',
'right-bottom',
] as const;
const DrawerSide = () => {
const [side, setSide] = useState<(typeof SIDES)[number]>('right-bottom');
const [open, setOpen] = useState(false);
return (
<div className="flex flex-wrap justify-center gap-2">
{SIDES.map((value) => (
<Button
key={value}
variant="outline"
onClick={() => {
setSide(value);
setOpen(true);
}}
>
{value}
</Button>
))}
<Drawer open={open} onOpenChange={setOpen}>
<Drawer.Content side={side}>
<Drawer.Header>
<Drawer.Title>side="{side}"</Drawer.Title>
</Drawer.Header>
<Drawer.Description>
Hyphenated values pick a desktop edge and fall back to a bottom
sheet on mobile — resize the viewport to see the switch.
</Drawer.Description>
<Drawer.Actions>
<Drawer.Close asChild>
<Button className="grow">Close</Button>
</Drawer.Close>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
</div>
);
};
export default DrawerSide; Stacked
import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';
const NestedDrawer = ({ depth }: { depth: number }) => {
return (
<Drawer>
<Drawer.Trigger asChild>
<Button variant="outline">Open Drawer {depth}</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Drawer {depth}</Drawer.Title>
</Drawer.Header>
<Drawer.Description>
Each drawer recedes behind the next, only the topmost backdrop dims
the page, and Escape closes one level at a time.
</Drawer.Description>
<NestedDrawer depth={depth + 1} />
<Drawer.Actions>
<Drawer.Close asChild>
<Button className="grow" variant="outline">
Close
</Button>
</Drawer.Close>
</Drawer.Actions>
</Drawer.Content>
</Drawer>
);
};
const DrawerStacked = () => {
return <NestedDrawer depth={1} />;
};
export default DrawerStacked; Best Practices
-
Placement: Use
right-bottom(the default) orleft-bottomfor content panels so they read as a side panel on desktop and a reachable sheet on mobile. Reserve single-edge values for cases that should not switch (e.g. aleftnav drawer on every breakpoint). -
Stacking: Stacking works, but keep it shallow. A drawer that spawns another drawer is best reserved for a genuine sub-flow; for linear steps, replace the content of a single drawer instead.
-
Content: Always include a
Drawer.Titleso the dialog has an accessible name. Keep content focused and use descriptive action labels (avoid “OK/Cancel”).
Previous
Divider
Next
Field