Overlays
Drawer
A modal panel docked to the right, left or bottom edge, for details or settings beside the page.
- Built on
<dialog>Invoker commandsclosedby<form method="dialog">@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.4 kB
- Dependencies
- React
Code
import { Button } from "@/components/equip/button";
import { Drawer, DrawerBody, DrawerFooter, DrawerTitle } from "@/components/equip/drawer";
const facts = [
["Commit", "a41c9e2 Fix retry on 502"],
["Region", "eu-west-1"],
["Started", "14:02 by Mira Holt"],
["Duration", "1 min 48 s"],
];
export default function Demo() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="demo-deploy-details">
Deployment details
</Button>
<Drawer id="demo-deploy-details">
<DrawerTitle>Deployment #1284</DrawerTitle>
<DrawerBody>
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: "8px 16px" }}>
{facts.map(([term, value]) => (
<div key={term} style={{ display: "contents" }}>
<dt>{term}</dt>
<dd style={{ margin: 0, color: "var(--eq-ink)" }}>{value}</dd>
</div>
))}
</dl>
</DrawerBody>
<DrawerFooter>
<Button type="submit" variant="outline" value="close">
Close
</Button>
<Button type="submit" value="rollback">
Roll back
</Button>
</DrawerFooter>
</Drawer>
</>
);
}Examples
Left side
Code
import { Button } from "@/components/equip/button";
import { Drawer, DrawerBody, DrawerFooter, DrawerTitle } from "@/components/equip/drawer";
const filters = ["Failed builds only", "Production", "Started by me", "Last 7 days"];
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-drawer-filters">
Filters
</Button>
<Drawer id="example-drawer-filters" side="left">
<DrawerTitle>Filter deployments</DrawerTitle>
<DrawerBody>
<div style={{ display: "grid", gap: 10 }}>
{filters.map((filter) => (
<label key={filter} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="checkbox" name="filter" value={filter} />
{filter}
</label>
))}
</div>
</DrawerBody>
<DrawerFooter>
<Button type="submit" variant="outline" value="reset">
Reset
</Button>
<Button type="submit" value="apply">
Apply filters
</Button>
</DrawerFooter>
</Drawer>
</>
);
}Bottom sheet
A bottom drawer sizes to its content up to 80% of the viewport height, then its body scrolls.
Code
import { Button } from "@/components/equip/button";
import { Drawer, DrawerBody, DrawerFooter, DrawerTitle } from "@/components/equip/drawer";
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-drawer-share">
Share build
</Button>
<Drawer id="example-drawer-share" side="bottom">
<DrawerTitle>Share build 1284</DrawerTitle>
<DrawerBody>
<p>
Anyone at Larder with the link can open the logs and artifacts for this build until
it expires on 30 November.
</p>
</DrawerBody>
<DrawerFooter>
<Button
type="button"
variant="outline"
command="close" commandfor="example-drawer-share"
>
Done
</Button>
</DrawerFooter>
</Drawer>
</>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Trigger buttons point at it with commandfor. |
side | "right" | "left" | "bottom" | "right" | The edge it docks to and slides in from. |
closedby | "any" | "closerequest" | "none" | "any" | Which user actions close it. any adds a click on the backdrop to Escape. |
...rest | ComponentProps<"dialog"> | none | Every native dialog attribute, including onClose, onCancel and ref. |
DrawerTitle | ComponentProps<"h2"> | none | The heading. The drawer is labelled by it. |
DrawerBody | ComponentProps<"div"> | none | The content. Takes the free height and scrolls. |
DrawerFooter | ComponentProps<"form"> | none | A method="dialog" form pinned to the bottom. Submit buttons inside close the drawer. |
Anatomy
| root | .eq-drawer | The native dialog, docked by data-side. |
|---|---|---|
| title | .eq-drawer-title | |
| body | .eq-drawer-body | |
| footer | .eq-drawer-footer | A form with method="dialog". |
Keyboard
| EnterSpace | On the trigger, opens the drawer and moves focus into it. |
|---|---|
| TabShift+Tab | Moves focus between the drawer's controls; the page behind is inert. |
| Escape | Closes the drawer and returns focus to the trigger. |
Notes
- Open it from any button with command="show-modal" and commandfor set to the drawer id. React does not type these attributes yet, so drawer.tsx declares them for every button.
- Close it from a submit button in DrawerFooter, from a button with command="close", with Escape, or with a click on the backdrop.
- It is a modal dialog: the browser traps focus, makes the page inert and restores focus to the trigger on close.
- It slides the full width of the panel rather than the 4px of other surfaces, so the motion says where it came from.
- Invoker commands need Chrome 135, Firefox 144 or Safari 26.2; on older engines Drawer adds one document click listener that runs show-modal and close for buttons whose commandfor matches its id. closedby is not in Safari yet; there a click handler closes the drawer when a click lands outside its box. The slide out needs transition of overlay, which only Chromium has; elsewhere the drawer slides in but closes at once.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/drawer.tsx118 lines
"use client";
import {
createContext,
useContext,
useEffect,
useId,
type ComponentProps,
type MouseEvent,
} from "react";
import "./drawer.css";
// React 19 does not type invoker commands yet. This lets any button take them.
declare module "react" {
interface ButtonHTMLAttributes<T> {
command?: string;
commandfor?: string;
}
}
const TitleId = createContext<string | undefined>(undefined);
export type DrawerProps = ComponentProps<"dialog"> & {
/** Required. Triggers point at it with commandfor. */
id: string;
/** The edge it docks to and slides in from. */
side?: "right" | "left" | "bottom";
};
/**
* A modal dialog docked to an edge. Opens with a native invoker: a button
* carrying command="show-modal" and commandfor set to this id. Engines without
* invoker commands get the same behaviour from a document click listener.
*/
export function Drawer({
id,
side = "right",
closedby = "any",
className,
onClick,
children,
...rest
}: DrawerProps) {
const titleId = useId();
useEffect(() => {
if ("command" in HTMLButtonElement.prototype) return;
function invoke(event: globalThis.MouseEvent) {
if (!(event.target instanceof Element)) return;
const invoker = event.target.closest("button[commandfor]");
if (!invoker || invoker.getAttribute("commandfor") !== id) return;
const drawer = document.getElementById(id);
if (!(drawer instanceof HTMLDialogElement)) return;
const command = invoker.getAttribute("command");
if (command === "show-modal" && !drawer.open) drawer.showModal();
if ((command === "close" || command === "request-close") && drawer.open) drawer.close();
}
document.addEventListener("click", invoke);
return () => document.removeEventListener("click", invoke);
}, [id]);
function handleClick(event: MouseEvent<HTMLDialogElement>) {
onClick?.(event);
// closedby="any" does this natively where supported.
if (event.defaultPrevented || closedby !== "any") return;
if ("closedBy" in HTMLDialogElement.prototype) return;
const drawer = event.currentTarget;
if (event.target !== drawer) return;
const box = drawer.getBoundingClientRect();
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom;
if (!inside) drawer.close();
}
return (
<TitleId.Provider value={titleId}>
<dialog
aria-labelledby={rest["aria-label"] ? undefined : titleId}
{...rest}
id={id}
closedby={closedby}
className={["eq-drawer", className].filter(Boolean).join(" ")}
data-side={side}
onClick={handleClick}
>
{children}
</dialog>
</TitleId.Provider>
);
}
export function DrawerTitle({ className, ...rest }: ComponentProps<"h2">) {
const id = useContext(TitleId);
return (
<h2 id={id} {...rest} className={["eq-drawer-title", className].filter(Boolean).join(" ")} />
);
}
export function DrawerBody({ className, ...rest }: ComponentProps<"div">) {
return <div {...rest} className={["eq-drawer-body", className].filter(Boolean).join(" ")} />;
}
/**
* A form with method="dialog": any submit button inside closes the drawer
* and sets returnValue to its value. No JavaScript involved.
*/
export function DrawerFooter({ className, ...rest }: ComponentProps<"form">) {
return (
<form
{...rest}
method="dialog"
className={["eq-drawer-footer", className].filter(Boolean).join(" ")}
/>
);
}components/equip/drawer.css140 lines
@layer equip {
.eq-drawer {
--_from: 100% 0;
box-sizing: border-box;
position: fixed;
inset: 0 0 0 auto;
width: min(400px, calc(100vw - var(--eq-space-6)));
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: var(--eq-space-4);
border: 0 solid var(--eq-line-strong);
border-left-width: 1px;
border-radius: var(--eq-radius-l) 0 0 var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
translate: var(--_from);
/* Leaving: quick. */
transition: translate var(--eq-dur) var(--eq-ease);
}
.eq-drawer[data-side="left"] {
--_from: -100% 0;
inset: 0 auto 0 0;
border-left-width: 0;
border-right-width: 1px;
border-radius: 0 var(--eq-radius-l) var(--eq-radius-l) 0;
}
.eq-drawer[data-side="bottom"] {
--_from: 0 100%;
inset: auto 0 0 0;
width: 100%;
height: auto;
max-height: min(80dvh, 640px);
border-left-width: 0;
border-top-width: 1px;
border-radius: var(--eq-radius-l) var(--eq-radius-l) 0 0;
}
/* Only when open: a display on the closed dialog would override the UA's display: none. */
.eq-drawer[open] {
display: flex;
flex-direction: column;
gap: var(--eq-space-3);
translate: none;
/* Arriving: the longer travel time, but on the plain ease: a spring would carry the
drawer past its edge and open a gap against the side of the screen. */
transition: translate var(--eq-dur-move) var(--eq-ease);
}
@starting-style {
.eq-drawer[open] {
translate: var(--_from);
}
}
/* The drawer takes focus as a container; its controls carry the ring. */
.eq-drawer:focus-visible {
outline: none;
}
.eq-drawer::backdrop {
background-color: transparent;
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-drawer[open]::backdrop {
background-color: var(--eq-backdrop);
}
@starting-style {
.eq-drawer[open]::backdrop {
background-color: transparent;
}
}
/* The slide out needs the drawer kept in the top layer, which only overlay can do. */
@supports (overlay: auto) {
.eq-drawer {
transition:
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-drawer[open] {
transition:
translate var(--eq-dur-move) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-drawer::backdrop {
transition:
background-color var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
.eq-drawer-title {
margin: 0;
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.01em;
}
.eq-drawer-body {
flex: 1;
min-height: 0;
overflow: auto;
color: var(--eq-ink-2);
}
.eq-drawer-body > :first-child {
margin-top: 0;
}
.eq-drawer-body > :last-child {
margin-bottom: 0;
}
.eq-drawer-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--eq-space-2);
margin: 0;
padding-top: var(--eq-space-3);
border-top: 1px solid var(--eq-line);
}
}