Card

Cards group content about one thing: a training ("Kinder 7–10 · Mo 17:30"), the detail of a registration, a dashboard item. They are flat (border, no shadow) and sit in a .grid or .stack. Don't nest cards in cards; use list rows or a subtle card inside instead.

Two modes. Static (<article>): .card__header with title and optional status badge, .card__body with the muted meta line and body paragraphs, and an optional .card__footer with up to three actions (button data, primary last) above a divider. Interactive (href): the whole card is one <a class="card" data-interactive>, for dashboard items that lead somewhere; it must not contain other actions. On hover it shifts to the second surface with a stronger border — no shadow, in-flow content stays flat (design guide §6.2).

variant: "accent" tints the card with --color-accent-subtle (one hero card per screen at most; never an accent fill, design guide §2.4); subtle uses the second surface for secondary or nested panels.

Body content is plain text paragraphs. Richer content (lists, key-value lists, stat tiles) is composed by the app: render the card markup by hand or place other components inside a .card element — never pass HTML through data.

Examples

Training card with status and action

Kinder 7–10 · Mo 17:30

Präsenz erfasst

14 anwesend · 2 abwesend · Leitung: Sandra Keller

Data
render({
  "title": "Kinder 7–10 · Mo 17:30",
  "badge": {
    "label": "Präsenz erfasst",
    "tone": "success"
  },
  "meta": "14 anwesend · 2 abwesend · Leitung: Sandra Keller",
  "actions": [
    {
      "label": "Öffnen",
      "href": "#training-4711"
    }
  ]
})
Markup
<article class="card"><header class="card__header"><h3 class="card__title">Kinder 7–10 · Mo 17:30</h3><span class="badge" data-status="success"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#check-circle"/></svg>Präsenz erfasst</span></header><div class="card__body"><p class="u-muted">14 anwesend · 2 abwesend · Leitung: Sandra Keller</p></div><footer class="card__footer"><a class="button" href="#training-4711">Öffnen</a></footer></article>

Detail card with body text

Luca Gerber

Wartet auf Freigabe

Gewünschte Gruppe: Kinder 7–10. Familientarif, weil die Schwester Nina bereits Mitglied ist.

Eingegangen gestern über das öffentliche Anmeldeformular.

Data
render({
  "id": "reg-17",
  "title": "Luca Gerber",
  "headingLevel": 2,
  "badge": {
    "label": "Wartet auf Freigabe",
    "tone": "warning"
  },
  "body": [
    "Gewünschte Gruppe: Kinder 7–10. Familientarif, weil die Schwester Nina bereits Mitglied ist.",
    "Eingegangen gestern über das öffentliche Anmeldeformular."
  ],
  "actions": [
    {
      "label": "Ablehnen",
      "variant": "ghost"
    },
    {
      "label": "Bearbeiten"
    },
    {
      "label": "Freigeben",
      "variant": "primary"
    }
  ]
})
Markup
<article class="card" id="reg-17" aria-labelledby="reg-17-title"><header class="card__header"><h2 class="card__title" id="reg-17-title">Luca Gerber</h2><span class="badge" data-status="warning"><svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#alert-triangle"/></svg>Wartet auf Freigabe</span></header><div class="card__body"><p>Gewünschte Gruppe: Kinder 7–10. Familientarif, weil die Schwester Nina bereits Mitglied ist.</p><p>Eingegangen gestern über das öffentliche Anmeldeformular.</p></div><footer class="card__footer"><button class="button" type="button" data-variant="ghost">Ablehnen</button><button class="button" type="button">Bearbeiten</button><button class="button" type="button" data-variant="primary">Freigeben</button></footer></article>

Whole card as link

Data
render({
  "title": "Gurtprüfung 14.11.",
  "meta": "38 Anmeldungen · 3 ohne Gebühr",
  "href": "#pruefung-2026-11"
})
Markup
<a class="card" data-interactive href="#pruefung-2026-11"><header class="card__header"><h3 class="card__title">Gurtprüfung 14.11.</h3></header><div class="card__body"><p class="u-muted">38 Anmeldungen · 3 ohne Gebühr</p></div></a>

Accent hero card (subtle tint)

Nächstes Training

Heute 17:30 · Kinder 7–10 · Dojang Breitenrain

Data
render({
  "variant": "accent",
  "title": "Nächstes Training",
  "body": [
    "Heute 17:30 · Kinder 7–10 · Dojang Breitenrain"
  ]
})
Markup
<article class="card" data-variant="accent"><header class="card__header"><h3 class="card__title">Nächstes Training</h3></header><div class="card__body"><p>Heute 17:30 · Kinder 7–10 · Dojang Breitenrain</p></div></article>

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
idstringoptional—Id of the card. When set together with title, the title gets {id}-title and the article is labelled by it (aria-labelledby).
pattern ^[A-Za-z][\w-]*$
titlestringoptional—Card title ("Kinder 7–10 · Mo 17:30").
min 1 chars
headingLevelintegeroptional3Heading level of the title (h2–h4); pick the level that fits the page outline.
≥ 2 · ≤ 4
badgeobjectoptional—Optional status shown at the end of the header (see Status badge).
badge.labelstringrequired—Status text, e.g. "Wartet auf Freigabe", "Bezahlt", "Fehler in Webling".
min 1 chars
badge.tone"neutral" | "info" | "success" | "warning" | "danger"optional"neutral"Status, rendered as data-status + leading icon. neutral (draft, inactive; no icon), info (planned, new), success (done, paid), warning (waiting, needs attention), danger (failed, overdue).
badge.titlestringoptional—Optional tooltip with more detail ("seit 3 Tagen offen"). Not announced reliably; never put essential information only here.
metastringoptional—One muted line under the header ("14 anwesend · 2 abwesend · Leitung: Sandra Keller").
bodyarray of stringoptional—Body paragraphs, plain text, in display order.
variant"default" | "accent" | "subtle"optional"default"accent: accent-subtle hero tint (one hero card per screen); subtle: second surface for nested/secondary panels.
hrefstringoptional—Makes the whole card one link (<a class="card" data-interactive href>). Cannot be combined with actions.
actionsarray of objectsoptional—Buttons in the .card__footer (see Button). Put the primary action last. Not allowed together with href.
max 3 items
actions[].labelstringoptional—Visible text. Required unless iconOnly.
min 1 chars
actions[].variant"secondary" | "primary" | "ghost" | "danger" | "danger-ghost"optional"secondary"Visual weight / meaning.
actions[].size"sm" | "md" | "lg"optional"md"sm (36 px) only in dense desktop tables; lg (52 px) for the single main action of a screen.
actions[].iconstringoptional—Icon id from the sprite, shown before the label.
actions[].iconOnlybooleanoptionalfalseSquare button showing only icon. Requires ariaLabel.
actions[].ariaLabelstringoptional—Accessible name when the visible label is missing or ambiguous ("Weitere Aktionen für Lea Müller").
actions[].hrefstringoptional—Render an <a> that looks like a button (navigation, not actions).
actions[].type"button" | "submit" | "reset"optional"button"Button type inside forms.
actions[].valuestringoptional—Form value, e.g. cancel / confirm inside <form method="dialog">.
actions[].namestringoptional—Form field name when the button submits a value.
actions[].disabledbooleanoptionalfalseNot available now. Prefer explaining why nearby over silently disabling.
actions[].busybooleanoptionalfalseAction in progress (aria-busy).
actions[].formNoValidatebooleanoptionalfalseSubmit without native validation — use on "Abbrechen" inside <form method="dialog"> so required fields do not block closing.
actions[].block"always" | "mobile"optional—Full width always, or only below 768 px.
actions[].behaviorstringoptional—Behaviour hook (data-behavior), e.g. open-dialog, theme-toggle.
actions[].actionstringoptional—Screen action name (data-action). Declared in the actions of the screen with a handler and a risk class; the engine adds undo or confirm accordingly.
pattern ^[a-z][a-zA-Z0-9-]*$
actions[].actionIdstringoptional—Id of the thing the action applies to (data-id), when the button is not inside an element that has one.
pattern ^[A-Za-z0-9_-]+$
actions[].targetstringoptional—Id of the element a behaviour acts on (data-target), e.g. the dialog to open.
actions[].popoverTargetstringoptional—Id of a popover menu this button toggles (popovertarget).
JSON Schema
{
  "type": "object",
  "additionalProperties": false,
  "required": [],
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^[A-Za-z][\\w-]*$",
      "description": "Id of the card. When set together with `title`, the title gets `{id}-title` and the article is labelled by it (`aria-labelledby`)."
    },
    "title": {
      "type": "string",
      "minLength": 1,
      "description": "Card title (\"Kinder 7–10 · Mo 17:30\")."
    },
    "headingLevel": {
      "type": "integer",
      "minimum": 2,
      "maximum": 4,
      "default": 3,
      "description": "Heading level of the title (`h2`–`h4`); pick the level that fits the page outline."
    },
    "badge": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "label"
      ],
      "properties": {
        "label": {
          "type": "string",
          "minLength": 1,
          "description": "Status text, e.g. \"Wartet auf Freigabe\", \"Bezahlt\", \"Fehler in Webling\"."
        },
        "tone": {
          "type": "string",
          "enum": [
            "neutral",
            "info",
            "success",
            "warning",
            "danger"
          ],
          "default": "neutral",
          "description": "Status, rendered as `data-status` + leading icon. `neutral` (draft, inactive; no icon), `info` (planned, new), `success` (done, paid), `warning` (waiting, needs attention), `danger` (failed, overdue)."
        },
        "title": {
          "type": "string",
          "description": "Optional tooltip with more detail (\"seit 3 Tagen offen\"). Not announced reliably; never put essential information only here."
        }
      },
      "description": "Optional status shown at the end of the header (see Status badge)."
    },
    "meta": {
      "type": "string",
      "description": "One muted line under the header (\"14 anwesend · 2 abwesend · Leitung: Sandra Keller\")."
    },
    "body": {
      "type": "array",
      "description": "Body paragraphs, plain text, in display order.",
      "items": {
        "type": "string",
        "minLength": 1,
        "description": "One paragraph."
      }
    },
    "variant": {
      "type": "string",
      "enum": [
        "default",
        "accent",
        "subtle"
      ],
      "default": "default",
      "description": "`accent`: accent-subtle hero tint (one hero card per screen); `subtle`: second surface for nested/secondary panels."
    },
    "href": {
      "type": "string",
      "description": "Makes the whole card one link (`<a class=\"card\" data-interactive href>`). Cannot be combined with `actions`."
    },
    "actions": {
      "type": "array",
      "maxItems": 3,
      "description": "Buttons in the `.card__footer` (see Button). Put the primary action last. Not allowed together with `href`.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [],
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Visible text. Required unless `iconOnly`."
          },
          "variant": {
            "type": "string",
            "enum": [
              "secondary",
              "primary",
              "ghost",
              "danger",
              "danger-ghost"
            ],
            "default": "secondary",
            "description": "Visual weight / meaning."
          },
          "size": {
            "type": "string",
            "enum": [
              "sm",
              "md",
              "lg"
            ],
            "default": "md",
            "description": "`sm` (36 px) only in dense desktop tables; `lg` (52 px) for the single main action of a screen."
          },
          "icon": {
            "type": "string",
            "description": "Icon id from the sprite, shown before the label."
          },
          "iconOnly": {
            "type": "boolean",
            "default": false,
            "description": "Square button showing only `icon`. Requires `ariaLabel`."
          },
          "ariaLabel": {
            "type": "string",
            "description": "Accessible name when the visible label is missing or ambiguous (\"Weitere Aktionen für Lea Müller\")."
          },
          "href": {
            "type": "string",
            "description": "Render an `<a>` that looks like a button (navigation, not actions)."
          },
          "type": {
            "type": "string",
            "enum": [
              "button",
              "submit",
              "reset"
            ],
            "default": "button",
            "description": "Button type inside forms."
          },
          "value": {
            "type": "string",
            "description": "Form value, e.g. `cancel` / `confirm` inside `<form method=\"dialog\">`."
          },
          "name": {
            "type": "string",
            "description": "Form field name when the button submits a value."
          },
          "disabled": {
            "type": "boolean",
            "default": false,
            "description": "Not available now. Prefer explaining why nearby over silently disabling."
          },
          "busy": {
            "type": "boolean",
            "default": false,
            "description": "Action in progress (`aria-busy`)."
          },
          "formNoValidate": {
            "type": "boolean",
            "default": false,
            "description": "Submit without native validation — use on \"Abbrechen\" inside `<form method=\"dialog\">` so required fields do not block closing."
          },
          "block": {
            "type": "string",
            "enum": [
              "always",
              "mobile"
            ],
            "description": "Full width always, or only below 768 px."
          },
          "behavior": {
            "type": "string",
            "description": "Behaviour hook (`data-behavior`), e.g. `open-dialog`, `theme-toggle`."
          },
          "action": {
            "type": "string",
            "pattern": "^[a-z][a-zA-Z0-9-]*$",
            "description": "Screen action name (`data-action`). Declared in the `actions` of the screen with a handler and a risk class; the engine adds undo or confirm accordingly."
          },
          "actionId": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]+$",
            "description": "Id of the thing the action applies to (`data-id`), when the button is not inside an element that has one."
          },
          "target": {
            "type": "string",
            "description": "Id of the element a behaviour acts on (`data-target`), e.g. the dialog to open."
          },
          "popoverTarget": {
            "type": "string",
            "description": "Id of a popover menu this button toggles (`popovertarget`)."
          }
        },
        "description": "One button (Button data)."
      }
    }
  }
}

Markup & states

Root: article | a.card — usable without render() by writing the markup directly.

Attributes

AttributeValuesDefaultDescription
data-variantaccent | subtle(none)Accent-subtle hero tint or subtle second surface.
data-interactive——Whole card is a link (<a href>): on hover second surface + strong border, no shadow.
aria-labelledby"{id}-title"—Names the article after its title (set when id and title are given).

Parts

PartDescription
.card__headerFlex row: title at the start, badge (or a small action) at the end.
.card__titleThe heading (h2–h4).
.card__bodyContent column: the meta line (p.u-muted) and body paragraphs.
.card__footer<footer> with the actions at the end side (primary last), above a --color-border divider.

States

StateDescription
[data-interactive]:hoverSecond surface + strong border (inside @media (hover: hover)).
:focus-visibleGlobal focus ring on the link card.

Accessibility

  • Static cards are <article>; with id + title they are labelled by their heading, so they appear as named regions.
  • A link card is one <a>; its accessible name is all its text, so keep the content short and lead with the title.
  • Never put buttons or links inside a link card (nested interactive content) — render throws when href and actions are combined.
  • On accent cards all text uses --color-accent-subtle-text, which is verified on --color-accent-subtle; don't add status colours there, they aren't verified on the tint.

Guidance

DoOne topic per card; lead with what the person needs to decide.
DoUse a grid of link cards for dashboard items that each lead to where the work is done.
Don'tDon't nest cards.
Don'tDon't add shadows or coloured backgrounds per card for decoration.
Don'tDon't use more than one accent card per screen.

Related: Stat tile · List · Key-value list · Status badge · Button