Drawer
An edge-anchored overlay panel that slides in from any side of the viewport.
Uses role="dialog" aria-modal="true"; focus is trapped; Esc closes by default and restores focus to the trigger.
Preview
tsx
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@arshad-shah/cynosure-react';
export default function Example() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button>Open drawer</Button>
</DrawerTrigger>
<DrawerContent side="right">
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Refine your search using the controls below.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="ghost">Close</Button>
</DrawerClose>
<Button>Apply</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
Variants
Section titled “Variants”DrawerContent accepts side="top" | "right" (default) | "bottom" | "left". Right is the canonical filter-panel placement; bottom is the canonical mobile sheet.
Preview
tsx
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@arshad-shah/cynosure-react';
const sides = ['top', 'right', 'bottom', 'left'] as const;
export default function Example() {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
{sides.map((side) => (
<Drawer key={side}>
<DrawerTrigger asChild>
<Button variant="outline">From {side}</Button>
</DrawerTrigger>
<DrawerContent side={side}>
<DrawerHeader>
<DrawerTitle>side="{side}"</DrawerTitle>
<DrawerDescription>
The panel slides in from the {side} edge of the viewport.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="ghost">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
);
}
Pass size="sm" | "md" (default) | "lg" | "xl" | "full". For top / bottom, size sets the height; for left / right, size sets the width.
Preview
tsx
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@arshad-shah/cynosure-react';
const sizes = ['sm', 'md', 'lg', 'xl'] as const;
export default function Example() {
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
{sizes.map((size) => (
<Drawer key={size}>
<DrawerTrigger asChild>
<Button variant="outline">Size {size}</Button>
</DrawerTrigger>
<DrawerContent side="right" size={size}>
<DrawerHeader>
<DrawerTitle>size="{size}"</DrawerTitle>
<DrawerDescription>
For horizontal sides, size controls the panel width.
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="ghost">Close</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
))}
</div>
);
}
With form
Section titled “With form”Embed a form inside DrawerContent. Submit closes the drawer; DrawerClose cancels.
Preview
tsx
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Input,
Label,
} from '@arshad-shah/cynosure-react';
import { useState } from 'react';
export default function Example() {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setOpen(false);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button>Invite teammate</Button>
</DrawerTrigger>
<DrawerContent side="right">
<form onSubmit={handleSubmit} style={{ display: 'contents' }}>
<DrawerHeader>
<DrawerTitle>Invite a teammate</DrawerTitle>
<DrawerDescription>
They will receive an email to join your workspace.
</DrawerDescription>
</DrawerHeader>
<div
style={{
display: 'grid',
gap: '1rem',
padding: '0 1.25rem',
}}
>
<div style={{ display: 'grid', gap: '0.375rem' }}>
<Label htmlFor="drawer-name">Name</Label>
<Input
id="drawer-name"
value={name}
onChange={setName}
placeholder="Alex Park"
required
/>
</div>
<div style={{ display: 'grid', gap: '0.375rem' }}>
<Label htmlFor="drawer-email">Email</Label>
<Input
id="drawer-email"
type="email"
value={email}
onChange={setEmail}
placeholder="alex@cynosure.app"
required
/>
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button type="button" variant="ghost">
Cancel
</Button>
</DrawerClose>
<Button type="submit">Send invite</Button>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
);
}
Controlled
Section titled “Controlled”Drive the open state externally to coordinate with route changes or async work.
Preview
tsx
import {
Button,
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@arshad-shah/cynosure-react';
import { useState } from 'react';
export default function Example() {
const [open, setOpen] = useState(false);
return (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', alignItems: 'flex-start' }}
>
<p style={{ fontSize: '0.875rem', margin: 0 }}>
open: <strong>{String(open)}</strong>
</p>
<Button onClick={() => setOpen(true)}>Open drawer</Button>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent side="right">
<DrawerHeader>
<DrawerTitle>Controlled drawer</DrawerTitle>
<DrawerDescription>Its open state is owned by the parent component.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="ghost">Close</Button>
</DrawerClose>
<Button onClick={() => setOpen(false)}>OK</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
);
}
PropTypeDefaultDescription
side
"top"|"right"|"bottom"|"left"
right
Edge the drawer slides in from.
size
"sm"|"md"|"lg"|"xl"|"full"
md
Drawer breadth.
closeOnOverlayClick
boolean
true
closeOnEscape
boolean
true
showCloseButton
boolean
true
closeLabel
string
Close
container
HTMLElement|(() => HTMLElement)
—
hideOverlay
boolean
false
Accessibility
Section titled “Accessibility”DrawerContentrenders withrole="dialog"andaria-modal="true", sharing the same focus-trap + scroll-lock kit asDialog.- Focus is trapped inside the drawer while open and returned to the trigger on close.
Esccloses by default (closeOnEscape={true}); suppress withcloseOnEscape={false}.- Clicking the backdrop closes by default (
closeOnOverlayClick={true}); suppress withcloseOnOverlayClick={false}. - The built-in close icon button is labelled via
closeLabel(“Close” by default); hide withshowCloseButton={false}.
Recipes
Section titled “Recipes”- Use
side="right"for filter and detail panels;side="bottom"works well as a mobile sheet. - Pair with route-based open state so deep links restore the drawer on refresh.
- Use
hideOverlaywhen stacking a drawer on top of an already-modal surface. - For multi-step flows, prefer
Dialogto keep a single focused task in view.