Drawer
betav0.1.0Modal side panel anchored to the right edge, used to create, edit or inspect one object while the page stays in view behind it. Built on @radix-ui/react-dialog (focus trap, Escape and outside-click dismiss, focus return, scroll lock), like Dialog. Use Dialog for a confirmation or a short focused question; never use Drawer for a permanent side menu or a bottom toolbar. Compound: Drawer > DrawerTrigger + DrawerContent > DrawerHeader + DrawerSubHeader? (inside DrawerTabs when the drawer has tabs) + DrawerBody > content + DrawerFooter. DrawerBody is the only scroll container and holds the footer, which is not fixed: when the drawer holds a form, render the form AS the body (`<DrawerBody asChild><form>`) so the submit button stays inside it. Default size is lg (960px). A drawer may open one second-level drawer, at least as wide, which shows a back control instead of the close control. Hosts with classification banners set --fds-drawer-inset-top / --fds-drawer-inset-bottom once at their root.
Variants
Examples
// Creation form: the form IS the body, so the submit button stays inside it.
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button priority="primary">Create malware</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader
title="Create a malware"
actions={<Button priority="secondary">Create multiple entities</Button>}
/>
<DrawerBody asChild>
<form onSubmit={handleSubmit}>
<Input label="Name" required />
<Textarea label="Description" />
<DrawerFooter>
<DrawerClose asChild>
<Button priority="secondary">Cancel</Button>
</DrawerClose>
<Button type="submit" priority="primary">Create</Button>
</DrawerFooter>
</form>
</DrawerBody>
</DrawerContent>
</Drawer>// Saving: busy blocks the close control, DrawerClose, Escape and the outside click.
<DrawerContent busy={isSubmitting}>
<DrawerHeader title="Update the malware" />
<DrawerBody asChild>
<form onSubmit={handleSubmit}>
<fieldset disabled={isSubmitting} className="contents">…fields…</fieldset>
<DrawerFooter>
<DrawerClose asChild>
<Button priority="secondary">Cancel</Button>
</DrawerClose>
<Button type="submit" priority="primary" loading={isSubmitting}>Save</Button>
</DrawerFooter>
</form>
</DrawerBody>
</DrawerContent>// Header extras: overline (category), badge or chip, people, actions.
<DrawerHeader
overline="Risk scenario"
title={entity.name}
badge={<Chip label="CVSS 9.8" severity="critical" />}
avatars={<AvatarGroup items={viewers} aria-label="Users viewing this entity" />}
actions={
<>
<IconButton icon={<Icon name="refresh-cw" aria-hidden />} aria-label="Refresh" priority="tertiary" />
<IconButton icon={<Icon name="ellipsis-vertical" aria-hidden />} aria-label="More actions" priority="tertiary" />
</>
}
/>// Tabs: the list stays fixed in the sub-header, each panel scrolls in the body.
<DrawerContent>
<DrawerHeader title="Update the malware" />
<DrawerTabs defaultValue="overview">
<DrawerSubHeader>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
</DrawerSubHeader>
<DrawerBody>
<TabsContent value="overview">…</TabsContent>
<TabsContent value="details">…</TabsContent>
</DrawerBody>
</DrawerTabs>
</DrawerContent>// List drawer: search and creation buttons stay visible while the list scrolls.
<DrawerContent>
<DrawerHeader title="Add entities" />
<DrawerSubHeader>
<SearchField placeholder="Search…" />
<Button priority="primary">Create</Button>
</DrawerSubHeader>
<DrawerBody>{results}</DrawerBody>
</DrawerContent>// Second level: opened from the first drawer, at least as wide, back control
// instead of the close control. Escape and the outside click go back to level 1.
// The opener is the nested Drawer's own DrawerTrigger, so focus returns to it.
<DrawerContent size="md">
<DrawerHeader title="Add individuals" />
<DrawerBody>
<Drawer open={creating} onOpenChange={setCreating}>
<DrawerTrigger asChild>
<Button priority="primary">Create an individual</Button>
</DrawerTrigger>
<DrawerContent size="md">
<DrawerHeader title="Create an individual" />
<DrawerBody>…</DrawerBody>
</DrawerContent>
</Drawer>
</DrawerBody>
</DrawerContent>// Loading the drawer's data: a Spinner in the body, no footer.
<DrawerContent>
<DrawerHeader title="Malware" />
<DrawerBody className="items-center justify-center">
<Spinner label="Loading" />
</DrawerBody>
</DrawerContent>Props
| Prop | Type |
|---|---|
Drawer.open | boolean — controlled open state (Radix Root). |
Drawer.onOpenChange | (open: boolean) => void — fires on every open-state change: trigger, close or back control, DrawerClose, Escape, outside click. |
Drawer.defaultOpen | boolean — uncontrolled initial state. Default false. |
DrawerTrigger.asChild / DrawerClose.asChild | boolean — render the single child element (e.g. a Button) instead of a wrapping button. |
DrawerClose.disabled | boolean — also forced while the drawer is busy. While disabled or busy, DrawerClose sets aria-disabled and stops the click in the capture phase, so an asChild anchor or a child passing disabled={false} cannot close the drawer, and the child's own onClick does not run. |
DrawerContent.size | "sm" | "md" | "lg" | "xl" — 420 / 640 / 960 / 1360 px. Default "lg". A second-level drawer narrower than its parent warns in development and renders at the parent's size. |
DrawerContent.busy | boolean — a submit is in flight: the close or back control and every DrawerClose are disabled, Escape and the outside click are ignored, aria-busy is set. Disable the fields and set `loading` on the primary Button yourself. Default false. |
DrawerHeader.title | string — required. One line, truncated with the full text in a tooltip when cut. It is the drawer's accessible name. |
DrawerHeader.overline | string — a category line above the title. |
DrawerHeader.badge | ReactNode — after the title: a Badge (dot, count) or a Chip (labelled value). |
DrawerHeader.avatars | ReactNode — an AvatarGroup of the people viewing or editing the object. |
DrawerHeader.actions | ReactNode — at most three IconButtons plus one Button or one Combobox (md, lg, xl); everything in one ellipsis-vertical menu in sm. |
DrawerHeader.onBack | () => void — replaces the close control with a chevron-left back control. A second-level drawer shows the back control without it (it closes that level). |
DrawerHeader.closeLabel / backLabel | string — accessible names of the close and back controls. Defaults "Close" / "Back". Products localise them. |
DrawerSubHeader.children | ReactNode — fixed zone under the header: a TabsList (inside DrawerTabs) or a list's search and buttons. |
DrawerTabs (Tabs props) | A Tabs root that keeps the panel layout: TabsList in DrawerSubHeader, TabsContent in DrawerBody. |
DrawerBody.asChild | boolean — render the child (a form) as the scroll container, so it wraps fields and DrawerFooter. Default false. |
DrawerFooter.children | ReactNode — the buttons, aligned to the end. Last child of DrawerBody; pinned to the bottom of a short body, scrolls with a long one. |
Accessibility
Semantics: role="dialog" from Radix and aria-modal="true" set by the component (Radix sets none), aria-labelledby wired to the title DrawerHeader renders, so a drawer cannot exist without an accessible name. aria-busy while busy. Keyboard: on open, focus goes to the panel itself (announced with its title), not to the first header control — that would open an avatar tooltip or land on an action; the next Tab reaches the first control, as the products' MUI drawers do. A consumer that wants a field focused calls event.preventDefault() in onOpenAutoFocus and focuses it. Focus is trapped in the panel; Escape closes the topmost drawer only (on a second level that is going back) and is ignored while busy; focus returns to the trigger. The close and back controls are IconButtons (tertiary, md) with localisable labels. The body takes a tab stop only while it overflows (axe scrollable-region-focusable), so a read-only drawer can be scrolled from the keyboard. Its focus ring is the standard ring-2 ring-focus drawn inset (focus-visible:ring-inset) instead of offset: the body spans the panel's full width and an outer offset would be clipped by the panel edges. A cut title keeps its full text as the accessible name, so nothing is lost to a screen reader; while it is cut, it also takes a tab stop with the standard focus ring, and focus opens the tooltip that shows the full text (RFC §9 Q16). A title that fits takes no tab stop. Motion: slide 150ms ease-out in both directions through @keyframes (src/styles/motion.css); under prefers-reduced-motion the same keyframes run with zero travel, as a fade. The overlay is a non-text scrim (bg-elevation-default-layer-0 at 80%, backdrop-blur-sm), not a contrast pair. A second-level drawer keeps a transparent overlay so only one scrim shows. Contrast ratios: computed by pnpm check:contrast (reports/contrast-report.md) from the pairs above, in both modes.