Drawer

A modal panel that slides in from the right edge.

Overview

A record or a form the reader works on beside the page, with actions that stay in view.

Record

Do
import { useState } from "react";
import { Button, Drawer, KeyValue, KeyValueList } from "@hackcove/ui";

function Example() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Open HC-65
      </Button>
      {open && (
        <Drawer
          title="HC-65 · Tabs"
          onClose={() => setOpen(false)}
          footer={
            <>
              <Button variant="ghost" onClick={() => setOpen(false)}>
                Cancel
              </Button>
              <Button onClick={() => setOpen(false)}>Save</Button>
            </>
          }
        >
          <KeyValueList>
            <KeyValue label="Status">In progress</KeyValue>
            <KeyValue label="Owner">Jacobus</KeyValue>
          </KeyValueList>
        </Drawer>
      )}
    </>
  );
}
A record or a form beside the page: the title names it, the body scrolls and the actions stay pinned in footer.
Don't
import { useState } from "react";
import { Body, Button, Drawer } from "@hackcove/ui";

function Example() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Delete build
      </Button>
      {open && (
        <Drawer
          title="Delete build?"
          size="sm"
          onClose={() => setOpen(false)}
          footer={<Button onClick={() => setOpen(false)}>Delete</Button>}
        >
          <Body>This removes the build and every snapshot in it.</Body>
        </Drawer>
      )}
    </>
  );
}
A yes-or-no question is a Dialog: a full-height panel for one confirmation is more than it asks.

Footer

Do
import { useState } from "react";
import { Button, Drawer, Field, Input } from "@hackcove/ui";

function Example() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Rename
      </Button>
      {open && (
        <Drawer
          title="Rename"
          size="sm"
          onClose={() => setOpen(false)}
          footer={<Button onClick={() => setOpen(false)}>Save</Button>}
        >
          <Field label="Name" htmlFor="drawer-rename">
            <Input id="drawer-rename" defaultValue="Hack Cove" />
          </Field>
        </Drawer>
      )}
    </>
  );
}
The actions go in footer, so Save stays in view while the body scrolls.
Don't
import { useState } from "react";
import { Button, Drawer, Field, Input, Stack } from "@hackcove/ui";

function Example() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Rename
      </Button>
      {open && (
        <Drawer title="Rename" size="sm" onClose={() => setOpen(false)}>
          <Stack>
            <Field label="Name" htmlFor="drawer-rename-body">
              <Input id="drawer-rename-body" defaultValue="Hack Cove" />
            </Field>
            <Button onClick={() => setOpen(false)}>Save</Button>
          </Stack>
        </Drawer>
      )}
    </>
  );
}
A Save in the body scrolls out of view with a long form and leaves the footer empty.

Usage

  • Render it only while it is open: {open && <Drawer … onClose={() => setOpen(false)} />}. It opens itself on mount.
  • Use it for a record to read or edit, or a form with several fields; a yes-or-no question is a Dialog.
  • The actions go in footer, pinned under the body, which scrolls between the header and the footer.
  • size is sm, md (the default) or lg; choose by the content, sm for one field and lg for a table.
  • Set closeLabel to the reader's language when the page is not in English.

Accessibility

  • A Drawer is the browser's own <dialog> opened with showModal(): the page behind goes inert, focus stays inside it and Esc closes it.
  • It is named by its title, an h2, through aria-labelledby.
  • The header's close button is an IconButton named by closeLabel ("Close" by default); a click on the scrim closes it too.
  • Closing, or unmounting it, hands focus back to the element that opened it.
  • The slide-in is switched off under prefers-reduced-motion: reduce.

API

Drawer

A modal slide-over anchored to the right edge, on the browser's own <dialog>: it is opened with showModal() on mount, so render it only while it is open. The page behind goes inert, Esc closes it and focus stays inside; a click on the scrim (the <dialog> element itself, not its contents) closes it too. The header holds the title and a close button; the body scrolls between it and an optional pinned footer.


closeLabel

The accessible name of the close button.

Type
string
Default
Close

footer

Actions pinned under the scrolling body, right-aligned.

Type
ReactNode

onClose required

Called when the drawer closes: Esc, a click on the scrim or the close button.

Type
() => void

size

md (the default) holds a form or a record; sm is narrower and lg wider.

Type
"sm" | "md" | "lg"
Default
md

title required

The drawer's accessible name, shown as its heading.

Type
ReactNode