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

Share Q3 roadmap

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.

Filters

Show issues that are
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.

Unsaved changes

Your edits to Pricing page copy have not been published.

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

PropTypeDefaultWhat it does
idstringnoneRequired. Trigger buttons point at it with commandfor.
labelstringnoneThe sheet's accessible name.
detentsnumber[][0.5, 0.92]Resting heights as a share of the viewport. Sorted lowest first.
defaultDetentnumber0Index into the sorted detents for the opening height.
dismissiblebooleantrueLets Escape, a backdrop click and a downward fling close it. Off, a fling springs back.
...restComponentProps<"dialog">noneEvery native dialog attribute, including onClose and ref.
BottomSheetTitleComponentProps<"h2">noneThe visible heading.
BottomSheetBodyComponentProps<"div">noneThe content. Takes the free height and scrolls without moving the page behind.
--eq-bottom-sheet-widthCSS length36remIts width on wide screens, where it is centred.

Anatomy

root.eq-bottom-sheetThe native dialog. data-dragging while a pointer holds the handle.
handle.eq-bottom-sheet-handleA full-width button: drag, tap or use the arrow keys.
grip.eq-bottom-sheet-gripThe visible bar inside the handle; it carries the focus ring.
title.eq-bottom-sheet-title
body.eq-bottom-sheet-body

Keyboard

EnterSpaceOn the trigger, opens the sheet with focus on the handle.
ArrowUpArrowDownOn the handle, moves to the next taller or shorter detent.
EnterSpaceOn the handle, toggles between the lowest and tallest detents.
EscapeCloses the sheet and returns focus to the trigger, unless dismissible is off.
TabShift+TabMoves 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index