Sheet

Click each button to open the sheet from that side. Press Esc or click the backdrop to close.

Open from any side

Filters

Menu

Left-side navigation typically lives here.

Announcement

Top sheet — good for system-wide alerts or quick search.

Actions

Bottom sheet — common pattern on mobile for context menus.


  
/*
 * Sheet — overlay
 *
 * 4-side slide-in panel built on native <dialog> (modal). Authors call
 * dialog.showModal() to open. Position is set via data-side; CSS
 * handles transform-in animation. Per answered design question: slide
 * from the relevant edge + fade backdrop; reduced-motion drops the
 * slide to fade-only.
 *
 *   <dialog class="sheet" data-side="right">
 *     <header class="sheet__header">…</header>
 *     <div class="sheet__body">…</div>
 *     <footer class="sheet__footer">…</footer>
 *   </dialog>
 */

@layer malevich.components {
  .sheet {
    --sheet-duration: var(--motion-base);

    margin: 0;
    padding: 0;

    background-color: var(--color-surface-raised);
    color: var(--color-ink-strong);
    border: 0;

    box-shadow: var(--shadow-overlay);

    display: flex;
    flex-direction: column;

    /* Default placement = right */
    position: fixed;
    inset-block-start: 0;
    inset-block-end: 0;
    inset-inline-end: 0;
    inline-size: var(--sheet-size);
    max-inline-size: 100vw;
    block-size: 100vh;
    max-block-size: 100vh;

    transform: translateX(100%);
    transition: transform var(--sheet-duration) var(--motion-easing-default);
  }

  .sheet[open] {
    transform: translateX(0);
  }

  .sheet::backdrop {
    background-color: var(--color-surface-overlay);
    opacity: 0;
    transition: opacity var(--sheet-duration) var(--motion-easing-default);
  }

  .sheet[open]::backdrop {
    opacity: 1;
  }

  /* Side placements */

  .sheet[data-side="right"] {
    inset-inline-start: auto;
    inset-inline-end: 0;
    transform: translateX(100%);
  }
  .sheet[data-side="right"][open] {
    transform: translateX(0);
  }

  .sheet[data-side="left"] {
    inset-inline-start: 0;
    inset-inline-end: auto;
    transform: translateX(-100%);
  }
  .sheet[data-side="left"][open] {
    transform: translateX(0);
  }

  .sheet[data-side="top"] {
    inset-block-start: 0;
    inset-block-end: auto;
    inset-inline-start: 0;
    inset-inline-end: 0;
    inline-size: auto;
    max-inline-size: 100vw;
    block-size: var(--sheet-size);
    transform: translateY(-100%);
  }
  .sheet[data-side="top"][open] {
    transform: translateY(0);
  }

  .sheet[data-side="bottom"] {
    inset-block-start: auto;
    inset-block-end: 0;
    inset-inline-start: 0;
    inset-inline-end: 0;
    inline-size: auto;
    max-inline-size: 100vw;
    block-size: var(--sheet-size);
    transform: translateY(100%);
  }
  .sheet[data-side="bottom"][open] {
    transform: translateY(0);
  }

  /* Layout slots */

  .sheet__header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-gap-elements-m);
    padding: var(--space-inset-block-m);
    border-block-end: var(--border-width-hairline) solid var(--color-border-muted);
  }

  .sheet__header > h1,
  .sheet__header > h2,
  .sheet__header > h3 {
    margin: 0;
    font-family: var(--font-heading-section-family);
    font-size: var(--font-heading-section-size);
    font-weight: var(--font-heading-section-weight);
    line-height: var(--font-heading-section-line-height);
  }

  .sheet__close {
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    padding: var(--space-inset-element-s);
    background: transparent;
    color: var(--color-ink-regular);
    border: 0;
    cursor: pointer;
    border-radius: var(--radius-button);
    inline-size: var(--size-control-s);
    block-size: var(--size-control-s);
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  .sheet__close:hover {
    background-color: var(--color-surface-canvas);
    color: var(--color-ink-strong);
  }

  .sheet__close:focus-visible {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-hairline);
  }

  .sheet__body {
    flex: 1;
    overflow: auto;
    padding: var(--space-inset-block-m);
  }

  .sheet__footer {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-gap-elements-s);
    padding: var(--space-inset-block-m);
    border-block-start: var(--border-width-hairline) solid var(--color-border-muted);
  }

  /* Reduced motion drops the slide to a pure fade. */
  @media (prefers-reduced-motion: reduce) {
    .sheet,
    .sheet[data-side] {
      transform: none;
      opacity: 0;
      transition: opacity var(--sheet-duration) var(--motion-easing-default);
    }
    .sheet[open],
    .sheet[data-side][open] {
      transform: none;
      opacity: 1;
    }
  }
}
// Sheet — runtime.
//
// Adds backdrop click-to-close, focus restoration, and scroll-lock on
// top of native <dialog>.showModal(). Mirrors Dialog's runtime
// behavior since they share the same native primitive — only visual
// placement differs.

import { lockScroll, unlockScroll } from "../../runtime/scroll-lock.js";

export interface SheetInitOptions {
  /** Close when the user clicks the backdrop. Default: true. */
  closeOnBackdropClick?: boolean;
}

const READY_ATTR = "data-malevich-ready";

export function initSheet(
  el: HTMLDialogElement,
  options: SheetInitOptions = {},
): void {
  if (el.getAttribute(READY_ATTR) === "true") return;

  const { closeOnBackdropClick = true } = options;

  let previouslyFocused: HTMLElement | null = null;
  let scrollLocked = false;

  function onClick(event: MouseEvent): void {
    if (!closeOnBackdropClick) return;
    if (event.target === el) {
      el.close();
    }
  }

  function onClose(): void {
    if (scrollLocked) {
      unlockScroll();
      scrollLocked = false;
    }
    previouslyFocused?.focus?.();
    previouslyFocused = null;
  }

  // Trap "showModal" so we can wire scroll-lock + focus capture.
  const originalShowModal = el.showModal.bind(el);
  el.showModal = function patchedShowModal(): void {
    previouslyFocused = document.activeElement as HTMLElement | null;
    lockScroll();
    scrollLocked = true;
    originalShowModal();
  };

  el.addEventListener("click", onClick);
  el.addEventListener("close", onClose);

  el.setAttribute(READY_ATTR, "true");
}

export function initSheets(root: ParentNode = document): void {
  for (const el of root.querySelectorAll<HTMLDialogElement>("dialog.sheet")) {
    initSheet(el);
  }
}
# Sheet — AGENTS.md

> Auto-generated from `sheet.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `sheet`
- **Layer:** overlays
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

- Settings or filters that benefit from a persistent, scrollable panel.

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `elevated`, `float` |
| `effect` | `blur` |
| `shader` | — |
| `rhythm` | `compact`, `regular`, `spacious` |
| `motion` | any value |
| `behavior` | `dismissible` |

## Anatomy

```html
<dialog class="sheet" data-side="right">
  <header class="sheet__header">
    <h2>Filters</h2>
    <button class="sheet__close" aria-label="Close" onclick="this.closest('dialog').close()">×</button>
  </header>
  <div class="sheet__body">
    <!-- Field-groups, fields, etc. -->
  </div>
  <footer class="sheet__footer">
    <button class="button -ghost" onclick="this.closest('dialog').close()">Cancel</button>
    <button class="button -primary">Apply</button>
  </footer>
</dialog>

<button onclick="document.querySelector('.sheet').showModal()">Open</button>
```

The runtime (`initSheet`, auto-registered for `dialog.sheet`) adds:

- Backdrop-click closes the sheet (opt-out via `closeOnBackdropClick:
  false`).
- Scroll lock on the document while open.
- Focus restoration to the previously-focused element on close.

## Tokens consumed

- `--color-surface-raised` — panel background
- `--color-surface-overlay` — backdrop
- `--color-surface-canvas` — close button hover
- `--color-ink-strong`, `--color-ink-regular`
- `--color-accent` — focus ring
- `--color-border-muted` — section dividers
- `--space-inset-block-m`, `--space-inset-element-s`
- `--space-gap-elements-{s,m}`
- `--shadow-overlay` — panel shadow
- `--radius-button` — close button radius
- `--size-control-s` — close button size
- `--border-width-hairline` / `--border-width-focus`
- `--motion-base`, `--motion-easing-default`

### Component-tier (defined inline)
- `--sheet-size` — overridable inline-size (right/left) or block-size
  (top/bottom)
- `--sheet-duration` — overridable enter/exit duration

## Accessibility contract

Native `<dialog>` + `showModal()` carries:

- Top-layer rendering (above all other content).
- Focus trap (Tab cycles inside the sheet).
- Escape closes (calls `close()`).
- Background `inert` (clicks and tab focus blocked behind the sheet).

The component adds:

- Backdrop-click close behavior.
- Focus restoration on close.
- Scroll lock on body so background does not scroll while open.

Authors should:

- Set a heading inside `.sheet__header` so assistive tech announces
  the sheet's purpose.
- Give the close button `aria-label="Close"` (the `×` glyph alone is
  not an accessible label).

## Guidance

### Do

- Use `<dialog>` as the element; `.sheet` class on it.
- Pair `__close` button with `aria-label="Close"`.
- Use `data-side` to pick the entry edge (default: right).
- Call `dialog.showModal()` to open; `dialog.close()` to close.

### Don't

- Don't use `<div>` with a custom modal implementation. The native
  `<dialog>` gives focus, escape, and top-layer for free.
- Don't stack two open sheets at once. Use Dialog for nested modal
  cases.
- Don't disable scroll lock unless you have a reason — body scroll
  through the backdrop is disorienting.