Skeleton

Show a skeleton when loading takes longer than ~300 ms (show nothing before that to avoid flashes). It mimics the final layout — for lists: an optional avatar circle and one or two text lines per row — so the page doesn't jump when data arrives (UX guide §7.1).

Skeletons replace one region (a list, a card body), never the whole app. The container is marked aria-busy="true" and contains a visually hidden status text ("Lädt …"), so screen readers learn that content is coming.

Visuals (design guide §9.3): --color-surface-2 blocks, text lines at --text-md height with --radius-sm, avatars round (40 px, like the default avatar); a subtle opacity pulse based on --duration-slow, off under prefers-reduced-motion. Line widths are set with the --skeleton-width custom property (the only inline style allowed, §12.2).

Replace the whole skeleton with the content (or an empty state / error) in one step; remove aria-busy with it.

Examples

List with avatars

Lädt …
Data
render({
  "rows": 3
})
Markup
<div class="stack" aria-busy="true"><span class="u-visually-hidden" role="status">Lädt …</span><div class="cluster" aria-hidden="true"><span class="skeleton" data-shape="circle"></span><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 60%"></span><span class="skeleton" style="--skeleton-width: 35%"></span></div></div><div class="cluster" aria-hidden="true"><span class="skeleton" data-shape="circle"></span><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 50%"></span><span class="skeleton" style="--skeleton-width: 30%"></span></div></div><div class="cluster" aria-hidden="true"><span class="skeleton" data-shape="circle"></span><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 70%"></span><span class="skeleton" style="--skeleton-width: 40%"></span></div></div></div>

Text-only rows

Trainings werden geladen …
Data
render({
  "rows": 4,
  "avatar": false,
  "lines": 1,
  "label": "Trainings werden geladen …"
})
Markup
<div class="stack" aria-busy="true"><span class="u-visually-hidden" role="status">Trainings werden geladen …</span><div class="cluster" aria-hidden="true"><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 60%"></span></div></div><div class="cluster" aria-hidden="true"><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 50%"></span></div></div><div class="cluster" aria-hidden="true"><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 70%"></span></div></div><div class="cluster" aria-hidden="true"><div class="stack u-grow" data-gap="sm"><span class="skeleton" style="--skeleton-width: 45%"></span></div></div></div>

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
rowsintegeroptional3Number of placeholder rows (match the expected count, max 10).
≥ 1 · ≤ 10
avatarbooleanoptionaltrueShow a round avatar placeholder at the start of each row.
linesintegeroptional2Text lines per row (title + meta = 2).
≥ 1 · ≤ 2
labelstringoptional"Lädt …"Visually hidden status text announced to screen readers ("Mitglieder werden geladen …").
min 1 chars
JSON Schema
{
  "type": "object",
  "additionalProperties": false,
  "required": [],
  "properties": {
    "rows": {
      "type": "integer",
      "minimum": 1,
      "maximum": 10,
      "default": 3,
      "description": "Number of placeholder rows (match the expected count, max 10)."
    },
    "avatar": {
      "type": "boolean",
      "default": true,
      "description": "Show a round avatar placeholder at the start of each row."
    },
    "lines": {
      "type": "integer",
      "minimum": 1,
      "maximum": 2,
      "default": 2,
      "description": "Text lines per row (title + meta = 2)."
    },
    "label": {
      "type": "string",
      "minLength": 1,
      "default": "Lädt …",
      "description": "Visually hidden status text announced to screen readers (\"Mitglieder werden geladen …\")."
    }
  }
}

Markup & states

Root: span (inside div.stack[aria-busy]).skeleton — usable without render() by writing the markup directly.

Attributes

AttributeValuesDefaultDescription
data-shape="circle"——Round 40 px placeholder (avatar).
style="--skeleton-width: N%"length / percentage—Width of a text line (default 100%).
aria-busy="true"——On the container: region is loading.

Parts

PartDescription
.stack[aria-busy]Container replacing the region.
.u-visually-hidden[role="status"]Announced loading text.
.clusterOne placeholder row (hidden from assistive tech).
.skeletonA --color-surface-2 block at --text-md height with a subtle opacity pulse.

States

StateDescription
@media (prefers-reduced-motion: reduce)No pulse.

CSS custom properties

PropertyDefaultDescription
--skeleton-width100%Width of a text-line placeholder.

Accessibility

  • Placeholder rows are aria-hidden; only the status text is announced.
  • The container has aria-busy="true" while loading.
  • The pulse is disabled for users who prefer reduced motion.

Guidance

DoMatch the final layout (avatar + two lines for member lists).
DoShow skeletons per region so the rest of the page stays usable.
Don'tDon't show a skeleton for fast loads (< 300 ms).
Don'tDon't use a full-page spinner instead of skeletons.
Don'tDon't leave a skeleton forever — after ~10 s show an error with "Erneut versuchen".

Related: Empty state · List