Zum Inhalt
← Sektionen & Funktionen

Vergleichstabelle mit fester Kopfzeile

Echte Tabelle mit fester Kopfzeile und erster Spalte, Monats- und Jahrespreis, hervorgehobenem Paket, „Nur Unterschiede“ und Paket-Umschalter auf dem Telefon.

SaaS, Kurse, Fahrschule, Fotografen-Pakete: drei bis vier Pakete mit Preis, ein empfohlenes in der Mitte.

Pakete oder Angebote als echte Tabelle. Beim Scrollen bleibt die Kopfzeile mit Paketname, Preis und Knopf stehen, beim seitlichen Scrollen die erste Spalte mit den Merkmalen. Ein Umschalter wechselt zwischen Monats- und Jahrespreis, ein Paket ist hervorgehoben. Merkmale sind in Gruppen geordnet, ein Schalter blendet gleiche Zeilen aus. Auf dem Telefon zeigt die Tabelle ein Paket zur Zeit, gewählt über einen Paket-Umschalter statt einer breiten Tabelle.

Wer vergleicht, verliert in langen Tabellen schnell den Bezug: Welche Spalte war das? Die feste Kopfzeile beantwortet das immer. „Nur Unterschiede“ macht die Entscheidung kürzer. Auf dem Telefon bleibt alles lesbar, ohne dass man waagerecht wischen muss.

Wann einsetzen

Preisseiten von Software, Kurs- und Fahrschul-Pakete, Fotografen-Pakete, Tarife, Vergleich mit Alternativen („wir gegen Excel“).

Wann nicht

Bei zwei Paketen mit wenigen Unterschieden: dann zwei Karten. Bei mehr als fünf Spalten: dann Auswahl von zwei bis drei zum Vergleich. Nicht für Fließtext-Inhalte, die keine Tabelle sind.

Varianten

  • Paketvergleich mit Monats- und Jahrespreis
  • Vergleich mit Alternativen (eigenes Produkt gegen Tabellenkalkulation und Papier)
  • Gruppen zum Ein- und Ausklappen
  • „Nur Unterschiede zeigen“
  • Telefon: Paket-Umschalter oder Auswahl von zwei Paketen nebeneinander

Umsetzung

Eine <table> mit <caption>, <thead> (Pakete als <th scope="col">) und einem <tbody> je Gruppe mit <th scope="colgroup">; Merkmale als <th scope="row">. Häkchen mit Text für Screenreader („enthalten“, „nicht enthalten“). Rahmen mit role="region" und aria-labelledby auf die caption; tabindex="0" nur, solange er scrollt. Breit (Tabelle passt): Rahmen ohne overflow, die Kopfzeile klebt am Fenster, kein Scrollen im Scrollen. Mittel: Rahmen scrollt in beiden Achsen, Kopfzeile oben und erste Spalte links bleiben stehen. Telefon: Paket-Umschalter blendet die anderen Spalten per Klasse aus, die Tabelle behält display: table. Abrechnung als Radiogruppe mit gleitender Pille; neue Preise per aria-live ansagen. Preise netto zzgl. USt. nur bei Angeboten für Unternehmen; sonst Gesamtpreise (vom Kunden prüfen lassen).

Robust umsetzen

Telefon. Unter 640 px ein Paket zur Zeit: Segment-Umschalter oben fest, darunter Kopfzeile des Pakets mit Preis und Knopf, Tabelle zweispaltig (Merkmal, Wert). Kein waagerechtes Wischen, kein Dropdown.

Ohne Bewegung. Preise und Spalten wechseln ohne Versatz und ohne Überblendung.

Barrierefrei. Echte Tabellen-Semantik (caption, th mit scope). Scroll-Container per Tastatur erreichbar (tabindex="0", role="region", Name aus der caption), Fokus sichtbar. Häkchen mit Text für Screenreader. Umschalter als Radiogruppe mit sichtbarer Beschriftung; Preiswechsel wird über aria-live angesagt. Das hervorgehobene Paket nicht nur über Farbe kennzeichnen, sondern mit Text („Empfohlen“).

Code

<!DOCTYPE html>
<!--
  Vergleichstabelle mit fester Kopfzeile
  Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/vergleichstabelle-sticky/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vergleichstabelle mit fester Kopfzeile · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  /* Rezept SaaS präzise, helle KMU-Variante: Graustufen plus ein Markenton */
  --grund: #FAFAFA; --flaeche: #FFFFFF; --text: #161616; --zweit: #4B4F56; --meta: #6B6F76;
  --linie: #E6E6E8; --linie-stark: #D4D4D8; --zeile: #F4F4F5;
  --marke: #C8430F; --marke-hell: #FBEFE8; --marke-text: #A3360B;
  --kurve: cubic-bezier(0.25, 0.46, 0.45, 0.94);   /* ease-out-quad wie Linear */
  --t-ui: 160ms; --t-wechsel: 180ms;
  --spalte1: 248px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Geist', system-ui, sans-serif; font-weight: 400; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
.ziffern { font-variant-numeric: tabular-nums; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Kopf */
.kopfleiste { display: flex; align-items: center; justify-content: space-between; gap: 24px; height: 60px; padding: 0 clamp(16px, 4vw, 40px); border-bottom: 1px solid var(--linie); background: var(--flaeche); }
.marke { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; letter-spacing: -.01em; }
.marke svg { width: 22px; height: 22px; }
.kopfleiste nav { display: flex; gap: 26px; font-size: 14px; color: var(--zweit); }
.kopfleiste nav [aria-current] { color: var(--text); font-weight: 500; }
.kopfleiste .rechts { display: flex; gap: 16px; align-items: center; font-size: 14px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; padding: 0 14px; border-radius: 6px; font-size: 14px; font-weight: 500; text-decoration: none; border: 1px solid transparent; cursor: pointer; transition: background-color var(--t-ui) var(--kurve), border-color var(--t-ui) var(--kurve); white-space: nowrap; }
.knopf.primaer { background: var(--marke); color: #fff; }
.knopf.primaer:hover { background: #B23A0C; }
.knopf.zweit { background: var(--flaeche); border-color: var(--linie-stark); color: var(--text); }
.knopf.zweit:hover { border-color: #A1A1AA; }
.knopf:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }

.seite { max-width: 1200px; margin: 0 auto; padding: clamp(28px, 5vw, 64px) clamp(16px, 4vw, 40px) 64px; }
.einstieg { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 24px 48px; margin-bottom: 28px; }
.einstieg .mono { color: var(--marke-text); margin-bottom: 14px; }
.einstieg h1 { font-size: clamp(32px, 1.6rem + 2.6vw, 52px); line-height: 1.02; font-weight: 580; letter-spacing: -.022em; max-width: 19ch; text-wrap: balance; }
.zusagen { list-style: none; display: grid; gap: 10px; padding: 16px 18px; border: 1px solid var(--linie); border-radius: 10px; background: var(--flaeche); color: var(--zweit); font-size: 10.5px; }
.zusagen li { display: flex; align-items: center; gap: 10px; }
.zusagen .ja { width: 16px; height: 16px; color: var(--marke); flex: none; }
.einstieg p { font-size: 16px; line-height: 1.6; color: var(--zweit); max-width: 52ch; margin-top: 14px; letter-spacing: -.006em; }

/* Werkzeugleiste über der Tabelle */
.leiste { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px 24px; padding: 12px 0 16px; }
.abrechnung { position: relative; display: inline-flex; padding: 3px; background: #EFEFF1; border-radius: 8px; border: 0; }
.abrechnung legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.abrechnung label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 14px; font-size: 14px; font-weight: 500; color: var(--zweit); cursor: pointer; border-radius: 6px; transition: color var(--t-wechsel) var(--kurve); }
.abrechnung input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.abrechnung label:has(input:checked) { color: var(--text); }
.abrechnung label:has(input:focus-visible) { outline: 2px solid var(--marke); outline-offset: 1px; }
.abrechnung .rabatt { font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .04em; color: var(--marke-text); background: var(--marke-hell); padding: 4px 6px; border-radius: 4px; }
.abrechnung .pille { position: absolute; z-index: 0; top: 3px; bottom: 3px; left: 0; width: 0; background: var(--flaeche); border-radius: 6px; box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.04); transition: translate var(--t-wechsel) var(--kurve), width var(--t-wechsel) var(--kurve); }
.unterschiede { display: inline-flex; align-items: center; gap: 10px; background: none; border: 0; font-size: 14px; color: var(--zweit); cursor: pointer; padding: 6px 0; }
.unterschiede .schiene { width: 32px; height: 18px; border-radius: 9px; background: var(--linie-stark); position: relative; transition: background-color var(--t-ui) var(--kurve); }
.unterschiede .schiene::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: translate var(--t-ui) var(--kurve); }
.unterschiede[aria-checked="true"] .schiene { background: var(--text); }
.unterschiede[aria-checked="true"] .schiene::after { translate: 14px 0; }
.unterschiede:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; border-radius: 4px; }
.unterschiede .zaehler { color: var(--meta); }

/* Paket-Umschalter (nur Telefon) */
.paketwahl { display: none; }

/* Tabelle im Scroll-Container */
.rahmen {
  position: relative; overflow: auto; max-height: min(calc(100svh - 96px), 860px);
  background: var(--flaeche); border: 1px solid var(--linie); border-radius: 10px;
  overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--linie-stark) transparent;
}
.rahmen:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
table { border-collapse: separate; border-spacing: 0; width: 100%; min-width: 880px; font-size: 14px; }
caption { text-align: left; padding: 0; }
th, td { padding: 12px 16px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--linie); }
td { text-align: center; color: var(--zweit); }

/* Fest: Kopfzeile oben, erste Spalte links, Eckzelle über beiden */
thead th { position: sticky; top: 0; z-index: 2; background: var(--flaeche); vertical-align: top; padding: 18px 16px 16px; border-bottom: 1px solid var(--linie-stark); text-align: left; font-weight: 400; }
tbody th[scope="row"] { position: sticky; left: 0; z-index: 1; background: var(--flaeche); width: var(--spalte1); min-width: var(--spalte1); font-weight: 400; color: var(--text); }
thead th:first-child { left: 0; z-index: 3; width: var(--spalte1); }
.rahmen.runter thead th { box-shadow: 0 1px 0 var(--linie-stark), 0 8px 16px -10px rgba(0,0,0,.18); }
.rahmen.quer tbody th[scope="row"], .rahmen.quer thead th:first-child { box-shadow: 1px 0 0 var(--linie-stark), 8px 0 14px -10px rgba(0,0,0,.2); }

.ecke { display: grid; gap: 10px; align-content: start; }
.ecke strong { font-size: 15px; font-weight: 560; letter-spacing: -.01em; }
.ecke span { color: var(--meta); font-size: 13px; line-height: 1.45; }

.paket { display: grid; gap: 6px; min-width: 150px; }
.paket .name { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 560; letter-spacing: -.01em; }
.paket .empf { font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: #fff; background: var(--marke); padding: 4px 6px; border-radius: 4px; }
.paket .satz { font-size: 13px; line-height: 1.4; color: var(--meta); min-height: 2.8em; }
.preiszeile { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.preisfenster { display: inline-block; height: 38px; overflow: hidden; }
.preis { font-size: 30px; font-weight: 560; letter-spacing: -.02em; line-height: 38px; font-variant-numeric: tabular-nums; display: inline-block; }
.preiszeile small { font-size: 12px; color: var(--meta); line-height: 1.25; }
.preisnotiz { font-size: 12.5px; color: var(--meta); min-height: 1.4em; }
.paket .knopf { width: 100%; margin-top: 8px; }

/* Hervorgehobenes Paket */
[data-hervor] { background: #FFFCFA; }
thead th[data-hervor] { background: #FFFCFA; box-shadow: inset 0 3px 0 var(--marke); }
.rahmen.runter thead th[data-hervor] { box-shadow: inset 0 3px 0 var(--marke), 0 1px 0 var(--linie-stark), 0 8px 16px -10px rgba(0,0,0,.18); }
td[data-hervor] { color: var(--text); }

.gruppe th { background: var(--zeile); font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--zweit); padding: 10px 16px; border-bottom: 1px solid var(--linie); }
.gruppe th span { position: sticky; left: 16px; }
tbody tr:not(.gruppe):hover th, tbody tr:not(.gruppe):hover td { background: #F7F7F8; }
tbody tr:not(.gruppe):hover td[data-hervor] { background: #FDF5F0; }
.merkmal { display: grid; gap: 2px; }
.merkmal small { color: var(--meta); font-size: 12.5px; line-height: 1.4; }
.ja { width: 18px; height: 18px; color: var(--text); vertical-align: middle; }
td[data-hervor] .ja { color: var(--marke); }
.nein { display: inline-block; width: 12px; height: 1.5px; background: #B9BBC0; vertical-align: middle; }
.wert { font-variant-numeric: tabular-nums; }

/* Nur Unterschiede */
.nur-unterschiede tr.gleich, .nur-unterschiede tbody.alle-gleich { display: none; }
@keyframes markiert { from { background: var(--marke-hell); } }
tr.neu-sichtbar th, tr.neu-sichtbar td { animation: markiert 600ms var(--kurve); }
.reduziert tr.neu-sichtbar th, .reduziert tr.neu-sichtbar td { animation: none; }

.fuss { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 32px; margin-top: 16px; font-size: 13px; line-height: 1.5; color: var(--meta); }
.fuss .mono { font-size: 10px; letter-spacing: .1em; }

/* Live-Ansage */
.ansage { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

/* Variante „Alternativen“: kein Preis-Umschalter, Spalten mit Haltung */
[data-variante="alternativen"] .abrechnung, [data-variante="alternativen"] .unterschiede, [data-variante="alternativen"] .preiszeile, [data-variante="alternativen"] .preisnotiz { display: none; }
[data-variante="alternativen"] td { font-size: 13.5px; line-height: 1.45; text-align: left; }
[data-variante="alternativen"] td .nein { margin-right: 8px; }

/* Pult */
.pult { position: fixed; left: 16px; bottom: 16px; z-index: 20; display: flex; align-items: center; gap: 10px; background: rgba(22, 22, 22, .92); color: #fff; padding: 8px 10px 8px 14px; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.2); }
.pult .mono { font-size: 10px; color: rgba(255,255,255,.6); }
.pult .gruppe-k { display: flex; background: rgba(255,255,255,.08); border-radius: 6px; padding: 2px; }
.pult button { background: none; border: 0; color: rgba(255,255,255,.75); font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .06em; text-transform: uppercase; padding: 7px 9px; border-radius: 4px; cursor: pointer; }
.pult button[aria-pressed="true"] { background: #fff; color: var(--text); }
.pult button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }

/* Reduzierte Bewegung */
:root.reduziert .abrechnung .pille, :root.reduziert .unterschiede .schiene::after, :root.reduziert .unterschiede .schiene { transition: none; }

/* Touch */
@media (hover: none) {
  tbody tr:not(.gruppe):hover th, tbody tr:not(.gruppe):hover td { background: inherit; }
  .abrechnung label { height: 40px; }
  .unterschiede { padding: 10px 0; }
  .knopf { height: 44px; }
}

/* Breit: Tabelle passt, Kopfzeile klebt am Fenster (kein Scrollen im Scrollen) */
@media (min-width: 1100px) {
  .rahmen { overflow: visible; max-height: none; }
  thead th:first-child { border-top-left-radius: 10px; }
  thead th:last-child { border-top-right-radius: 10px; }
}
/* Mittlere Breiten: Tabelle scrollt in ihrem Rahmen, Kopfzeile oben und erste Spalte links bleiben stehen */
@media (max-width: 960px) {
  .kopfleiste nav { display: none; }
  .einstieg { grid-template-columns: 1fr; }
  :root { --spalte1: 210px; }
}

/* Telefon: ein Paket zur Zeit, gewählt über Segmente; bleibt eine Tabelle */
@media (max-width: 640px) {
  .kopfleiste .rechts a:not(.knopf) { display: none; }
  .leiste { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 4px 0 12px; }
  .paketwahl { position: sticky; top: 0; z-index: 6; margin: 0 -16px 12px; border-radius: 0; border-width: 0 0 1px; box-shadow: 0 1px 0 var(--linie); }
  .abrechnung { display: flex; width: 100%; min-width: 0; }
  td { text-align: left; }
  .abrechnung label { flex: 1; justify-content: center; padding: 0 6px; white-space: nowrap; }
  .abrechnung .rabatt { font-size: 9.5px; padding: 3px 5px; }
  .zusagen { display: none; }
  .paketwahl { display: flex; gap: 0; border: 1px solid var(--linie-stark); overflow: hidden; background: var(--flaeche); }
  .paketwahl legend { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  .paketwahl label { position: relative; flex: 1; text-align: center; font-size: 13.5px; font-weight: 500; padding: 11px 2px; color: var(--zweit); cursor: pointer; transition: background-color var(--t-ui) var(--kurve), color var(--t-ui) var(--kurve); }
  .paketwahl label + label { border-left: 1px solid var(--linie); }
  .paketwahl input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
  .paketwahl label:has(input:checked) { background: var(--text); color: #fff; }
  .paketwahl label:has(input:focus-visible) { outline: 2px solid var(--marke); outline-offset: -3px; }
  .paketwahl .punkt { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--marke); margin-left: 4px; vertical-align: 2px; }
  .rahmen { overflow: visible; max-height: none; border-radius: 8px; }
  table { min-width: 0; table-layout: fixed; }
  :root { --spalte1: 52%; }
  .aus { display: none; }
  thead th { top: var(--leiste-h, 112px); }
  thead th:first-child { vertical-align: bottom; }
  .ecke span { display: none; }
  .paket .satz { display: none; }
  .paket { gap: 4px; }
  .paket .knopf { height: 38px; margin-top: 4px; }
  thead th { padding: 14px 12px 12px; }
  .preis { font-size: 26px; }
  th, td { padding: 11px 12px; }
  .gruppe th span { left: 12px; }
  .unterschiede { width: 100%; }
  .unterschiede .zaehler { margin-left: auto; }
  .pult { left: 12px; right: auto; bottom: 12px; }
  [data-variante="alternativen"] td { font-size: 13px; }
}
@media (max-width: 360px) {
  .paket { min-width: 0; }                     /* 320 px: Paketspalte darf schmaler werden, kein Querscrollen */
  .paket .knopf { white-space: normal; text-align: center; }
}

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .kopfleiste { display: none; }
.vorschau .seite { padding: 22px 28px 0; }
.vorschau .einstieg { grid-template-columns: minmax(0, 1fr) auto; margin-bottom: 4px; }
.vorschau .einstieg h1 { font-size: 34px; max-width: 22ch; }
.vorschau .einstieg p, .vorschau .zusagen { display: none; }
.vorschau .einstieg .mono { margin-bottom: 8px; }
.vorschau .rahmen { max-height: 548px; }
.vorschau .fuss { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopfleiste">
  <div class="marke"><svg viewBox="0 0 22 22" aria-hidden="true"><rect x="1" y="1" width="20" height="20" rx="5" fill="#161616"/><path d="M6 11.5l3.2 3.2L16 7.6" fill="none" stroke="#C8430F" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>Kernlog</div>
  <nav aria-label="Hauptnavigation"><span>Produkt</span><span>Kunden</span><span aria-current="page">Preise</span><span>Doku</span><span>Änderungen</span></nav>
  <div class="rechts"><a href="#vergleich" style="color:inherit;text-decoration:none">Anmelden</a><a class="knopf primaer" href="#vergleich">Kostenlos testen</a></div>
</header>

<main class="seite" id="vergleich">
  <div class="einstieg">
    <div>
      <p class="mono" data-eyebrow>Preise · Ingolstadt</p>
      <h1 data-titel>Ein Preis je Person. Maschinen zählen wir nicht.</h1>
      <p data-unterzeile>Prüfpläne, Wartungsprotokolle und Fristen für Ihre Anlagen. Alle Pakete mit unbegrenzt vielen Maschinen; monatlich kündbar.</p>
    </div>
    <ul class="zusagen mono" aria-label="In allen Paketen">
      <li><svg class="ja" aria-hidden="true"><use href="#haken"/></svg>Rechenzentrum in Deutschland</li>
      <li><svg class="ja" aria-hidden="true"><use href="#haken"/></svg>Vertrag zur Auftragsverarbeitung</li>
      <li><svg class="ja" aria-hidden="true"><use href="#haken"/></svg>Daten jederzeit exportierbar</li>
    </ul>
  </div>

  <div class="leiste" data-leiste>
    <fieldset class="abrechnung" data-abrechnung>
      <legend>Abrechnung</legend>
      <span class="pille" aria-hidden="true"></span>
      <label><input type="radio" name="abrechnung" value="monat" checked>Monatlich</label>
      <label><input type="radio" name="abrechnung" value="jahr">Jährlich <span class="rabatt">2 Monate gratis</span></label>
    </fieldset>
    <button type="button" class="unterschiede" role="switch" aria-checked="false" data-unterschiede>
      <span class="schiene" aria-hidden="true"></span><span>Nur Unterschiede</span><span class="zaehler ziffern" data-zaehler></span>
    </button>
  </div>
  <fieldset class="paketwahl" data-paketwahl><legend>Paket zeigen</legend></fieldset>

  <div class="rahmen" data-rahmen role="region" aria-labelledby="tab-titel" tabindex="0">
    <table data-tabelle>
      <caption class="nur-sr" id="tab-titel">Kernlog-Pakete im Vergleich</caption>
    </table>
  </div>

  <div class="fuss">
    <p data-fusstext>Alle Preise je Person und Monat, netto zzgl. 19 % USt. Das Angebot richtet sich an Unternehmen. Start bleibt kostenlos, ohne Kreditkarte.</p>
    <p class="mono" data-werte></p>
  </div>
  <p class="ansage" aria-live="polite" data-ansage></p>
</main>

<div class="pult">
  <span class="mono">Variante</span>
  <div class="gruppe-k" role="group" aria-label="Variante">
    <button type="button" data-v="pakete" aria-pressed="true">Pakete</button>
    <button type="button" data-v="alternativen" aria-pressed="false">Alternativen</button>
  </div>
</div>

<svg width="0" height="0" style="position:absolute" aria-hidden="true"><symbol id="haken" viewBox="0 0 18 18"><path d="M4 9.4l3.1 3.1L14 5.6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const eur = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });

  // Erfundenes Produkt: Kernlog, Instandhaltung und Prüfprotokolle, Ingolstadt
  const J = true, N = false;
  const DATEN = {
    pakete: {
      ecke: ['Pakete vergleichen', 'Preise je Person und Monat. Wechsel jederzeit, anteilig verrechnet.'],
      spalten: [
        { id: 'start', name: 'Start', satz: 'Für eine Werkstatt, die vom Papier weg will.', monat: 0, jahr: 0, knopf: 'Kostenlos starten', art: 'zweit' },
        { id: 'team', name: 'Team', satz: 'Für Instandhaltung mit mehreren Schichten.', monat: 12, jahr: 10, knopf: '14 Tage testen', art: 'primaer', hervor: true },
        { id: 'werk', name: 'Werk', satz: 'Für mehrere Hallen, Audits und Zertifikate.', monat: 24, jahr: 20, knopf: '14 Tage testen', art: 'zweit' },
        { id: 'konzern', name: 'Konzern', satz: 'Für Standorte in mehreren Ländern.', monat: null, jahr: null, knopf: 'Gespräch vereinbaren', art: 'zweit' }
      ],
      gruppen: [
        ['Grundlagen', [
          ['Personen', '', ['bis 3', 'unbegrenzt', 'unbegrenzt', 'unbegrenzt']],
          ['Maschinen und Anlagen', '', ['unbegrenzt', 'unbegrenzt', 'unbegrenzt', 'unbegrenzt']],
          ['Prüfpläne', 'Intervalle, Zuständige, Checklisten', ['5', 'unbegrenzt', 'unbegrenzt', 'unbegrenzt']],
          ['App für Telefon und Tablet', 'arbeitet auch ohne Netz in der Halle', [J, J, J, J]]
        ]],
        ['Prüfungen und Protokolle', [
          ['Fotos und Messwerte im Protokoll', '', [J, J, J, J]],
          ['Unterschrift auf dem Gerät', 'mit Zeitstempel und Name', [N, J, J, J]],
          ['Vorlagen für Elektroprüfung', 'nach DGUV Vorschrift 3, anpassbar', [N, J, J, J]],
          ['Erinnerung vor Fristablauf', '', ['E-Mail', 'E-Mail und App', 'E-Mail und App', 'E-Mail und App']],
          ['Mängel als Aufgabe weitergeben', '', [N, J, J, J]]
        ]],
        ['Auswertung', [
          ['Prüfbericht als PDF', '', [J, J, J, J]],
          ['Übersicht offener Fristen', '', [J, J, J, J]],
          ['Kennzahlen je Halle und Anlage', 'Ausfälle, Verzug, Kosten', [N, N, J, J]],
          ['Export CSV und Excel', '', [N, J, J, J]],
          ['Schnittstelle (API)', '', [N, N, J, J]]
        ]],
        ['Sicherheit und Betrieb', [
          ['Rollen und Rechte', '', ['2 Rollen', 'eigene Rollen', 'eigene Rollen', 'eigene Rollen']],
          ['Anmeldung über Firmenkonto (SSO)', '', [N, N, J, J]],
          ['Änderungsprotokoll', 'wer hat wann was geändert', [N, '30 Tage', 'unbegrenzt', 'unbegrenzt']],
          ['Rechenzentrum in Deutschland', '', [J, J, J, J]],
          ['Vertrag zur Auftragsverarbeitung', '', [J, J, J, J]]
        ]],
        ['Begleitung', [
          ['Hilfe', '', ['E-Mail', 'Chat, Antwort in 4 h', 'Telefon und Chat', 'Telefon, 24/7']],
          ['Einführung im Werk', '', [N, N, '1 Tag vor Ort', 'nach Plan']],
          ['Feste Ansprechperson', '', [N, N, N, J]]
        ]]
      ]
    },
    alternativen: {
      ecke: ['Warum wechseln?', 'Kernlog gegen das, was in vielen Werkstätten heute läuft.'],
      spalten: [
        { id: 'kernlog', name: 'Kernlog', satz: 'Prüfpläne und Protokolle in einer App.', knopf: '14 Tage testen', art: 'primaer', hervor: true },
        { id: 'tabelle', name: 'Tabellenkalkulation', satz: 'Eine Datei pro Halle, gepflegt von einer Person.', knopf: 'Vorlage importieren', art: 'zweit' },
        { id: 'papier', name: 'Papier und Ordner', satz: 'Prüfkarten an der Maschine, Ordner im Büro.', knopf: 'Ordner abscannen lassen', art: 'zweit' }
      ],
      gruppen: [
        ['Im Alltag', [
          ['Fristen im Blick', '', ['Erinnerung vor Ablauf, automatisch', 'nur mit eigener Formel und Disziplin', 'Wandkalender, oft veraltet']],
          ['Protokoll an der Maschine', '', ['App, auch ohne Netz', 'Laptop nötig oder später abtippen', 'Klemmbrett, später ablegen']],
          ['Fotos zum Mangel', '', ['direkt im Protokoll', 'separat speichern und verlinken', 'ausdrucken und abheften']],
          ['Mehrere Schichten', '', ['alle sehen denselben Stand', 'Dateiversionen per Mail', 'Übergabe mündlich']]
        ]],
        ['Beim Audit', [
          ['Nachweis für eine Anlage', '', ['Bericht in einer Minute', 'Zeilen suchen und zusammenstellen', 'Ordner holen und kopieren']],
          ['Wer hat geprüft?', '', ['Name, Zeit, Unterschrift', 'Kürzel in einer Zelle', 'Unterschrift, wenn lesbar']],
          ['Änderungen nachvollziehen', '', ['vollständiges Protokoll', N, N]]
        ]],
        ['Kosten', [
          ['Preis', '', ['ab 10 € je Person und Monat', 'Lizenz meist vorhanden', 'Papier und Zeit']],
          ['Aufwand pro Prüfung', 'Beispielwerte, vor Einsatz durch eigene Messung ersetzen', ['ca. 4 Minuten', 'ca. 12 Minuten', 'ca. 15 Minuten']]
        ]]
      ]
    }
  };

  const zustand = { variante: 'pakete', abrechnung: 'monat', nurUnterschiede: false, mobil: 'team' };
  const tabelle = $('[data-tabelle]'), rahmen = $('[data-rahmen]'), ansage = $('[data-ansage]');
  const schalter = $('[data-unterschiede]'), zaehler = $('[data-zaehler]'), paketwahl = $('[data-paketwahl]');
  const abrechnung = $('[data-abrechnung]'), pille = abrechnung.querySelector('.pille');
  const schmal = matchMedia('(max-width: 640px)');

  const zelle = (w, sp) => {
    if (w === true) return `<svg class="ja" aria-hidden="true"><use href="#haken"/></svg><span class="nur-sr">enthalten</span>`;
    if (w === false) return `<span class="nein" aria-hidden="true"></span><span class="nur-sr">nicht enthalten</span>${zustand.variante === 'alternativen' ? '<span aria-hidden="true">nicht möglich</span>' : ''}`;
    return `<span class="wert">${w}</span>`;
  };

  function preisText(sp) {
    const p = sp[zustand.abrechnung];
    if (p === null) return { preis: 'Individuell', klein: '', notiz: 'ab 200 Personen' };
    if (p === 0) return { preis: eur.format(0), klein: 'für immer', notiz: 'bis 3 Personen' };
    return { preis: eur.format(p), klein: 'je Person<br>und Monat', notiz: zustand.abrechnung === 'jahr' ? `jährlich ${eur.format(p * 12)} je Person` : 'monatlich kündbar' };
  }

  function baue() {
    const d = DATEN[zustand.variante];
    const sp = d.spalten;
    if (!sp.some(s => s.id === zustand.mobil)) zustand.mobil = (sp.find(s => s.hervor) || sp[0]).id;
    const aus = s => s.id === zustand.mobil ? '' : ' class="aus"';
    let h = `<caption class="nur-sr" id="tab-titel">${zustand.variante === 'pakete' ? 'Kernlog-Pakete im Vergleich' : 'Kernlog im Vergleich mit Tabellenkalkulation und Papier'}</caption>`;
    h += `<thead><tr><th scope="col"><div class="ecke"><strong>${d.ecke[0]}</strong><span>${d.ecke[1]}</span></div></th>`;
    h += sp.map(s => {
      const p = s.monat !== undefined ? preisText(s) : null;
      return `<th scope="col" data-p="${s.id}"${s.hervor ? ' data-hervor' : ''}${aus(s)}><div class="paket">
        <span class="name">${s.name}${s.hervor && zustand.variante === 'pakete' ? '<span class="empf">Empfohlen</span>' : ''}</span>
        <span class="satz">${s.satz}</span>
        ${p ? `<span class="preiszeile"><span class="preisfenster"><span class="preis" data-preis="${s.id}">${p.preis}</span></span><small data-klein="${s.id}">${p.klein}</small></span><span class="preisnotiz" data-notiz="${s.id}">${p.notiz}</span>` : ''}
        <a class="knopf ${s.art}" href="#vergleich" aria-label="${s.knopf}: ${s.name}">${s.knopf}</a>
      </div></th>`;
    }).join('') + '</tr></thead>';
    let gesamt = 0, verschieden = 0;
    d.gruppen.forEach(([gname, zeilen]) => {
      let gleichAlle = true;
      const zeilenHtml = zeilen.map(([name, info, werte]) => {
        gesamt++;
        const gleich = werte.every(w => JSON.stringify(w) === JSON.stringify(werte[0]));
        if (!gleich) { verschieden++; gleichAlle = false; }
        return `<tr${gleich ? ' class="gleich"' : ''}><th scope="row"><span class="merkmal">${name}${info ? `<small>${info}</small>` : ''}</span></th>${werte.map((w, i) => `<td data-p="${sp[i].id}"${sp[i].hervor ? ' data-hervor' : ''}${aus(sp[i])}>${zelle(w, sp[i])}</td>`).join('')}</tr>`;
      }).join('');
      h += `<tbody${gleichAlle ? ' class="alle-gleich"' : ''}><tr class="gruppe"><th scope="colgroup" colspan="${schmal.matches ? 2 : sp.length + 1}" data-voll="${sp.length + 1}"><span>${gname}</span></th></tr>${zeilenHtml}</tbody>`;
    });
    tabelle.innerHTML = h;
    tabelle.dataset.gesamt = gesamt; tabelle.dataset.verschieden = verschieden;
    // Paket-Umschalter fürs Telefon
    paketwahl.innerHTML = '<legend>Paket zeigen</legend>' + sp.map(s => `<label><input type="radio" name="paket" value="${s.id}"${s.id === zustand.mobil ? ' checked' : ''}>${s.name.split(' ')[0]}${s.hervor ? '<span class="punkt" aria-hidden="true"></span>' : ''}</label>`).join('');
    zeigeUnterschiede(false);
  }

  function zeigeUnterschiede(markieren) {
    tabelle.classList.toggle('nur-unterschiede', zustand.nurUnterschiede);
    schalter.setAttribute('aria-checked', String(zustand.nurUnterschiede));
    const g = +tabelle.dataset.gesamt, v = +tabelle.dataset.verschieden;
    zaehler.textContent = zustand.nurUnterschiede ? `${v} von ${g}` : `${g} Merkmale`;
    if (markieren && !zustand.nurUnterschiede) tabelle.querySelectorAll('tr.gleich').forEach(tr => { tr.classList.remove('neu-sichtbar'); void tr.offsetWidth; tr.classList.add('neu-sichtbar'); });
  }

  // Preiswechsel: alter Wert nach oben raus, neuer von unten rein (180 ms, 6 px)
  function preiseWechseln() {
    const sp = DATEN.pakete.spalten;
    sp.forEach(s => {
      const el = tabelle.querySelector(`[data-preis="${s.id}"]`); if (!el) return;
      const p = preisText(s);
      const setze = () => { el.textContent = p.preis; tabelle.querySelector(`[data-klein="${s.id}"]`).innerHTML = p.klein; tabelle.querySelector(`[data-notiz="${s.id}"]`).textContent = p.notiz; };
      if (reduziert() || el.textContent === p.preis) return setze();
      el.animate([{ transform: 'translateY(0)', opacity: 1 }, { transform: 'translateY(-6px)', opacity: 0 }], { duration: 90, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }).onfinish = () => {
        setze();
        el.animate([{ transform: 'translateY(6px)', opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 180, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' });
      };
    });
    const team = DATEN.pakete.spalten[1];
    ansage.textContent = zustand.abrechnung === 'jahr' ? `Jährliche Abrechnung: Team ${eur.format(team.jahr)}, Werk ${eur.format(DATEN.pakete.spalten[2].jahr)} je Person und Monat.` : `Monatliche Abrechnung: Team ${eur.format(team.monat)}, Werk ${eur.format(DATEN.pakete.spalten[2].monat)} je Person und Monat.`;
  }

  function pilleSetzen() {
    const l = abrechnung.querySelector('input:checked').parentElement;
    pille.style.width = l.offsetWidth + 'px';
    pille.style.translate = (l.offsetLeft) + 'px 0';
  }

  // Schatten an den festen Kanten
  function kanten() {
    const s = rahmen.scrollHeight > rahmen.clientHeight + 2 ? rahmen.scrollTop : (rahmen.getBoundingClientRect().top < leisteH() ? 10 : 0);
    rahmen.classList.toggle('runter', s > 2);
    rahmen.classList.toggle('quer', rahmen.scrollLeft > 2);
    const rollt = rahmen.scrollHeight > rahmen.clientHeight + 2 || rahmen.scrollWidth > rahmen.clientWidth + 2;
    if (rollt !== (rahmen.tabIndex === 0)) rahmen.tabIndex = rollt ? 0 : -1;
  }
  function leisteH() { return schmal.matches ? paketwahl.offsetHeight : 0; }
  function leisteMessen() { document.documentElement.style.setProperty('--leiste-h', leisteH() + 'px'); }
  rahmen.addEventListener('scroll', kanten, { passive: true });
  addEventListener('scroll', kanten, { passive: true });
  addEventListener('resize', () => { pilleSetzen(); leisteMessen(); kanten(); });
  schmal.addEventListener?.('change', () => { tabelle.querySelectorAll('[data-voll]').forEach(th => th.colSpan = schmal.matches ? 2 : +th.dataset.voll); leisteMessen(); kanten(); });

  // Bedienung
  abrechnung.addEventListener('change', e => { zustand.abrechnung = e.target.value; pilleSetzen(); preiseWechseln(); });
  schalter.addEventListener('click', () => { zustand.nurUnterschiede = !zustand.nurUnterschiede; zeigeUnterschiede(true); });
  paketwahl.addEventListener('change', e => {
    zustand.mobil = e.target.value;
    tabelle.querySelectorAll('[data-p]').forEach(c => c.classList.toggle('aus', c.dataset.p !== zustand.mobil));
  });

  // Variante (?v=)
  const VARIANTEN = ['pakete', 'alternativen'];
  const WERTE = {
    pakete: 'ease-out-quad cubic-bezier(0.25, 0.46, 0.45, 0.94) · UI 160 ms · Umschalter und Preis 180 ms, 6 px · thead sticky top 0, Spalte 1 sticky left 0',
    alternativen: 'ease-out-quad · UI 160 ms · thead sticky top 0, Spalte 1 sticky left 0 · ohne Preis-Umschalter'
  };
  function variante(v) {
    if (!VARIANTEN.includes(v)) v = VARIANTEN[0];
    zustand.variante = v; document.body.dataset.variante = v;
    document.querySelectorAll('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    $('[data-titel]').textContent = v === 'pakete' ? 'Ein Preis je Person. Maschinen zählen wir nicht.' : 'Schluss mit Prüfkarten und Tabellenblättern.';
    $('[data-unterzeile]').textContent = v === 'pakete' ? 'Prüfpläne, Wartungsprotokolle und Fristen für Ihre Anlagen. Alle Pakete mit unbegrenzt vielen Maschinen; monatlich kündbar.' : 'Viele Werkstätten prüfen mit Karteikarten und einer Datei, die nur eine Person versteht. So sieht der Unterschied im Alltag und beim Audit aus.';
    $('[data-eyebrow]').textContent = v === 'pakete' ? 'Preise · Ingolstadt' : 'Vergleich · Ingolstadt';
    $('[data-fusstext]').textContent = v === 'pakete' ? 'Alle Preise je Person und Monat, netto zzgl. 19 % USt. Das Angebot richtet sich an Unternehmen. Start bleibt kostenlos, ohne Kreditkarte.' : 'Zeitangaben sind Beispielwerte für diese Demo. Echte Vergleiche nur mit belegbaren Zahlen.';
    $('[data-werte]').textContent = WERTE[v];
    rahmen.scrollTo(0, 0);
    baue();
    const q = new URLSearchParams(location.search);
    if (q.get('v') !== v) { q.set('v', v); history.replaceState(null, '', '?' + q.toString().replace(/=(&|$)/g, '$1')); }
  }
  document.querySelectorAll('[data-v]').forEach(b => b.addEventListener('click', () => { if (html.classList.contains('vorfuehrung')) neustart(); variante(b.dataset.v); }));

  // Vorführung: scrollen (Kopf bleibt), Jahrespreis, nur Unterschiede, Paketwechsel auf dem Telefon
  let zeitgeber = [], laeuft = false, rollen = 0;
  const nach = (ms, f) => zeitgeber.push(setTimeout(f, ms));
  function scrollZiel() { return rahmen.scrollHeight > rahmen.clientHeight + 2 ? rahmen : document.scrollingElement; }
  function gleite(ziel, ms = 1400) {
    const el = scrollZiel(), von = el.scrollTop, bis = el === rahmen || !ziel ? ziel : rahmen.getBoundingClientRect().top + scrollY - leisteH() + ziel;
    cancelAnimationFrame(rollen);
    if (reduziert()) { el.scrollTop = bis; return; }
    const t0 = performance.now();
    const f = t => { const p = Math.min(1, (t - t0) / ms), e = p < .5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; el.scrollTop = von + (bis - von) * e; if (p < 1) rollen = requestAnimationFrame(f); };
    rollen = requestAnimationFrame(f);
  }
  function gleiteQuer(ziel, ms = 1200) {
    if (rahmen.scrollWidth <= rahmen.clientWidth + 2) return;
    const von = rahmen.scrollLeft, bis = ziel === 'ende' ? rahmen.scrollWidth - rahmen.clientWidth : ziel;
    if (reduziert()) { rahmen.scrollLeft = bis; return; }
    const t0 = performance.now();
    const f = t => { const p = Math.min(1, (t - t0) / ms), e = p < .5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; rahmen.scrollLeft = von + (bis - von) * e; if (p < 1) requestAnimationFrame(f); };
    requestAnimationFrame(f);
  }
  function klick(el) { if (!el) return; el.animate?.([{ boxShadow: '0 0 0 0 rgba(200,67,15,.45)' }, { boxShadow: '0 0 0 8px rgba(200,67,15,0)' }], { duration: 600 }); el.click(); }
  function waehle(name, wert) { const i = document.querySelector(`input[name="${name}"][value="${wert}"]`); if (!i) return; klick(i.parentElement); i.checked = true; i.dispatchEvent(new Event('change', { bubbles: true })); }
  function eineRunde() {
    zustand.nurUnterschiede = false; zeigeUnterschiede(false);
    if (zustand.variante === 'pakete') waehle('abrechnung', 'monat');
    scrollZiel().scrollTop = 0;
    nach(700, () => gleite(380, 1600));
    if (zustand.variante === 'pakete') nach(2800, () => waehle('abrechnung', 'jahr'));
    if (zustand.variante === 'pakete') nach(4600, () => klick(schalter));
    nach(6000, () => gleiteQuer('ende'));
    nach(6400, () => gleite(760, 1400));
    nach(8200, () => gleiteQuer(0));
    if (schmal.matches) { nach(3400, () => waehle('paket', zustand.variante === 'pakete' ? 'werk' : 'tabelle')); nach(7800, () => waehle('paket', zustand.variante === 'pakete' ? 'team' : 'kernlog')); }
    if (zustand.variante === 'pakete') nach(8600, () => klick(schalter));
    if (zustand.variante === 'pakete') nach(9600, () => waehle('abrechnung', 'monat'));
    nach(10600, () => gleite(0, 1300));
    nach(12600, eineRunde);
  }
  function pause() { zeitgeber.forEach(clearTimeout); zeitgeber = []; cancelAnimationFrame(rollen); laeuft = false; }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung');
    rahmen.scrollLeft = 0;
    zustand.nurUnterschiede = false; zustand.abrechnung = 'monat';
    abrechnung.querySelector('input[value="monat"]').checked = true;
    baue(); pilleSetzen(); scrollZiel().scrollTop = 0; kanten();
  }
  function vorfuehren() { neustart(); html.classList.add('vorfuehrung'); laeuft = true; eineRunde(); }

  variante(new URLSearchParams(location.search).get('v'));
  pilleSetzen(); leisteMessen(); kanten();
  document.fonts?.ready.then(() => { pilleSetzen(); leisteMessen(); });

})();
</script>
</body>
</html>