Overlaysstable.sheet
Bottom sheet
A panel that slides up from the bottom on phones (a centred dialog from 768 px) for details and quick actions in context.
The phone-first overlay: show details of an item without leaving the list (an exercise during training, a student's stripes), or offer a short choice. The action sits at the bottom, in thumb reach (UX guide §5.4). From 768 px the same markup is shown as a centred dialog.
Same mechanics as the dialog: native <dialog> + showModal(), <form method="dialog">, every footer button closes it with its value as returnValue; Esc, the ✕ and a backdrop click close with cancel, and focus returns to the opener. The handle at the top is a visual cue only — there is no swipe-to-close, so the ✕ stays.
Browser back should close an open sheet before leaving the page (UX guide §3.3) — push a history entry when opening if the sheet is a "place" people expect to go back from.
Data covers text, a muted meta line and form fields; apps render richer content (stripe chips, lists) into .dialog__body themselves.
Examples
Exercise details with one action
Data
render({
"id": "exercise-42",
"title": "Dollyo Chagi an Pratze",
"meta": "15 Wiederholungen · Pause 60 s",
"trigger": {
"label": "Details",
"variant": "ghost",
"icon": "info"
},
"paragraphs": [
"Saubere Technik, kontrolliert. Hüfte eindrehen, Standbein drehen."
],
"actions": [
{
"label": "Satz erledigt",
"value": "done",
"variant": "primary"
}
]
})Markup
<button class="button" type="button" data-variant="ghost" data-behavior="open-dialog" data-target="exercise-42"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#info"/></svg>Details</button>
<dialog class="sheet" id="exercise-42" aria-labelledby="exercise-42-title" aria-describedby="exercise-42-body">
<form method="dialog" class="dialog__inner">
<div class="sheet__handle" aria-hidden="true"></div>
<header class="dialog__header"><h2 id="exercise-42-title">Dollyo Chagi an Pratze</h2><button class="button" data-variant="ghost" data-icon-only value="cancel" formnovalidate aria-label="Schliessen"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#x"/></svg></button></header>
<div class="dialog__body">
<div class="stack" data-gap="sm" id="exercise-42-body"><p class="u-muted">15 Wiederholungen · Pause 60 s</p><p>Saubere Technik, kontrolliert. Hüfte eindrehen, Standbein drehen.</p></div>
</div>
<footer class="dialog__footer button-row" data-stack-mobile><button class="button" value="done" data-variant="primary" data-block>Satz erledigt</button></footer>
</form>
</dialog>Quick note
Data
render({
"id": "note-sheet",
"title": "Notiz zu Noah Keller",
"fields": [
{
"id": "note-text",
"label": "Notiz",
"control": "textarea",
"rows": 3,
"hint": "Nur für Trainerinnen und Trainer sichtbar."
}
],
"actions": [
{
"label": "Abbrechen",
"value": "cancel"
},
{
"label": "Notiz speichern",
"value": "save",
"variant": "primary"
}
]
})Markup
<dialog class="sheet" id="note-sheet" aria-labelledby="note-sheet-title">
<form method="dialog" class="dialog__inner">
<div class="sheet__handle" aria-hidden="true"></div>
<header class="dialog__header"><h2 id="note-sheet-title">Notiz zu Noah Keller</h2><button class="button" data-variant="ghost" data-icon-only value="cancel" formnovalidate aria-label="Schliessen"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#x"/></svg></button></header>
<div class="dialog__body">
<div class="field">
<label class="field__label" for="note-text">Notiz</label>
<p class="field__hint" id="note-text-hint">Nur für Trainerinnen und Trainer sichtbar.</p>
<textarea class="textarea" id="note-text" name="note-text" aria-describedby="note-text-hint" rows="3"></textarea>
</div>
</div>
<footer class="dialog__footer button-row" data-stack-mobile><button class="button" value="cancel" formnovalidate>Abbrechen</button><button class="button" value="save" data-variant="primary">Notiz speichern</button></footer>
</form>
</dialog>Data contract
What render(data) accepts — validated against this JSON Schema in development and tests. Unknown fields are rejected. The same schema is in components.json.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | required | — | Id of the <dialog> (target of the opening button); prefix for {id}-title and {id}-body. Must be unique on the page.pattern ^[A-Za-z][\w-]*$ |
title | string | required | — | Heading (the item's name, "Dollyo Chagi an Pratze"). min 1 chars |
meta | string | optional | — | One muted line under the title ("15 Wiederholungen · Pause 60 s"). |
paragraphs | array of string | optional | — | Body text, one string per paragraph. Linked as the dialog's description. |
fields | array of objects | optional | — | Form fields in the body (see Form field). Their values are in the form when the dialog closes. |
fields[].id | string | required | — | Id of the control; also prefix for {id}-hint and {id}-error. Must be unique on the page.pattern ^[A-Za-z][\w-]*$ |
fields[].label | string | required | — | Visible label ("E-Mail", "Geburtsdatum"). min 1 chars |
fields[].control | "input" | "select" | "textarea" | optional | "input" | Which control to render. |
fields[].type | "text" | "email" | "tel" | "date" | "time" | "number" | "url" | "search" | "password" | optional | "text" | Input type (control = input). |
fields[].name | string | optional | — | Form field name; defaults to id. |
fields[].value | string | number | optional | — | Current value. |
fields[].placeholder | string | optional | — | Example value ("z. B. 3011"). Never a substitute for the label. |
fields[].hint | string | optional | — | Help text shown between label and control. |
fields[].error | string | optional | — | Error message; when set the control is marked invalid. Say what to do: "Bitte gib … ein". |
fields[].required | boolean | optional | false | Field must be filled. Optional fields are marked "(optional)" instead of marking required ones. |
fields[].optional | boolean | optional | false | Append "(optional)" to the label (use in forms where most fields are required). |
fields[].disabled | boolean | optional | false | Read-only for now; explain why in the hint. |
fields[].autocomplete | string | optional | — | HTML autocomplete token, e.g. email, given-name, bday, one-time-code. |
fields[].inputmode | "text" | "numeric" | "decimal" | "tel" | "email" | "url" | "search" | optional | — | Virtual keyboard hint, e.g. numeric for PLZ. |
fields[].maxlength | integer | optional | — | Maximum characters. ≥ 1 |
fields[].rows | integer | optional | 4 | Visible lines (control = textarea). ≥ 2 |
fields[].options | array of objects | optional | — | Choices (control = select). Put a sensible default first or add an empty "Bitte wählen" option. |
fields[].options[].value | string | required | — | Submitted value. |
fields[].options[].label | string | required | — | Shown text. |
fields[].options[].disabled | boolean | optional | — | Not selectable (e.g. full group). |
closeLabel | string | optional | "Schliessen" | Accessible name of the ✕ button in the header. |
showClose | boolean | optional | true | Show the ✕ button in the header. |
actions | array of objects | optional | — | Footer buttons, primary last. A single action is shown full width. Optional — a pure detail sheet closes with the ✕. min 1 items · max 3 items |
actions[].label | string | required | — | Button text, a verb naming the result ("Gruppe ändern"); "Abbrechen" for the cancel button. min 1 chars |
actions[].value | string | required | — | Return value. Use cancel for the dismiss button (it skips form validation).pattern ^[a-z][a-z0-9-]*$ |
actions[].variant | "secondary" | "primary" | "ghost" | "danger" | optional | "secondary" | Button variant; one primary (or danger for destructive) per dialog. |
trigger | object | optional | — | Optional opening button rendered before the sheet (data-behavior="open-dialog"). |
trigger.label | string | required | — | Button text; ends with "…" when it opens a dialog ("Gruppe ändern …"). min 1 chars |
trigger.variant | "secondary" | "primary" | "ghost" | "danger" | "danger-ghost" | optional | "secondary" | Visual weight / meaning. |
trigger.icon | string | optional | — | Icon id shown before the label. |
JSON Schema
{
"type": "object",
"additionalProperties": false,
"required": [
"id",
"title"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z][\\w-]*$",
"description": "Id of the `<dialog>` (target of the opening button); prefix for `{id}-title` and `{id}-body`. Must be unique on the page."
},
"title": {
"type": "string",
"minLength": 1,
"description": "Heading (the item's name, \"Dollyo Chagi an Pratze\")."
},
"meta": {
"type": "string",
"description": "One muted line under the title (\"15 Wiederholungen · Pause 60 s\")."
},
"paragraphs": {
"type": "array",
"description": "Body text, one string per paragraph. Linked as the dialog's description.",
"items": {
"type": "string",
"minLength": 1,
"description": "One paragraph."
}
},
"fields": {
"type": "array",
"description": "Form fields in the body (see Form field). Their values are in the form when the dialog closes.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"label"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z][\\w-]*$",
"description": "Id of the control; also prefix for `{id}-hint` and `{id}-error`. Must be unique on the page."
},
"label": {
"type": "string",
"minLength": 1,
"description": "Visible label (\"E-Mail\", \"Geburtsdatum\")."
},
"control": {
"type": "string",
"enum": [
"input",
"select",
"textarea"
],
"default": "input",
"description": "Which control to render."
},
"type": {
"type": "string",
"enum": [
"text",
"email",
"tel",
"date",
"time",
"number",
"url",
"search",
"password"
],
"default": "text",
"description": "Input type (control = input)."
},
"name": {
"type": "string",
"description": "Form field name; defaults to `id`."
},
"value": {
"type": [
"string",
"number"
],
"description": "Current value."
},
"placeholder": {
"type": "string",
"description": "Example value (\"z. B. 3011\"). Never a substitute for the label."
},
"hint": {
"type": "string",
"description": "Help text shown between label and control."
},
"error": {
"type": "string",
"description": "Error message; when set the control is marked invalid. Say what to do: \"Bitte gib … ein\"."
},
"required": {
"type": "boolean",
"default": false,
"description": "Field must be filled. Optional fields are marked \"(optional)\" instead of marking required ones."
},
"optional": {
"type": "boolean",
"default": false,
"description": "Append \"(optional)\" to the label (use in forms where most fields are required)."
},
"disabled": {
"type": "boolean",
"default": false,
"description": "Read-only for now; explain why in the hint."
},
"autocomplete": {
"type": "string",
"description": "HTML autocomplete token, e.g. `email`, `given-name`, `bday`, `one-time-code`."
},
"inputmode": {
"type": "string",
"enum": [
"text",
"numeric",
"decimal",
"tel",
"email",
"url",
"search"
],
"description": "Virtual keyboard hint, e.g. `numeric` for PLZ."
},
"maxlength": {
"type": "integer",
"minimum": 1,
"description": "Maximum characters."
},
"rows": {
"type": "integer",
"minimum": 2,
"default": 4,
"description": "Visible lines (control = textarea)."
},
"options": {
"type": "array",
"description": "Choices (control = select). Put a sensible default first or add an empty \"Bitte wählen\" option.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"value",
"label"
],
"properties": {
"value": {
"type": "string",
"description": "Submitted value."
},
"label": {
"type": "string",
"description": "Shown text."
},
"disabled": {
"type": "boolean",
"description": "Not selectable (e.g. full group)."
}
}
}
}
},
"description": "One form field."
}
},
"closeLabel": {
"type": "string",
"default": "Schliessen",
"description": "Accessible name of the ✕ button in the header."
},
"showClose": {
"type": "boolean",
"default": true,
"description": "Show the ✕ button in the header."
},
"actions": {
"type": "array",
"minItems": 1,
"maxItems": 3,
"description": "Footer buttons, primary last. A single action is shown full width. Optional — a pure detail sheet closes with the ✕.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"label",
"value"
],
"properties": {
"label": {
"type": "string",
"minLength": 1,
"description": "Button text, a verb naming the result (\"Gruppe ändern\"); \"Abbrechen\" for the cancel button."
},
"value": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$",
"description": "Return value. Use `cancel` for the dismiss button (it skips form validation)."
},
"variant": {
"type": "string",
"enum": [
"secondary",
"primary",
"ghost",
"danger"
],
"default": "secondary",
"description": "Button variant; one `primary` (or `danger` for destructive) per dialog."
}
}
}
},
"trigger": {
"type": "object",
"additionalProperties": false,
"required": [
"label"
],
"description": "Optional opening button rendered before the sheet (`data-behavior=\"open-dialog\"`).",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"description": "Button text; ends with \"…\" when it opens a dialog (\"Gruppe ändern …\")."
},
"variant": {
"type": "string",
"enum": [
"secondary",
"primary",
"ghost",
"danger",
"danger-ghost"
],
"default": "secondary",
"description": "Visual weight / meaning."
},
"icon": {
"type": "string",
"description": "Icon id shown before the label."
}
}
}
}
}Markup & states
Root: dialog.sheet — usable without render() by writing the markup directly.
Attributes
| Attribute | Values | Default | Description |
|---|---|---|---|
aria-labelledby | "{id}-title" | — | Named by its heading. |
aria-describedby | "{id}-body" | — | Meta and text as description (when present). |
Parts
| Part | Description |
|---|---|
.dialog__inner | <form method="dialog"> wrapping everything (shared with the dialog). |
.sheet__handle | Grab-handle bar, decorative; hidden from 768 px. |
.dialog__header / .dialog__body / .dialog__footer | Same parts as the dialog. |
States
| State | Description |
|---|---|
[open] | Slides up from the bottom (phones) / fades in centred (≥ 768 px). |
::backdrop | Dimmed page; a click closes with cancel. |
Behaviour & events
Module: js/dialog.js
Declarative: a button with data-behavior="open-dialog" data-target="{id}" and mount(root). Imperative: openDialog(dialogEl, openerEl).
API
| Function | Signature | Returns | Description |
|---|---|---|---|
openDialog | openDialog(dialog: HTMLDialogElement, opener?: HTMLElement) | void | Opens with showModal(), closes on backdrop click (returnValue = "cancel"), returns focus to opener (default: the active element) on close. |
Events
| Event | Detail | Description |
|---|---|---|
close (on the dialog) | dialog.returnValue → value of the pressed button, or "cancel" | Native event after any way of closing. Read the form fields here when the value is not cancel. |
cancel (on the dialog) | — | Native event on Esc; preventDefault() to keep unsaved input and ask first. |
Accessibility
- Native modal dialog: focus trapped, Esc closes, focus returns to the opener.
- Always has a visible ✕ (or a cancel action) — the handle is not a control and swiping is not required.
- Content respects the bottom safe area (home indicator) on phones.
Guidance
Related: Dialog · Menu · Confirm dialog