Bottom sheet

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

Dollyo Chagi an Pratze

15 Wiederholungen · Pause 60 s

Saubere Technik, kontrolliert. Hüfte eindrehen, Standbein drehen.

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

Notiz zu Noah Keller

Nur für Trainerinnen und Trainer sichtbar.

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.

FieldTypeRequiredDefaultDescription
idstringrequired—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-]*$
titlestringrequired—Heading (the item's name, "Dollyo Chagi an Pratze").
min 1 chars
metastringoptional—One muted line under the title ("15 Wiederholungen · Pause 60 s").
paragraphsarray of stringoptional—Body text, one string per paragraph. Linked as the dialog's description.
fieldsarray of objectsoptional—Form fields in the body (see Form field). Their values are in the form when the dialog closes.
fields[].idstringrequired—Id of the control; also prefix for {id}-hint and {id}-error. Must be unique on the page.
pattern ^[A-Za-z][\w-]*$
fields[].labelstringrequired—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[].namestringoptional—Form field name; defaults to id.
fields[].valuestring | numberoptional—Current value.
fields[].placeholderstringoptional—Example value ("z. B. 3011"). Never a substitute for the label.
fields[].hintstringoptional—Help text shown between label and control.
fields[].errorstringoptional—Error message; when set the control is marked invalid. Say what to do: "Bitte gib … ein".
fields[].requiredbooleanoptionalfalseField must be filled. Optional fields are marked "(optional)" instead of marking required ones.
fields[].optionalbooleanoptionalfalseAppend "(optional)" to the label (use in forms where most fields are required).
fields[].disabledbooleanoptionalfalseRead-only for now; explain why in the hint.
fields[].autocompletestringoptional—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[].maxlengthintegeroptional—Maximum characters.
≥ 1
fields[].rowsintegeroptional4Visible lines (control = textarea).
≥ 2
fields[].optionsarray of objectsoptional—Choices (control = select). Put a sensible default first or add an empty "Bitte wählen" option.
fields[].options[].valuestringrequired—Submitted value.
fields[].options[].labelstringrequired—Shown text.
fields[].options[].disabledbooleanoptional—Not selectable (e.g. full group).
closeLabelstringoptional"Schliessen"Accessible name of the ✕ button in the header.
showClosebooleanoptionaltrueShow the ✕ button in the header.
actionsarray of objectsoptional—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[].labelstringrequired—Button text, a verb naming the result ("Gruppe ändern"); "Abbrechen" for the cancel button.
min 1 chars
actions[].valuestringrequired—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.
triggerobjectoptional—Optional opening button rendered before the sheet (data-behavior="open-dialog").
trigger.labelstringrequired—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.iconstringoptional—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

AttributeValuesDefaultDescription
aria-labelledby"{id}-title"—Named by its heading.
aria-describedby"{id}-body"—Meta and text as description (when present).

Parts

PartDescription
.dialog__inner<form method="dialog"> wrapping everything (shared with the dialog).
.sheet__handleGrab-handle bar, decorative; hidden from 768 px.
.dialog__header / .dialog__body / .dialog__footerSame parts as the dialog.

States

StateDescription
[open]Slides up from the bottom (phones) / fades in centred (≥ 768 px).
::backdropDimmed 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

FunctionSignatureReturnsDescription
openDialogopenDialog(dialog: HTMLDialogElement, opener?: HTMLElement)voidOpens with showModal(), closes on backdrop click (returnValue = "cancel"), returns focus to opener (default: the active element) on close.

Events

EventDetailDescription
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

DoUse for context on phones: details, a quick note, a short choice.
DoKeep the primary action at the bottom, full width.
Don'tDon't put long forms or multi-step flows into a sheet — use a page.
Don'tDon't stack sheets on top of each other.

Related: Dialog · Menu · Confirm dialog