App shell

All apps use one shell; modules plug into it and never draw their own header, sidebar or "back to hub" bar (UX guide §3.1). Below 1024 px it shows the top bar and a bottom tab bar with at most 5 items (4 areas + "Mehr"); from 1024 px the sidebar replaces the tab bar.

The top bar holds the page title (or the club switcher), an optional back link for detail pages on mobile, and up to three icon actions (search, notifications, user menu). The sidebar holds the brand, navigation sections (max ~7 top-level groups) and an optional footer.

render outputs an empty <main class="shell__main" id="{mainId}" tabindex="-1">. Page content is not shell data: the app renders each route into that element (e.g. render(view, document.getElementById("main"))). Never pass page HTML through data.

The schema enforces at most 5 tab bar items. Mark exactly one item current in each navigation. On route change, update aria-current, set the document title ("{Seite} · {Bereich} · {Verein}") and move focus to the page h1 (UX guide §3.3).

Examples

Admin app (registrations)

Data
render({
  "title": "Anmeldungen",
  "actions": [
    {
      "icon": "search",
      "iconOnly": true,
      "variant": "ghost",
      "ariaLabel": "Suchen"
    },
    {
      "icon": "bell",
      "iconOnly": true,
      "variant": "ghost",
      "ariaLabel": "Mitteilungen (1 neu)"
    }
  ],
  "sidebar": {
    "brand": "TKD Bern · Admin",
    "sections": [
      {
        "items": [
          {
            "label": "Übersicht",
            "href": "/admin/",
            "icon": "home"
          },
          {
            "label": "Mitglieder",
            "href": "/admin/members",
            "icon": "users",
            "current": true,
            "count": 3,
            "countLabel": "3 offene Anmeldungen"
          },
          {
            "label": "Prüfungen",
            "href": "/admin/belt-tests",
            "icon": "award"
          },
          {
            "label": "Events",
            "href": "/admin/events",
            "icon": "calendar"
          },
          {
            "label": "Kommunikation",
            "href": "/admin/comms",
            "icon": "mail"
          }
        ]
      },
      {
        "label": "Verein",
        "items": [
          {
            "label": "Einstellungen",
            "href": "/admin/settings",
            "icon": "settings"
          }
        ]
      }
    ]
  },
  "tabbar": [
    {
      "label": "Übersicht",
      "href": "/admin/",
      "icon": "home"
    },
    {
      "label": "Mitglieder",
      "href": "/admin/members",
      "icon": "users",
      "current": true,
      "count": 3,
      "countLabel": "3 offene Anmeldungen"
    },
    {
      "label": "Prüfungen",
      "href": "/admin/belt-tests",
      "icon": "award"
    },
    {
      "label": "Mehr",
      "href": "/admin/more",
      "icon": "more"
    }
  ]
})
Markup
<div class="shell">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<header class="topbar">
<span class="topbar__title">Anmeldungen</span>
<div class="topbar__actions"><button class="button" type="button" data-variant="ghost" data-icon-only aria-label="Suchen"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#search"/></svg></button><button class="button" type="button" data-variant="ghost" data-icon-only aria-label="Mitteilungen (1 neu)"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#bell"/></svg></button></div>
</header>
<nav class="sidebar" aria-label="Hauptnavigation">
<div class="sidebar__brand">TKD Bern · Admin</div>
<a class="nav-item" href="/admin/"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#home"/></svg>Übersicht</a><a class="nav-item" href="/admin/members" aria-current="page"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#users"/></svg>Mitglieder<span class="count"><span aria-hidden="true">3</span><span class="u-visually-hidden">3 offene Anmeldungen</span></span></a><a class="nav-item" href="/admin/belt-tests"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#award"/></svg>Prüfungen</a><a class="nav-item" href="/admin/events"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#calendar"/></svg>Events</a><a class="nav-item" href="/admin/comms"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#mail"/></svg>Kommunikation</a><p class="sidebar__section">Verein</p><a class="nav-item" href="/admin/settings"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#settings"/></svg>Einstellungen</a>
</nav>
<main class="shell__main" id="main" tabindex="-1"></main>
<nav class="tabbar" aria-label="Hauptnavigation"><a class="tabbar__item" href="/admin/"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#home"/></svg>Übersicht</a><a class="tabbar__item" href="/admin/members" aria-current="page"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#users"/></svg>Mitglieder<span class="count"><span aria-hidden="true">3</span><span class="u-visually-hidden">3 offene Anmeldungen</span></span></a><a class="tabbar__item" href="/admin/belt-tests"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#award"/></svg>Prüfungen</a><a class="tabbar__item" href="/admin/more"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#more"/></svg>Mehr</a></nav>
</div>

Trainer app with club switcher

Data
render({
  "title": "Heute",
  "titleHidden": true,
  "clubSwitcher": {
    "current": {
      "name": "Taekwondo Bern",
      "shortName": "TKD Bern"
    },
    "clubs": [
      {
        "id": "tkd-bern",
        "name": "Taekwondo Bern",
        "href": "/c/tkd-bern/",
        "current": true
      },
      {
        "id": "tkd-thun",
        "name": "Taekwondo Club Thun",
        "href": "/c/tkd-thun/"
      }
    ]
  },
  "actions": [
    {
      "icon": "bell",
      "iconOnly": true,
      "variant": "ghost",
      "ariaLabel": "Mitteilungen (1 neu)"
    }
  ],
  "sidebar": {
    "brand": "TKD Bern · Trainer",
    "sections": [
      {
        "items": [
          {
            "label": "Heute",
            "href": "/trainer/",
            "icon": "home",
            "current": true
          },
          {
            "label": "Klassen",
            "href": "/trainer/classes",
            "icon": "users"
          },
          {
            "label": "Pläne",
            "href": "/trainer/plans",
            "icon": "list"
          },
          {
            "label": "Prüfung",
            "href": "/trainer/belt-test",
            "icon": "award"
          }
        ]
      }
    ],
    "footer": [
      {
        "label": "Hilfe",
        "href": "/help",
        "icon": "info"
      }
    ]
  },
  "tabbar": [
    {
      "label": "Heute",
      "href": "/trainer/",
      "icon": "home",
      "current": true
    },
    {
      "label": "Klassen",
      "href": "/trainer/classes",
      "icon": "users"
    },
    {
      "label": "Pläne",
      "href": "/trainer/plans",
      "icon": "list"
    },
    {
      "label": "Prüfung",
      "href": "/trainer/belt-test",
      "icon": "award"
    },
    {
      "label": "Mehr",
      "href": "/trainer/more",
      "icon": "more"
    }
  ]
})
Markup
<div class="shell">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
<header class="topbar">
<button class="club-switcher" type="button" aria-label="Verein wechseln: Taekwondo Bern" popovertarget="club-menu" aria-haspopup="menu" aria-expanded="false">TKD Bern<svg class="icon" data-size="sm" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#chevron-down"/></svg></button>
<div class="menu" id="club-menu" popover role="menu" data-behavior="menu"><a class="menu__item" role="menuitem" href="/c/tkd-bern/" data-club="tkd-bern" aria-current="true"><span class="u-grow">Taekwondo Bern</span><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#check"/></svg></a><a class="menu__item" role="menuitem" href="/c/tkd-thun/" data-club="tkd-thun"><span class="u-grow">Taekwondo Club Thun</span></a></div>
<span class="topbar__title u-visually-hidden">Heute</span>
<span class="u-grow"></span><div class="topbar__actions"><button class="button" type="button" data-variant="ghost" data-icon-only aria-label="Mitteilungen (1 neu)"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#bell"/></svg></button></div>
</header>
<nav class="sidebar" aria-label="Hauptnavigation">
<div class="sidebar__brand">TKD Bern · Trainer</div>
<a class="nav-item" href="/trainer/" aria-current="page"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#home"/></svg>Heute</a><a class="nav-item" href="/trainer/classes"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#users"/></svg>Klassen</a><a class="nav-item" href="/trainer/plans"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#list"/></svg>Pläne</a><a class="nav-item" href="/trainer/belt-test"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#award"/></svg>Prüfung</a>
<div class="sidebar__footer"><a class="nav-item" href="/help"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#info"/></svg>Hilfe</a></div>
</nav>
<main class="shell__main" id="main" tabindex="-1"></main>
<nav class="tabbar" aria-label="Hauptnavigation"><a class="tabbar__item" href="/trainer/" aria-current="page"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#home"/></svg>Heute</a><a class="tabbar__item" href="/trainer/classes"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#users"/></svg>Klassen</a><a class="tabbar__item" href="/trainer/plans"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#list"/></svg>Pläne</a><a class="tabbar__item" href="/trainer/belt-test"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#award"/></svg>Prüfung</a><a class="tabbar__item" href="/trainer/more"><svg class="icon" aria-hidden="true"><use href="../../packages/ui/icons/icons.svg#more"/></svg>Mehr</a></nav>
</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
titlestringrequired—Page title in the top bar ("Anmeldungen").
min 1 chars
titleHiddenbooleanoptionalfalseHide the title visually (e.g. home screens whose h1 greets the user); it stays for screen readers.
skipLabelstringoptional"Zum Inhalt springen"Text of the skip link.
mainIdstringoptional"main"Id of the main region; target of the skip link. The app renders page content into it.
pattern ^[A-Za-z][\w-]*$
navLabelstringoptional"Hauptnavigation"Accessible name of sidebar and tab bar (only one is visible at a time).
clubSwitcherobjectoptional—Optional club switcher at the start of the top bar (see Club switcher).
clubSwitcher.currentobjectrequired—The active club.
clubSwitcher.current.namestringrequired—Full club name ("Taekwondo Bern"), used in the accessible label.
min 1 chars
clubSwitcher.current.shortNamestringoptional—Short visible name when space is tight ("TKD Bern"). Defaults to name.
clubSwitcher.current.logostringoptional—Logo image URL (src); decorative, square.
clubSwitcher.menuIdstringoptional"club-menu"Id of the popover menu (popovertarget). Unique on the page.
pattern ^[A-Za-z][\w-]*$
clubSwitcher.clubsarray of objectsoptional—All clubs of the person (including the current one), from their memberships. The menu is rendered only with 2 or more.
clubSwitcher.clubs[].idstringrequired—Club id (tenant), written to data-club.
pattern ^[A-Za-z0-9_-]+$
clubSwitcher.clubs[].namestringrequired—Club name shown in the menu.
min 1 chars
clubSwitcher.clubs[].hrefstringrequired—Home URL of that club (switching target).
clubSwitcher.clubs[].logostringoptional—Logo image URL (src).
clubSwitcher.clubs[].currentbooleanoptionalfalseThis is the active club (check mark, aria-current).
backobjectoptional—Back link at the start of the top bar, for detail pages (points to the parent list, not to browser history).
back.labelstringrequired—Parent page name ("Mitglieder"), not "Zurück".
min 1 chars
back.hrefstringrequired—URL of the parent page.
actionsarray of objectsoptional—Top bar actions (Button data), usually ghost icon-only buttons with ariaLabel (search, notifications).
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).
sidebarobjectrequired—Desktop sidebar.
sidebar.brandstringrequired—Brand line at the top ("TKD Bern · Admin").
min 1 chars
sidebar.logostringoptional—Club logo image URL (src), decorative.
sidebar.sectionsarray of objectsrequired—Groups of nav items. The first usually has no label.
min 1 items · max 7 items
sidebar.sections[].labelstringoptional—Section heading ("Verein"); omit for the first group.
sidebar.sections[].itemsarray of objectsrequired—Items of the section.
min 1 items
sidebar.sections[].items[].labelstringrequired—Area name ("Heute", "Mitglieder"). Short — tab bar labels are tiny.
min 1 chars
sidebar.sections[].items[].hrefstringrequired—Route URL of the area.
sidebar.sections[].items[].iconstringrequired—Icon id from the sprite ("home", "users", "list", "award", "more").
sidebar.sections[].items[].currentbooleanoptionalfalseThe current area (aria-current="page"). Exactly one item per navigation.
sidebar.sections[].items[].countintegeroptional—Number of items needing attention (count bubble); 0 shows nothing.
≥ 0
sidebar.sections[].items[].countLabelstringoptional—Accessible text for the count ("3 offene Anmeldungen"); see Count bubble.
sidebar.footerarray of objectsoptional—Items pinned to the bottom (help, settings).
sidebar.footer[].labelstringrequired—Area name ("Heute", "Mitglieder"). Short — tab bar labels are tiny.
min 1 chars
sidebar.footer[].hrefstringrequired—Route URL of the area.
sidebar.footer[].iconstringrequired—Icon id from the sprite ("home", "users", "list", "award", "more").
sidebar.footer[].currentbooleanoptionalfalseThe current area (aria-current="page"). Exactly one item per navigation.
sidebar.footer[].countintegeroptional—Number of items needing attention (count bubble); 0 shows nothing.
≥ 0
sidebar.footer[].countLabelstringoptional—Accessible text for the count ("3 offene Anmeldungen"); see Count bubble.
tabbararray of objectsrequired—Mobile tab bar: max 5 items (4 areas by frequency for the role + "Mehr").
min 2 items · max 5 items
tabbar[].labelstringrequired—Area name ("Heute", "Mitglieder"). Short — tab bar labels are tiny.
min 1 chars
tabbar[].hrefstringrequired—Route URL of the area.
tabbar[].iconstringrequired—Icon id from the sprite ("home", "users", "list", "award", "more").
tabbar[].currentbooleanoptionalfalseThe current area (aria-current="page"). Exactly one item per navigation.
tabbar[].countintegeroptional—Number of items needing attention (count bubble); 0 shows nothing.
≥ 0
tabbar[].countLabelstringoptional—Accessible text for the count ("3 offene Anmeldungen"); see Count bubble.
JSON Schema
{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "title",
    "sidebar",
    "tabbar"
  ],
  "properties": {
    "title": {
      "type": "string",
      "minLength": 1,
      "description": "Page title in the top bar (\"Anmeldungen\")."
    },
    "titleHidden": {
      "type": "boolean",
      "default": false,
      "description": "Hide the title visually (e.g. home screens whose `h1` greets the user); it stays for screen readers."
    },
    "skipLabel": {
      "type": "string",
      "default": "Zum Inhalt springen",
      "description": "Text of the skip link."
    },
    "mainId": {
      "type": "string",
      "pattern": "^[A-Za-z][\\w-]*$",
      "default": "main",
      "description": "Id of the main region; target of the skip link. The app renders page content into it."
    },
    "navLabel": {
      "type": "string",
      "default": "Hauptnavigation",
      "description": "Accessible name of sidebar and tab bar (only one is visible at a time)."
    },
    "clubSwitcher": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "current"
      ],
      "properties": {
        "current": {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "name"
          ],
          "description": "The active club.",
          "properties": {
            "name": {
              "type": "string",
              "minLength": 1,
              "description": "Full club name (\"Taekwondo Bern\"), used in the accessible label."
            },
            "shortName": {
              "type": "string",
              "description": "Short visible name when space is tight (\"TKD Bern\"). Defaults to `name`."
            },
            "logo": {
              "type": "string",
              "description": "Logo image URL (src); decorative, square."
            }
          }
        },
        "menuId": {
          "type": "string",
          "pattern": "^[A-Za-z][\\w-]*$",
          "default": "club-menu",
          "description": "Id of the popover menu (`popovertarget`). Unique on the page."
        },
        "clubs": {
          "type": "array",
          "description": "All clubs of the person (including the current one), from their memberships. The menu is rendered only with 2 or more.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "id",
              "name",
              "href"
            ],
            "properties": {
              "id": {
                "type": "string",
                "pattern": "^[A-Za-z0-9_-]+$",
                "description": "Club id (tenant), written to `data-club`."
              },
              "name": {
                "type": "string",
                "minLength": 1,
                "description": "Club name shown in the menu."
              },
              "href": {
                "type": "string",
                "description": "Home URL of that club (switching target)."
              },
              "logo": {
                "type": "string",
                "description": "Logo image URL (src)."
              },
              "current": {
                "type": "boolean",
                "default": false,
                "description": "This is the active club (check mark, `aria-current`)."
              }
            }
          }
        }
      },
      "description": "Optional club switcher at the start of the top bar (see Club switcher)."
    },
    "back": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "label",
        "href"
      ],
      "description": "Back link at the start of the top bar, for detail pages (points to the parent list, not to browser history).",
      "properties": {
        "label": {
          "type": "string",
          "minLength": 1,
          "description": "Parent page name (\"Mitglieder\"), not \"Zurück\"."
        },
        "href": {
          "type": "string",
          "description": "URL of the parent page."
        }
      }
    },
    "actions": {
      "type": "array",
      "maxItems": 3,
      "description": "Top bar actions (Button data), usually ghost icon-only buttons with `ariaLabel` (search, notifications).",
      "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."
      }
    },
    "sidebar": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "brand",
        "sections"
      ],
      "description": "Desktop sidebar.",
      "properties": {
        "brand": {
          "type": "string",
          "minLength": 1,
          "description": "Brand line at the top (\"TKD Bern · Admin\")."
        },
        "logo": {
          "type": "string",
          "description": "Club logo image URL (src), decorative."
        },
        "sections": {
          "type": "array",
          "minItems": 1,
          "maxItems": 7,
          "description": "Groups of nav items. The first usually has no label.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "items"
            ],
            "properties": {
              "label": {
                "type": "string",
                "description": "Section heading (\"Verein\"); omit for the first group."
              },
              "items": {
                "type": "array",
                "minItems": 1,
                "description": "Items of the section.",
                "items": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "label",
                    "href",
                    "icon"
                  ],
                  "description": "One navigation item.",
                  "properties": {
                    "label": {
                      "type": "string",
                      "minLength": 1,
                      "description": "Area name (\"Heute\", \"Mitglieder\"). Short — tab bar labels are tiny."
                    },
                    "href": {
                      "type": "string",
                      "description": "Route URL of the area."
                    },
                    "icon": {
                      "type": "string",
                      "description": "Icon id from the sprite (\"home\", \"users\", \"list\", \"award\", \"more\")."
                    },
                    "current": {
                      "type": "boolean",
                      "default": false,
                      "description": "The current area (`aria-current=\"page\"`). Exactly one item per navigation."
                    },
                    "count": {
                      "type": "integer",
                      "minimum": 0,
                      "description": "Number of items needing attention (count bubble); 0 shows nothing."
                    },
                    "countLabel": {
                      "type": "string",
                      "description": "Accessible text for the count (\"3 offene Anmeldungen\"); see Count bubble."
                    }
                  }
                }
              }
            }
          }
        },
        "footer": {
          "type": "array",
          "description": "Items pinned to the bottom (help, settings).",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "label",
              "href",
              "icon"
            ],
            "description": "One navigation item.",
            "properties": {
              "label": {
                "type": "string",
                "minLength": 1,
                "description": "Area name (\"Heute\", \"Mitglieder\"). Short — tab bar labels are tiny."
              },
              "href": {
                "type": "string",
                "description": "Route URL of the area."
              },
              "icon": {
                "type": "string",
                "description": "Icon id from the sprite (\"home\", \"users\", \"list\", \"award\", \"more\")."
              },
              "current": {
                "type": "boolean",
                "default": false,
                "description": "The current area (`aria-current=\"page\"`). Exactly one item per navigation."
              },
              "count": {
                "type": "integer",
                "minimum": 0,
                "description": "Number of items needing attention (count bubble); 0 shows nothing."
              },
              "countLabel": {
                "type": "string",
                "description": "Accessible text for the count (\"3 offene Anmeldungen\"); see Count bubble."
              }
            }
          }
        }
      }
    },
    "tabbar": {
      "type": "array",
      "minItems": 2,
      "maxItems": 5,
      "description": "Mobile tab bar: max 5 items (4 areas by frequency for the role + \"Mehr\").",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "label",
          "href",
          "icon"
        ],
        "description": "One navigation item.",
        "properties": {
          "label": {
            "type": "string",
            "minLength": 1,
            "description": "Area name (\"Heute\", \"Mitglieder\"). Short — tab bar labels are tiny."
          },
          "href": {
            "type": "string",
            "description": "Route URL of the area."
          },
          "icon": {
            "type": "string",
            "description": "Icon id from the sprite (\"home\", \"users\", \"list\", \"award\", \"more\")."
          },
          "current": {
            "type": "boolean",
            "default": false,
            "description": "The current area (`aria-current=\"page\"`). Exactly one item per navigation."
          },
          "count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of items needing attention (count bubble); 0 shows nothing."
          },
          "countLabel": {
            "type": "string",
            "description": "Accessible text for the count (\"3 offene Anmeldungen\"); see Count bubble."
          }
        }
      }
    }
  }
}

Markup & states

Root: div.shell — usable without render() by writing the markup directly.

Attributes

AttributeValuesDefaultDescription
aria-current="page"——On the active .nav-item / .tabbar__item.
aria-label (nav)text—Names sidebar and tab bar ("Hauptnavigation").

Parts

PartDescription
.skip-linkFirst focusable element; jumps to #{mainId}. Visible on focus.
.topbar / .topbar__title / .topbar__actionsSticky top bar with title and actions; may start with .club-switcher or .back-link.
.sidebar / .sidebar__brand / .sidebar__section / .sidebar__footerDesktop navigation (≥ 1024 px).
.nav-itemSidebar link with icon, label and optional .count.
.shell__mainEmpty main region (tabindex="-1") that the app fills.
.tabbar / .tabbar__itemMobile bottom navigation (< 1024 px), max 5 items.

States

StateDescription
[aria-current="page"]Active item. Sidebar: accent-subtle background, accent-text label and icon, 3 px accent bar on the start edge. Tab bar: accent-text + top indicator.
.skip-link:focusSkip link slides into view.

CSS custom properties

PropertyDefaultDescription
--sidebar-w—Sidebar width (token).
--topbar-h / --tabbar-h—Bar heights (tokens).
--safe-top / --safe-bottom—Safe-area insets for notched phones.

Accessibility

  • Skip link first; main has tabindex="-1" so the skip link and route changes can move focus there.
  • Landmarks: header, nav (labelled), main. Sidebar and tab bar share a label but only one is displayed at a time (the other is display: none).
  • The active item has aria-current="page", not only the accent colour.
  • Counts on nav items carry an accessible label ("Mitglieder, 3 offene Anmeldungen").
  • Icons in nav items are decorative; labels are always visible (no icon-only navigation).

Guidance

DoOrder items by frequency for the role (Trainer: Heute, Klassen, Pläne, Prüfung, Mehr).
DoBuild navigation from the session's capabilities: hide what the club didn't buy or the user may not use.
DoKeep the club visible (switcher or brand) on every screen.
Don'tDon't exceed 5 tab bar items or ~7 sidebar groups.
Don'tDon't show disabled or locked teaser items.
Don'tDon't let modules render their own header or sidebar.

Related: Page header · Club switcher · Count bubble · Tabs