Click each button to open the sheet from that side. Press Esc or click the backdrop to close.
/*
* 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.