Skip to main content

Drawer

betav0.1.0

Modal 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

size: "sm" 420px · "md" 640px · "lg" 960px (default) · "xl" 1360px

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

PropType
Drawer.openboolean — 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.defaultOpenboolean — uncontrolled initial state. Default false.
DrawerTrigger.asChild / DrawerClose.asChildboolean — render the single child element (e.g. a Button) instead of a wrapping button.
DrawerClose.disabledboolean — 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.busyboolean — 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.titlestring — required. One line, truncated with the full text in a tooltip when cut. It is the drawer's accessible name.
DrawerHeader.overlinestring — a category line above the title.
DrawerHeader.badgeReactNode — after the title: a Badge (dot, count) or a Chip (labelled value).
DrawerHeader.avatarsReactNode — an AvatarGroup of the people viewing or editing the object.
DrawerHeader.actionsReactNode — 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 / backLabelstring — accessible names of the close and back controls. Defaults "Close" / "Back". Products localise them.
DrawerSubHeader.childrenReactNode — 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.asChildboolean — render the child (a form) as the scroll container, so it wraps fields and DrawerFooter. Default false.
DrawerFooter.childrenReactNode — the buttons, aligned to the end. Last child of DrawerBody; pinned to the bottom of a short body, scrolls with a long one.

Accessibility

2.1 AAWCAG pass

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.