Drawer
A dialog that slides in from an edge of the screen and keeps the page in sight beside it.
payload-cache · 4 issues
- Cached results outlive a model update#52bug
- Document the CLI client#51documentationcli
- Warm the cache after a deploy#49enhancement
- Report the hit rate#47enhancementgood first issue
- Filters slides in from the right; on a phone it takes the whole width.
- Tab stays inside. Escape, the cross or a click on the backdrop closes it, and focus goes back to Filters.
- Open Labels and scroll the list: it moves, the page behind does not.
Field marks
- Import
import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerBody } from "cennge-ui";- Size
- 2.1 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA modal dialog:
role="dialog", named by its title - Keyboard
- Tab Shift Tab Esc
- State
- Controlled with
isOpenandonOpenChange, or uncontrolled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Placement
- Left, right, top or bottom; right by default
- Tests
- end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: dialog-scroll, overlays
- Source
- src/components/drawer
When to use#
Use a Drawer for a task that belongs next to the page rather than in front of it: filters for a list, the settings of the thing on screen, details of a row. A side drawer leaves part of the page in view; on a phone a bottom drawer is a sheet the thumb can reach.
A Drawer is a Dialog placed at an edge: the same parts, the same focus trap, Escape and scroll lock, and the same handling of lists opened inside it.
Similar#
- Dialog sits in the middle of the screen, for a task that needs the reader's full attention.
- Sidebar is the app's navigation; it turns into a drawer on phones by itself.
- Popover is a small panel next to its button that leaves the page usable.
From any edge#
placement picks the edge. A left or right drawer is as tall as the screen and as wide as size says: sm 20rem, md 28rem, lg 42rem, or the whole width on a screen narrower than that. A top or bottom drawer spans the width and grows with its content up to 85% of the screen's height.
import {
Button,
Drawer,
DrawerBody,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
type DrawerPlacement,
} from "cennge-ui";
const placements: DrawerPlacement[] = ["left", "right", "top", "bottom"];
const repositories = [
{ name: "cennge-ui", description: "React components on Radix UI and Tailwind CSS v4" },
{ name: "ai-niche-scout", description: "Validate an AI startup idea" },
{ name: "payload-cache", description: "Caches the results of an expensive transformer" },
{ name: "spredo-crypto-screener", description: "Crypto project screener" },
{ name: "playwright-msi-scraper", description: "MSI product pages into structured JSON" },
];
export default function DrawerPlacements() {
return (
<div className="flex flex-wrap gap-3">
{placements.map((placement) => (
<Drawer key={placement}>
<DrawerTrigger>
<Button variant="flat">{placement[0]!.toUpperCase() + placement.slice(1)}</Button>
</DrawerTrigger>
<DrawerContent placement={placement}>
<DrawerHeader>
<DrawerTitle>Repositories</DrawerTitle>
</DrawerHeader>
<DrawerBody>
<ul className="flex flex-col gap-3">
{repositories.map((repository) => (
<li key={repository.name}>
<p className="font-medium">{repository.name}</p>
<p className="text-muted">{repository.description}</p>
</li>
))}
</ul>
</DrawerBody>
</DrawerContent>
</Drawer>
))}
</div>
);
}A sheet from the bottom#
A bottom drawer has rounded top corners and a handle. The handle is drawn, not dragged: the sheet closes with the cross, Escape or a tap on the backdrop. Past 85% of the screen's height the body scrolls.
import {
Button,
Drawer,
DrawerBody,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Snippet,
} from "cennge-ui";
export default function CloneSheet() {
return (
<Drawer>
<DrawerTrigger>
<Button color="primary">Clone</Button>
</DrawerTrigger>
<DrawerContent placement="bottom">
<DrawerHeader className="mx-auto w-full max-w-xl">
<DrawerTitle>Clone ai-niche-scout</DrawerTitle>
<DrawerDescription>Paste both lines into a terminal.</DrawerDescription>
</DrawerHeader>
<DrawerBody className="mx-auto w-full max-w-xl">
<Snippet variant="bordered" className="w-full">
{"git clone https://github.com/Cennge/ai-niche-scout.git\ncd ai-niche-scout"}
</Snippet>
</DrawerBody>
</DrawerContent>
</Drawer>
);
}Settings with a footer#
In a side drawer DrawerBody takes the height left between the header and the footer and scrolls on its own, so the buttons stay at the bottom edge. DrawerClose makes any button close the drawer.
import {
Button,
Drawer,
DrawerBody,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
Input,
Switch,
Textarea,
toast,
} from "cennge-ui";
export default function RepositorySettings() {
return (
<Drawer>
<DrawerTrigger>
<Button variant="flat">Settings</Button>
</DrawerTrigger>
<DrawerContent size="lg">
<DrawerHeader>
<DrawerTitle>Repository settings</DrawerTitle>
<DrawerDescription>Cennge/spredo-crypto-screener</DrawerDescription>
</DrawerHeader>
<DrawerBody className="flex flex-col gap-5">
<Input label="Name" defaultValue="spredo-crypto-screener" />
<Textarea label="Description" minRows={2} defaultValue="Crypto project screener: FastAPI and CoinGecko behind, Next.js in front" />
<Switch defaultSelected>Issues</Switch>
<Switch>Discussions</Switch>
<Switch defaultSelected>Deploy previews for pull requests</Switch>
</DrawerBody>
<DrawerFooter>
<DrawerClose>
<Button variant="flat">Cancel</Button>
</DrawerClose>
<DrawerClose>
<Button color="primary" onClick={() => toast.success("Settings saved")}>
Save
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}Accessibility#
- The drawer has
role="dialog", its name fromDrawerTitleand its description fromDrawerDescription. - While it is open, Tab and Shift+Tab cycle inside it and the page behind is hidden from screen readers. On close, focus goes back to the trigger, or, for a drawer opened from code, to the element that had it, if that element is still on the page.
- The end-to-end tests open a right drawer in Chromium, Firefox and WebKit, on desktop and phone profiles. After the slide they measure its right edge against the screen's, check that focus has moved inside, close it with Escape and look for focus back on the trigger; axe runs with it open. A Combobox opened in a drawer passes the same scroll tests as one in a Dialog: its list scrolls, the page behind does not.
Styling#
classNames takes overlay, content and closeButton. The dimmed backdrop is the overlay's ::before, so your own backdrop takes the before: variant, as in classNames={{ overlay: "before:bg-black/70" }}. A side drawer rounds its inner corners from 640px wide; a top or bottom drawer always does.
Props#
Drawer#
DrawerContent#
Also takes every attribute of <div>.