Overlays
Bottom sheet
A modal panel that rises from the bottom edge and rests at set heights, for a menu or a short form.
- Built on
<dialog>Invoker commandsclosedbyPointer capture@starting-styleenv(safe-area-inset-bottom)- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.1 kB
- Dependencies
- React
Code
import { Button } from "@/components/equip/button";
import { BottomSheet, BottomSheetBody, BottomSheetTitle } from "@/components/equip/bottom-sheet";
function Icon({ d }: { d: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
</svg>
);
}
const actions = [
{ value: "link", label: "Copy link", d: "M7 9a3 3 0 0 0 4.2 0l2-2a3 3 0 0 0-4.2-4.2l-.7.7 M9 7a3 3 0 0 0-4.2 0l-2 2a3 3 0 0 0 4.2 4.2l.7-.7" },
{ value: "email", label: "Send by email", d: "M2 4h12v8H2Z M2 4l6 5 6-5" },
{ value: "pdf", label: "Export as PDF", d: "M8 2.5v8 M5 7.5l3 3 3-3 M3 13.5h10" },
];
export default function Demo() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="demo-share-sheet">
Share
</Button>
<BottomSheet id="demo-share-sheet" label="Share Q3 roadmap">
<BottomSheetTitle>Share Q3 roadmap</BottomSheetTitle>
<BottomSheetBody>
<form method="dialog" style={{ display: "grid", gap: 4, margin: 0 }}>
{actions.map((action) => (
<Button
key={action.value}
type="submit"
value={action.value}
variant="ghost"
lead={<Icon d={action.d} />}
style={{ justifyContent: "flex-start" }}
>
{action.label}
</Button>
))}
</form>
</BottomSheetBody>
</BottomSheet>
</>
);
}Examples
Three detents
Drag the handle, or focus it and press the arrow keys, to move between a peek, half and full height.
Code
import { Button } from "@/components/equip/button";
import { BottomSheet, BottomSheetBody, BottomSheetTitle } from "@/components/equip/bottom-sheet";
const filters = ["Open", "In review", "Blocked", "Done", "Archived", "Assigned to me", "Created by me", "Due this week"];
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-filter-sheet">
Filters
</Button>
<BottomSheet id="example-filter-sheet" label="Filters" detents={[0.3, 0.6, 0.92]} defaultDetent={1}>
<BottomSheetTitle>Filters</BottomSheetTitle>
<BottomSheetBody>
<fieldset style={{ display: "grid", gap: 12, margin: 0, padding: 0, border: 0 }}>
<legend style={{ marginBottom: 8, color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
Show issues that are
</legend>
{filters.map((filter) => (
<label key={filter} style={{ display: "flex", gap: 8, alignItems: "center" }}>
<input type="checkbox" name="filter" value={filter} defaultChecked={filter === "Open"} />
{filter}
</label>
))}
</fieldset>
</BottomSheetBody>
</BottomSheet>
</>
);
}Needs an answer
With dismissible off, Escape, the backdrop and a fling do nothing; a fling springs back to the lowest detent.
Code
import { Button } from "@/components/equip/button";
import { BottomSheet, BottomSheetBody, BottomSheetTitle } from "@/components/equip/bottom-sheet";
export default function Example() {
return (
<>
<Button variant="outline" command="show-modal" commandfor="example-unsaved-sheet">
Leave editor
</Button>
<BottomSheet id="example-unsaved-sheet" label="Unsaved changes" detents={[0.32]} dismissible={false}>
<BottomSheetTitle>Unsaved changes</BottomSheetTitle>
<BottomSheetBody>
<p style={{ color: "var(--eq-ink-2)" }}>Your edits to Pricing page copy have not been published.</p>
<form method="dialog" style={{ display: "grid", gap: 8, margin: 0 }}>
<Button type="submit" value="keep">
Keep editing
</Button>
<Button type="submit" value="discard" variant="outline">
Discard changes
</Button>
</form>
</BottomSheetBody>
</BottomSheet>
</>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Trigger buttons point at it with commandfor. |
label | string | none | The sheet's accessible name. |
detents | number[] | [0.5, 0.92] | Resting heights as a share of the viewport. Sorted lowest first. |
defaultDetent | number | 0 | Index into the sorted detents for the opening height. |
dismissible | boolean | true | Lets Escape, a backdrop click and a downward fling close it. Off, a fling springs back. |
...rest | ComponentProps<"dialog"> | none | Every native dialog attribute, including onClose and ref. |
BottomSheetTitle | ComponentProps<"h2"> | none | The visible heading. |
BottomSheetBody | ComponentProps<"div"> | none | The content. Takes the free height and scrolls without moving the page behind. |
--eq-bottom-sheet-width | CSS length | 36rem | Its width on wide screens, where it is centred. |
Anatomy
| root | .eq-bottom-sheet | The native dialog. data-dragging while a pointer holds the handle. |
|---|---|---|
| handle | .eq-bottom-sheet-handle | A full-width button: drag, tap or use the arrow keys. |
| grip | .eq-bottom-sheet-grip | The visible bar inside the handle; it carries the focus ring. |
| title | .eq-bottom-sheet-title | |
| body | .eq-bottom-sheet-body |
Keyboard
| EnterSpace | On the trigger, opens the sheet with focus on the handle. |
|---|---|
| ArrowUpArrowDown | On the handle, moves to the next taller or shorter detent. |
| EnterSpace | On the handle, toggles between the lowest and tallest detents. |
| Escape | Closes the sheet and returns focus to the trigger, unless dismissible is off. |
| TabShift+Tab | Moves through the sheet's controls; the page behind is inert. |
Notes
- Open it from any button with command="show-modal" and commandfor set to the sheet id, as with Dialog and Drawer.
- The sheet follows the finger exactly while dragging; on release it projects the flick's speed forward and settles on the nearest detent with the spring.
- A quick downward flick, or a release below half the lowest detent, closes it.
- The handle is a real button with aria-expanded, true at the tallest detent, so the drag gesture has a keyboard twin.
- It grows from the bottom edge rather than sliding, so the spring lifts the top edge and never opens a gap under the sheet.
- The backdrop dims in proportion to the height below the lowest detent, so it fades as the sheet is dragged away.
- It keeps clear of the home indicator with env(safe-area-inset-bottom); add viewport-fit=cover to your viewport meta for that to apply.
- After closing it returns to its opening height once the exit has finished, so it never jumps on the way out.
- Invoker commands need Chrome 135, Firefox 144 or Safari 26.2; older engines get a document click listener that does the same. closedby is not in Safari yet; there a click handler closes the sheet on a backdrop click. The backdrop's dim reads custom properties that ::backdrop inherits from the dialog, which older engines do not do; there the backdrop is fully dimmed throughout. The slide out needs transition of overlay, which only Chromium has; elsewhere the sheet 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/bottom-sheet.tsx238 lines
"use client";
import {
useEffect,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type SyntheticEvent,
} from "react";
import "./bottom-sheet.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 DEFAULT_DETENTS = [0.5, 0.92];
export type BottomSheetProps = Omit<ComponentProps<"dialog">, "closedby"> & {
/** Required. Triggers point at it with commandfor. */
id: string;
/** The sheet's accessible name. */
label: string;
/** Resting heights as a share of the viewport, lowest first. */
detents?: number[];
/** Index into detents for the height it opens at. */
defaultDetent?: number;
/** Lets Escape, a backdrop click and a downward fling close it. */
dismissible?: boolean;
};
type Drag = { startY: number; startHeight: number; samples: { y: number; t: number }[]; moved: boolean };
/**
* A modal dialog that rises from the bottom edge and rests at one of several
* heights. Opens with a native invoker: a button carrying command="show-modal"
* and commandfor set to this id.
*/
export function BottomSheet({
id,
label,
detents = DEFAULT_DETENTS,
defaultDetent = 0,
dismissible = true,
className,
style,
onClick,
onCancel,
onClose,
children,
...rest
}: BottomSheetProps) {
const stops = detents.filter((d) => d > 0 && d <= 1).sort((a, b) => a - b);
if (stops.length === 0) stops.push(DEFAULT_DETENTS[0]);
const last = stops.length - 1;
const start = Math.min(Math.max(0, defaultDetent), last);
const sheetRef = useRef<HTMLDialogElement>(null);
const drag = useRef<Drag | null>(null);
const swallowClick = useRef(false);
const [level, setLevel] = useState(start);
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 sheet = document.getElementById(id);
if (!(sheet instanceof HTMLDialogElement)) return;
const command = invoker.getAttribute("command");
if (command === "show-modal" && !sheet.open) sheet.showModal();
if ((command === "close" || command === "request-close") && sheet.open) sheet.close();
}
document.addEventListener("click", invoke);
return () => document.removeEventListener("click", invoke);
}, [id]);
// The height lives in a custom property written straight to the element, so a drag never re-renders.
function setHeight(share: number) {
sheetRef.current?.style.setProperty("--_h", String(share));
}
function settle(index: number) {
const next = Math.min(Math.max(0, index), last);
setLevel(next);
setHeight(stops[next]);
}
function handlePointerDown(event: PointerEvent<HTMLButtonElement>) {
const sheet = sheetRef.current;
if (!sheet || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
swallowClick.current = false;
drag.current = {
startY: event.clientY,
startHeight: sheet.getBoundingClientRect().height,
samples: [{ y: event.clientY, t: event.timeStamp }],
moved: false,
};
sheet.setAttribute("data-dragging", "");
}
function handlePointerMove(event: PointerEvent<HTMLButtonElement>) {
const state = drag.current;
if (!state) return;
const dy = event.clientY - state.startY;
if (Math.abs(dy) > 3) state.moved = true;
const height = Math.min(Math.max(state.startHeight - dy, 0), window.innerHeight);
setHeight(height / window.innerHeight);
state.samples.push({ y: event.clientY, t: event.timeStamp });
while (state.samples.length > 2 && event.timeStamp - state.samples[0].t > 100) state.samples.shift();
}
function handlePointerEnd(event: PointerEvent<HTMLButtonElement>) {
const state = drag.current;
const sheet = sheetRef.current;
drag.current = null;
if (!state || !sheet) return;
sheet.removeAttribute("data-dragging");
if (!state.moved) return;
swallowClick.current = true;
const first = state.samples[0];
const elapsed = Math.max(event.timeStamp - first.t, 1);
const velocity = (event.clientY - first.y) / elapsed; // px per ms, positive is down
const viewport = window.innerHeight;
const share = sheet.getBoundingClientRect().height / viewport;
// Where the sheet would coast to in 200ms: a quick flick counts for more than its distance.
const projected = share - (velocity * 200) / viewport;
if (dismissible && (velocity > 1 || projected < stops[0] / 2)) {
sheet.close();
return;
}
let nearest = 0;
for (let i = 1; i < stops.length; i++) {
if (Math.abs(stops[i] - projected) < Math.abs(stops[nearest] - projected)) nearest = i;
}
settle(nearest);
}
function handleHandleClick() {
if (swallowClick.current) {
swallowClick.current = false;
return;
}
settle(level === last ? 0 : last);
}
function handleHandleKey(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
settle(level + (event.key === "ArrowUp" ? 1 : -1));
}
}
function handleClick(event: MouseEvent<HTMLDialogElement>) {
onClick?.(event);
// closedby="any" does this natively where supported.
if (event.defaultPrevented || !dismissible) return;
if ("closedBy" in HTMLDialogElement.prototype) return;
const sheet = event.currentTarget;
if (event.target !== sheet) return;
const box = sheet.getBoundingClientRect();
const inside =
event.clientX >= box.left &&
event.clientX <= box.right &&
event.clientY >= box.top &&
event.clientY <= box.bottom;
if (!inside) sheet.close();
}
function handleCancel(event: SyntheticEvent<HTMLDialogElement>) {
onCancel?.(event);
if (!dismissible) event.preventDefault();
}
function handleClose(event: SyntheticEvent<HTMLDialogElement>) {
onClose?.(event);
const sheet = event.currentTarget;
// Back to the opening height once the sheet has finished leaving, so it never jumps on the way out.
const reset = () => {
if (sheet.open) return;
setLevel(start);
sheet.style.setProperty("--_h", String(stops[start]));
};
Promise.all(sheet.getAnimations().map((animation) => animation.finished)).then(reset, reset);
}
return (
<dialog
aria-label={label}
{...rest}
ref={sheetRef}
id={id}
closedby={dismissible ? "any" : "none"}
className={["eq-bottom-sheet", className].filter(Boolean).join(" ")}
style={{ ...style, "--_h": stops[start], "--_low": stops[0] } as CSSProperties}
onClick={handleClick}
onCancel={handleCancel}
onClose={handleClose}
>
<button
type="button"
className="eq-bottom-sheet-handle"
aria-label="Resize sheet"
aria-expanded={stops.length > 1 ? level === last : undefined}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onClick={handleHandleClick}
onKeyDown={handleHandleKey}
>
<span className="eq-bottom-sheet-grip" aria-hidden="true" />
</button>
{children}
</dialog>
);
}
export function BottomSheetTitle({ className, ...rest }: ComponentProps<"h2">) {
return <h2 {...rest} className={["eq-bottom-sheet-title", className].filter(Boolean).join(" ")} />;
}
/** The content. Takes the free height and scrolls without moving the page behind. */
export function BottomSheetBody({ className, ...rest }: ComponentProps<"div">) {
return <div {...rest} className={["eq-bottom-sheet-body", className].filter(Boolean).join(" ")} />;
}components/equip/bottom-sheet.css177 lines
@layer equip {
.eq-bottom-sheet {
box-sizing: border-box;
position: fixed;
inset: auto 0 0 0;
width: 100%;
max-width: min(var(--eq-bottom-sheet-width, 36rem), 100%);
height: calc(var(--_h, 0.5) * 100dvh);
max-height: 100dvh;
margin: 0 auto;
padding: 0 var(--eq-space-4) max(var(--eq-space-4), env(safe-area-inset-bottom));
overflow: hidden;
border: 1px solid var(--eq-line-strong);
border-bottom: 0;
border-radius: var(--eq-radius-l) var(--eq-radius-l) 0 0;
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: 0 100%;
/* Leaving: quick, straight down. */
transition: translate var(--eq-dur) var(--eq-ease);
}
/* Only when open: a display on the closed dialog would override the UA's display: none. */
.eq-bottom-sheet[open] {
display: flex;
flex-direction: column;
translate: none;
/* Arriving and settling grow the height from the bottom edge, so the spring's
overshoot lifts the top edge instead of opening a gap under the sheet. */
transition:
height var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur) var(--eq-ease);
}
@starting-style {
.eq-bottom-sheet[open] {
height: 0;
}
}
/* The sheet takes focus as a container; its controls carry the ring. */
.eq-bottom-sheet:focus-visible {
outline: none;
}
/* The dim follows the height: full at the lowest detent and above, fading as the sheet is dragged away. */
.eq-bottom-sheet::backdrop {
background-color: transparent;
transition: background-color var(--eq-dur) var(--eq-ease);
}
.eq-bottom-sheet[open]::backdrop {
background-color: color-mix(
in srgb,
var(--eq-backdrop) calc(min(1, var(--_h, 1) / var(--_low, 1)) * 100%),
transparent
);
}
@starting-style {
.eq-bottom-sheet[open]::backdrop {
background-color: transparent;
}
}
/* The slide out needs the sheet kept in the top layer, which only overlay can do. */
@supports (overlay: auto) {
.eq-bottom-sheet {
transition:
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-bottom-sheet[open] {
transition:
height var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-bottom-sheet::backdrop {
transition:
background-color var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
/* While a finger holds it, the sheet and the dim follow exactly. */
.eq-bottom-sheet[open][data-dragging],
.eq-bottom-sheet[open][data-dragging]::backdrop {
transition: none;
}
.eq-bottom-sheet-handle {
flex: none;
display: grid;
place-items: center;
height: var(--eq-space-5);
margin-inline: calc(-1 * var(--eq-space-4));
padding: 0;
border: 0;
background: transparent;
cursor: grab;
touch-action: none;
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-bottom-sheet-grip {
width: 36px;
height: 4px;
border-radius: var(--eq-radius-full);
background: var(--eq-line-strong);
transition:
width var(--eq-dur-move) var(--eq-spring),
background-color var(--eq-dur) var(--eq-ease);
}
@media (hover: hover) {
.eq-bottom-sheet-handle:hover .eq-bottom-sheet-grip {
background: var(--eq-ink-3);
}
}
.eq-bottom-sheet-handle:active {
scale: var(--eq-press);
}
.eq-bottom-sheet[data-dragging] .eq-bottom-sheet-handle {
cursor: grabbing;
}
.eq-bottom-sheet[data-dragging] .eq-bottom-sheet-grip {
width: 48px;
background: var(--eq-ink-3);
}
/* The ring goes on the grip: the handle runs edge to edge, where a ring would be clipped. */
.eq-bottom-sheet-handle:focus-visible {
outline: none;
}
.eq-bottom-sheet-handle:focus-visible .eq-bottom-sheet-grip {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-bottom-sheet-title {
flex: none;
margin: 0 0 var(--eq-space-3);
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.01em;
}
.eq-bottom-sheet-body {
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.eq-bottom-sheet-body > :first-child {
margin-top: 0;
}
.eq-bottom-sheet-body > :last-child {
margin-bottom: 0;
}
}