Zum Inhalt
← Zugang & Robustheit

Tabelle schmal und robust

Richtpreis-Tabelle eines Umzugsbetriebs bleibt auf jeder Breite eine echte Tabelle: feste erste Spalte mit scroll-getriebenen Schatten, Zeilen als Karten per Container-Abfrage oder Spalten nach Vorrang mit Menü.

Viele Spalten, die man vergleicht (Richtpreise, technische Daten, Fahrpläne): Zeilenkopf bleibt stehen, der Rest scrollt.

Breite Daten- und Preistabellen bleiben auf dem Telefon echte <table> mit <caption>, scope und Tastaturbedienung. Drei Wege je nach Inhalt: erste Spalte fest und waagerecht scrollen mit Schatten, die zeigen, dass es weitergeht (scroll-getrieben, inzwischen in allen Engines). Oder unter einer Containerbreite jede Zeile als Karte, die Spaltenköpfe stehen als Etiketten davor. Oder Spalten ein- und ausblenden mit Vorrang, was auf dem Telefon zuerst bleibt.

Tabellen sind auf dem Telefon die häufigste Stelle für Querscrollen der ganzen Seite (WCAG 1.4.10). Eine Tabelle, die in ihrem Rahmen scrollt und das sichtbar macht, hält die Seite ruhig. Karten lesen sich bei wenigen Spalten besser als Wischen. Die Semantik bleibt dabei erhalten, damit Screenreader Zeile und Spalte ansagen.

Wann einsetzen

Richtpreise nach Wohnungsgröße oder Leistung, technische Daten mit vielen Maßen, Fahr- und Kurspläne, Gebührenübersichten, Liefergebiete. Umzug, Industrie, Kurs, Fitness, IT, Handwerk.

Wann nicht

Nicht für Paketvergleiche mit Häkchen (dafür vergleichstabelle-sticky) und nicht für Wochenpläne (wochen-kursplan). Keine Tabelle für Layout. Karten nur bis etwa sechs Spalten; danach verliert man den Vergleich zwischen Zeilen.

Varianten

  • Feste Spalte: erste Spalte sticky, Rahmen scrollt waagerecht, Schatten per animation-timeline: scroll(nearest inline), Rückfall per scroll-Ereignis
  • Karten: unter 560 px Containerbreite wird jede Zeile eine Karte, Spaltenköpfe als Etiketten per attr(data-kopf), Rollen nachgetragen
  • Spalten wählen: Vorrang je Spalte (1 immer, 2 ab 520 px, 3 ab 900 px Containerbreite), Menü „Spalten n von 8“ zum Zu- und Abschalten

Umsetzung

  1. Immer <table> mit <caption id>, <thead> mit <th scope=col>, erste Zelle je Zeile <th scope=row>. Zahlen rechtsbündig mit tabellarischen Ziffern, Einheiten im Kopf.
  2. Rahmen: <div class=rahmen tabindex=0 role=region aria-labelledby=caption-id> mit overflow-x: auto, sichtbarem Fokusrahmen und overscroll-behavior-x: contain.
  3. Feste Spalte: th[scope=row] { position: sticky; left: 0 } mit eigenem Hintergrund. Schatten: zwei Pseudo-Elemente am Rahmen, sticky an den Kanten, Deckkraft per animation: schatten linear both; animation-timeline: scroll(self inline) (Zeile nach der Kurzschreibweise). Rückfall ohne Unterstützung über @supports not (animation-timeline: scroll()): Schatten per JS-Klasse am scroll-Ereignis. tabindex nur, solange der Inhalt breiter ist (ResizeObserver).
  4. Karten: container-type: inline-size am Rahmen; @container (max-width: 560px) setzt tr auf display: grid und td::before { content: attr(data-kopf) }. Weil display auf Tabellenteilen in manchen Browsern die Rollen löscht, die Rollen per role=table/row/cell/rowheader/columnheader im Kartenmodus nachtragen (JS beim Umschalten) und den thead visuell verstecken, nicht entfernen.
  5. Spalten wählen: data-vorrang=1…3 an den Spalten; unter 640 px nur Vorrang 1 sichtbar; ein Menü mit Checkboxen schaltet Spalten über eine Klasse am table zu (col-Ausblendung per :nth-child), Ansage „5 von 8 Spalten sichtbar“.
  6. Keine Tabelle in einem zweiten senkrechten Scrollbereich.

Robust umsetzen

Telefon. Das ist der Kern. Feste Spalte: Wischen im Rahmen, Seite bleibt stehen, rechter Schatten zeigt „weiter“. Karten: keine Querbewegung, jede Zeile untereinander. Spalten wählen: drei Spalten passen, Menü als große Checkboxen.

Ohne Bewegung. Schatten bleiben (keine Bewegung, nur Zustand). Spalten erscheinen ohne Überblenden, das Menü öffnet ohne Gleiten.

Barrierefrei. Echte Tabellensemantik in allen Modi, auch als Karten (Rollen nachgetragen). Rahmen fokussierbar mit Namen aus der caption, damit Tastaturnutzer ihn mit Pfeiltasten scrollen können. Spaltenköpfe in Karten als sichtbare Etiketten. Ausgeblendete Spalten per display: none, nicht nur verdeckt. Kein waagerechtes Scrollen der Seite bei 320 px. Preise mit Hinweis „Richtwert, Angebot nach Besichtigung“ (vom Kunden prüfen lassen).

Code

<!DOCTYPE html>
<!--
  Tabelle schmal und robust
  Fundus · Zugang & Robustheit · https://cecodesign.de/lab/fundus/zugang/tabelle-schmal-robust/
  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>Tabelle schmal und robust · 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: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 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; }

/* Rezept umzug-transport-logistik: Asphalt hell, Planengrau, Flottengelb nur mit dunkler Kontur */
:root {
  --grund: #F2F2EF; --text: #18191B; --flaeche: #DFE0DC; --dunkel: #24272A; --gelb: #F2B705; --leise: #575A5E; --linie: #CBCCC8;
  --ui-ein: cubic-bezier(0.16, 1, 0.3, 1);
  --ui-aus: cubic-bezier(0.7, 0, 0.84, 0);
  --spalte1: 10.5rem;
  --rand: clamp(16px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font: 400 1.0625rem/1.6 'Schibsted Grotesk', system-ui, sans-serif; }
body { min-height: 100svh; }
:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; }
button { font: inherit; color: inherit; }
.mono { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.unsichtbar { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.kopf { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px var(--rand); background: var(--dunkel); color: #fff; }
.marke { display: flex; align-items: baseline; gap: 10px; }
.marke b { font: 700 1.5rem/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .01em; text-transform: uppercase; }
.marke b span { color: var(--gelb); }
.marke small { color: rgba(255, 255, 255, .75); font-size: .85rem; }
.anfrage { display: inline-flex; align-items: center; min-height: 44px; padding: 0 18px; background: var(--gelb); color: var(--text); font-weight: 700; text-decoration: none; border-radius: 4px; box-shadow: inset 0 0 0 2px var(--text); }

main { padding: clamp(28px, 5vw, 64px) var(--rand) 120px; max-width: 1280px; margin: 0 auto; }
.einstieg { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 16px 48px; align-items: end; margin-bottom: 28px; }
.einstieg .mono { grid-column: 1 / -1; color: var(--leise); }
h1 { font: 700 clamp(2.5rem, 1.4rem + 4.6vw, 5.4rem)/0.92 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: 0; text-wrap: balance; }
.einstieg p { color: var(--leise); max-width: 46ch; }

/* Bühne: Breite testen (zeigt die Container-Abfrage auch am Desktop) */
.werkbank { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin-bottom: 14px; }
.segment { display: flex; flex-wrap: wrap; background: var(--flaeche); border-radius: 6px; padding: 3px; }
.segment button { border: 0; background: transparent; min-height: 40px; padding: 0 14px; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: .92rem; }
.segment button[aria-pressed="true"] { background: var(--text); color: #fff; }
.werkbank .hinweis { font-size: .9rem; color: var(--leise); }
.feld { width: 100%; transition: max-width 300ms var(--ui-ein); margin-inline: auto; }
.feld[data-breite="tablet"] { max-width: 640px; }
.feld[data-breite="telefon"] { max-width: 360px; }
.reduziert .feld { transition: none; }

/* ============ Tabelle: gemeinsame Grundlage ============ */
.huelle { container: tabelle / inline-size; }
table { border-collapse: separate; border-spacing: 0; width: 100%; font-size: .98rem; background: #fff; }
td.zahl, td.preis { font-variant-numeric: tabular-nums lining-nums; } /* tnum nur an Zahlen: sonst werden Komma und Punkt im Text zu breit */
caption { text-align: left; padding: 0 0 12px; caption-side: top; }
caption b { display: block; font: 700 1.5rem/1.1 'Special Gothic', sans-serif; font-stretch: 80%; text-transform: uppercase; }
caption span { display: block; color: var(--leise); font-size: .9rem; margin-top: 4px; }
thead th { font-size: .78rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; text-align: left; padding: 12px 14px; border-bottom: 2px solid var(--text); background: #fff; vertical-align: bottom; white-space: nowrap; }
thead th small { display: block; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--leise); font-size: .78rem; }
tbody th, tbody td { padding: 14px; border-bottom: 1px solid var(--linie); text-align: left; vertical-align: top; background: #fff; }
tbody th { font-weight: 700; }
tbody th small { display: block; font-weight: 400; color: var(--leise); font-size: .85rem; }
td.zahl, th.zahl { text-align: right; }
td.preis { font-weight: 800; font-size: 1.1rem; white-space: nowrap; }
td.preis b { background: var(--gelb); box-shadow: inset 0 0 0 1.5px var(--text); padding: 2px 6px; border-radius: 3px; font-weight: 800; }
tbody tr:hover th, tbody tr:hover td { background: #FAFAF7; }
.fuss { font-size: .88rem; color: var(--leise); margin-top: 12px; max-width: 72ch; }

/* ============ Variante feste-spalte ============ */
.rahmen { position: relative; }
/* Scrollrahmen nur, wenn das Markup vollständig ist (role, Name, tabindex) – sonst wird nichts abgeschnitten */
.rahmen[role="region"][aria-labelledby] { overflow-x: auto; overscroll-behavior-x: contain; }
.rahmen[role="region"][aria-labelledby]:focus-visible { outline: 3px solid var(--text); outline-offset: 2px; }
html[data-v="feste"] .rahmen { display: flex; }
html[data-v="feste"] table { min-width: 820px; flex: none; width: max(100%, 820px); }
html[data-v="feste"] tbody th, html[data-v="feste"] thead th:first-child { position: sticky; left: 0; z-index: 1; width: var(--spalte1); min-width: var(--spalte1); }
html[data-v="feste"] tbody th { box-shadow: inset -1px 0 0 var(--linie); }
/* Schatten: Pseudo-Elemente kleben an den Kanten, ihre Deckkraft folgt dem Scrollstand (scroll-getrieben, alle Engines seit Firefox 159) */
html[data-v="feste"] .rahmen::before, html[data-v="feste"] .rahmen::after { content: ''; position: sticky; z-index: 2; flex: none; width: 24px; pointer-events: none; opacity: 0; }
html[data-v="feste"] .rahmen::before { order: -1; left: var(--spalte1); margin-right: -24px; background: linear-gradient(90deg, rgba(24, 25, 27, .2), transparent); }
html[data-v="feste"] .rahmen::after { right: 0; margin-left: -24px; background: linear-gradient(-90deg, rgba(24, 25, 27, .2), transparent); }
@supports (animation-timeline: scroll()) {
  html[data-v="feste"] .rahmen::before { animation: schatten-l linear both; animation-timeline: scroll(nearest inline); }
  html[data-v="feste"] .rahmen::after { animation: schatten-r linear both; animation-timeline: scroll(nearest inline); }
  html[data-v="feste"] .rahmen:not([data-scrollt])::before, html[data-v="feste"] .rahmen:not([data-scrollt])::after { animation-name: none; opacity: 0; } /* nur der Name: die Kurzschreibweise setzte die Timeline zurück */
}
@keyframes schatten-l { 0% { opacity: 0; } 6%, 100% { opacity: 1; } }
@keyframes schatten-r { 0%, 94% { opacity: 1; } 100% { opacity: 0; } }
/* Rückfall ohne scroll-getriebene Animation: Klassen per scroll-Ereignis */
@supports not (animation-timeline: scroll()) {
  html[data-v="feste"] .rahmen[data-links]::before { opacity: 1; }
  html[data-v="feste"] .rahmen[data-rechts]::after { opacity: 1; }
}
.tabkopf { display: none; margin-bottom: 12px; }
.tabkopf b { display: block; font: 700 1.5rem/1.1 'Special Gothic', sans-serif; font-stretch: 80%; text-transform: uppercase; }
.tabkopf span { display: block; color: var(--leise); font-size: .9rem; margin-top: 4px; }
html[data-v="feste"] .tabkopf { display: block; }
html[data-v="feste"] caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.wisch { display: none; align-items: center; gap: 8px; font-size: .85rem; color: var(--leise); margin-top: 8px; }
.wisch svg { width: 18px; height: 18px; }
html[data-v="feste"] .huelle:has(.rahmen[data-scrollt]) .wisch { display: flex; }

/* ============ Variante karten: Zeile wird Karte, unter 560 px Containerbreite ============ */
@container tabelle (max-width: 560px) {
  html[data-v="karten"] table, html[data-v="karten"] tbody, html[data-v="karten"] caption { display: block; }
  html[data-v="karten"] table { background: transparent; }
  html[data-v="karten"] thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } /* versteckt, nicht entfernt */
  html[data-v="karten"] tbody tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; background: #fff; border-radius: 6px; margin-bottom: 12px; box-shadow: inset 0 0 0 1px var(--linie); overflow: hidden; }
  html[data-v="karten"] tbody th { grid-column: 1 / -1; background: var(--flaeche); border: 0; padding: 12px 14px; display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
  html[data-v="karten"] tbody td { display: contents; }
  html[data-v="karten"] tbody td::before { content: attr(data-kopf); text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--linie); color: var(--leise); font-size: .9rem; }
  html[data-v="karten"] tbody td > span { padding: 9px 14px; border-bottom: 1px solid var(--linie); text-align: right; }
  html[data-v="karten"] tbody td.preis::before { font-weight: 700; color: var(--text); border-bottom: 0; align-self: center; }
  html[data-v="karten"] tbody td.preis > span { border-bottom: 0; padding-block: 12px; }
  html[data-v="karten"] tbody tr:hover th, html[data-v="karten"] tbody tr:hover td { background: transparent; }
  html[data-v="karten"] tbody tr:hover th { background: var(--flaeche); }
}

/* ============ Variante spalten: Vorrang je Spalte, Rest zuschaltbar ============ */
.spaltenwahl { position: relative; margin-bottom: 14px; }
.spaltenwahl summary { list-style: none; display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 16px; border-radius: 4px; box-shadow: inset 0 0 0 1.5px var(--text); cursor: pointer; font-weight: 600; background: #fff; }
.spaltenwahl summary::-webkit-details-marker { display: none; }
.spaltenwahl summary svg { width: 16px; height: 16px; transition: transform 200ms var(--ui-aus); }
.spaltenwahl[open] summary svg { transform: rotate(180deg); transition: transform 300ms var(--ui-ein); }
.spaltenwahl .menue { position: absolute; z-index: 5; top: calc(100% + 6px); left: 0; min-width: 16rem; background: #fff; border-radius: 6px; box-shadow: 0 16px 40px rgba(24, 25, 27, .18), inset 0 0 0 1px var(--linie); padding: 8px; display: grid; }
.spaltenwahl[open] .menue { animation: menue-ein 300ms var(--ui-ein) both; }
@keyframes menue-ein { from { opacity: 0; transform: translateY(-6px); } }
.reduziert .spaltenwahl[open] .menue, .reduziert .spaltenwahl summary svg { animation: none; transition: none; }
.menue label { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 10px; border-radius: 4px; cursor: pointer; }
.menue label:hover { background: var(--grund); }
.menue input { width: 20px; height: 20px; accent-color: var(--text); }
.menue small { margin-left: auto; color: var(--leise); font-size: .8rem; }
.menue input:disabled + span { color: var(--leise); }
html[data-v="spalten"] table { min-width: 0; }
@container tabelle (max-width: 899px) { html[data-v="spalten"] .t:not(.alle) .v3:not(.an) { display: none; } }
@container tabelle (max-width: 519px) { html[data-v="spalten"] .t:not(.alle) .v2:not(.an) { display: none; } }
html[data-v="spalten"] .t .aus { display: none !important; }
html[data-v="spalten"] td.neu, html[data-v="spalten"] th.neu { animation: spalte-ein 300ms var(--ui-ein) both; }
@keyframes spalte-ein { from { background: #FFF3C9; } }
.reduziert td.neu, .reduziert th.neu { animation: none; }

/* Varianten */
html:not([data-v="spalten"]) .nur-spalten { display: none; }
html:not([data-v="feste"]) .nur-feste { display: none; }
html:not([data-v="karten"]) .nur-karten { display: none; }

/* Pult */
.pult { position: fixed; right: 16px; bottom: 16px; z-index: 20; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; background: rgba(36, 39, 42, .95); color: #fff; padding: 8px 10px 8px 14px; border-radius: 10px; box-shadow: 0 10px 28px rgba(0, 0, 0, .22); max-width: calc(100vw - 32px); }
.pult .mono { font-size: 10px; color: rgba(255, 255, 255, .7); }
.pult .gruppe { display: flex; flex-wrap: wrap; background: rgba(255, 255, 255, .08); border-radius: 6px; padding: 2px; }
.pult button { background: none; border: 0; color: rgba(255, 255, 255, .8); font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .06em; text-transform: uppercase; padding: 8px 10px; 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; }
.pult .werte { flex-basis: 100%; font: 400 10.5px/1.55 'Geist Mono', monospace; color: rgba(255, 255, 255, .72); max-width: 48ch; }
.pult .werte b { color: #fff; font-weight: 500; }

.vorschau body { overflow: hidden; }
.vorschau .einstieg p { display: none; }

@media (max-width: 760px) {
  .einstieg { grid-template-columns: minmax(0, 1fr); }
  .werkbank { display: none; }   /* Telefon: echte Breite statt Simulation */
  :root { --spalte1: 8.5rem; }
  .kopf .marke small { display: none; }
  .pult .werte { display: none; }
}
@media (max-width: 360px) { .anfrage { padding: 0 12px; font-size: .9rem; } :root { --spalte1: 7.5rem; } }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch], input, select, textarea, summary { border: 1px solid ButtonText !important; }
  td.preis b { border: 1px solid CanvasText; }
  html[data-v="feste"] .rahmen::before, html[data-v="feste"] .rahmen::after { display: none; }
}
</style>
</head>
<body>
<header class="kopf">
  <span class="marke"><b>Krahnert<span>.</span></b><small>Umzüge · Göppingen</small></span>
  <a class="anfrage" href="#tabelle">Besichtigung anfragen</a>
</header>

<main>
  <div class="einstieg">
    <span class="mono">Preise · Stand November 2026</span>
    <h1>Was ein Umzug in Göppingen ungefähr kostet.</h1>
    <p>Richtwerte für Umzüge innerhalb der Stadt, ohne Klavier und ohne Einlagerung. Den festen Preis nennen wir nach der Besichtigung, kostenlos und in einer halben Stunde.</p>
  </div>

  <div class="werkbank">
    <span class="mono">Breite testen</span>
    <div class="segment" role="group" aria-label="Breite der Tabelle testen">
      <button type="button" data-breite="voll" aria-pressed="true">Breit</button>
      <button type="button" data-breite="tablet" aria-pressed="false">640 px</button>
      <button type="button" data-breite="telefon" aria-pressed="false">360 px</button>
    </div>
    <p class="hinweis nur-feste">Erste Spalte bleibt, die Schatten zeigen, wo es weitergeht.</p>
    <p class="hinweis nur-karten">Unter 560 px Containerbreite wird jede Zeile eine Karte.</p>
    <p class="hinweis nur-spalten">Unter 900 und 520 px Containerbreite treten Spalten mit niedrigem Vorrang zurück.</p>
  </div>

  <div class="feld" data-breite="voll" data-feld>
    <div class="huelle" id="tabelle">
      <details class="spaltenwahl nur-spalten" data-spaltenwahl>
        <summary><span data-spalten-zahl>Spalten</span><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.7"/></svg></summary>
        <div class="menue" data-menue role="group" aria-label="Sichtbare Spalten"></div>
      </details>
      <p class="unsichtbar" aria-live="polite" data-ansage></p>
      <p class="tabkopf" aria-hidden="true"><b>Richtpreise Umzug</b><span>innerhalb Göppingens, 2. Etage mit Aufzug, Preise inkl. MwSt.</span></p>
      <div class="rahmen" role="region" aria-labelledby="cap" data-rahmen>
        <table class="t" data-tabelle>
          <caption id="cap"><b>Richtpreise Umzug</b><span>innerhalb Göppingens, 2. Etage mit Aufzug, Preise inkl. MwSt.</span></caption>
          <thead>
            <tr>
              <th scope="col" data-k="1" data-vorrang="1">Wohnung</th>
              <th scope="col" class="zahl" data-k="2" data-vorrang="1">Fläche<small>m²</small></th>
              <th scope="col" class="zahl" data-k="3" data-vorrang="2">Umzugsgut<small>m³</small></th>
              <th scope="col" class="zahl" data-k="4" data-vorrang="3">Team<small>Personen</small></th>
              <th scope="col" data-k="5" data-vorrang="3">Fahrzeug</th>
              <th scope="col" class="zahl" data-k="6" data-vorrang="2">Dauer<small>Stunden</small></th>
              <th scope="col" data-k="7" data-vorrang="3">Halteverbot</th>
              <th scope="col" class="zahl" data-k="8" data-vorrang="1">Richtpreis<small>ab</small></th>
            </tr>
          </thead>
          <tbody data-zeilen></tbody>
        </table>
      </div>
      <p class="wisch nur-feste" aria-hidden="true"><svg viewBox="0 0 18 18"><path d="M3 9h12M11 5l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>Seitlich wischen oder mit den Pfeiltasten scrollen</p>
    </div>
    <p class="fuss">Richtwerte für die Planung, kein Angebot. Der verbindliche Preis hängt von Etage, Laufweg und Menge ab und steht im Angebot nach der Besichtigung. Preisangaben vom Kunden prüfen lassen.</p>
  </div>
</main>

<div class="pult">
  <span class="mono">Variante</span>
  <div class="gruppe" role="group" aria-label="Variante">
    <button type="button" data-v="feste" aria-pressed="true">Feste Spalte</button>
    <button type="button" data-v="karten" aria-pressed="false">Karten</button>
    <button type="button" data-v="spalten" aria-pressed="false">Spalten wählen</button>
  </div>
  <p class="werte" data-werte></p>
</div>

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

  // Daten (erfundene Beispielwerte) – im echten Projekt steht das als HTML im Quelltext
  const ZEILEN = [
    ['1 Zimmer', 'Apartment', 35, 15, 2, '3,5 t', '3–4', 'eine Adresse', 690],
    ['2 Zimmer', 'Küche, Bad', 55, 25, 3, '3,5 t', '4–5', 'eine Adresse', 1090],
    ['3 Zimmer', 'mit Keller', 80, 38, 3, '7,5 t', '5–7', 'beide Adressen', 1590],
    ['4 Zimmer', 'mit Keller', 105, 50, 4, '7,5 t', '6–8', 'beide Adressen', 2190],
    ['Haus', 'Reihenhaus', 125, 62, 4, '7,5 t + 3,5 t', '7–9', 'beide Adressen', 2790],
    ['Haus', 'freistehend', 160, 80, 5, '12 t', '8–10', 'beide Adressen', 3490]
  ];
  const KOPF = $$('thead th').map(th => th.firstChild.textContent.trim() + (th.querySelector('small') ? ' (' + th.querySelector('small').textContent + ')' : ''));
  const tbody = $('[data-zeilen]');
  tbody.innerHTML = ZEILEN.map(z => {
    const zellen = [
      [z[2], 'zahl', zahl.format(z[2])], [z[3], 'zahl', zahl.format(z[3])], [z[4], 'zahl', z[4]], [0, '', z[5]], [0, 'zahl', z[6]], [0, '', z[7]], [0, 'zahl preis', `<b>${euro.format(z[8])}</b>`]
    ];
    return `<tr><th scope="row" data-k="1">${z[0]}<small>${z[1]}</small></th>${zellen.map((c, i) => `<td class="${c[1]}" data-k="${i + 2}" data-kopf="${KOPF[i + 1]}"><span>${c[2]}</span></td>`).join('')}</tr>`;
  }).join('');
  // Vorrang-Klassen je Spalte (für Variante „spalten“)
  const VORRANG = {}; $$('thead th').forEach(th => VORRANG[th.dataset.k] = +th.dataset.vorrang);
  $$('[data-k]').forEach(c => c.classList.add('v' + VORRANG[c.dataset.k], 'k' + c.dataset.k));

  const rahmen = $('[data-rahmen]'), tabelle = $('[data-tabelle]'), huelle = $('.huelle');

  // Rahmen nur fokussierbar, wenn er wirklich scrollt (sonst ein leerer Tabstopp)
  function pruefeScroll() {
    const scrollt = rahmen.scrollWidth > rahmen.clientWidth + 1;
    rahmen.toggleAttribute('data-scrollt', scrollt);
    if (scrollt) rahmen.setAttribute('tabindex', '0'); else rahmen.removeAttribute('tabindex');
    schatten();
  }
  // Rückfall für die Schatten ohne animation-timeline
  function schatten() {
    rahmen.toggleAttribute('data-links', rahmen.scrollLeft > 2);
    rahmen.toggleAttribute('data-rechts', rahmen.scrollLeft + rahmen.clientWidth < rahmen.scrollWidth - 2);
  }
  if (!CSS.supports('animation-timeline: scroll()')) rahmen.addEventListener('scroll', schatten, { passive: true });
  new ResizeObserver(() => { pruefeScroll(); kartenRollen(); spaltenStand(); }).observe(huelle);

  // Karten: display auf Tabellenteilen löscht in manchen Browsern die Rollen → im Kartenmodus nachtragen
  let kartenAn = false;
  function kartenRollen() {
    const an = html.dataset.v === 'karten' && huelle.clientWidth <= 560;
    if (an === kartenAn) return; kartenAn = an;
    const setze = (sel, rolle) => $$(sel, tabelle).forEach(el => an ? el.setAttribute('role', rolle) : el.removeAttribute('role'));
    if (an) tabelle.setAttribute('role', 'table'); else tabelle.removeAttribute('role');
    setze('tbody', 'rowgroup'); setze('tr', 'row'); setze('thead th', 'columnheader'); setze('tbody th', 'rowheader'); setze('tbody td', 'cell');
    if (an) tabelle.setAttribute('aria-labelledby', 'cap'); else tabelle.removeAttribute('aria-labelledby');
  }

  // Spalten wählen
  const menue = $('[data-menue]'), ansage = $('[data-ansage]');
  $$('thead th').forEach(th => {
    const k = th.dataset.k, fest = VORRANG[k] === 1;
    const l = document.createElement('label');
    l.innerHTML = `<input type="checkbox" value="${k}" ${fest ? 'checked disabled' : ''}><span>${th.firstChild.textContent.trim()}</span><small>${fest ? 'immer' : 'Vorrang ' + VORRANG[k]}</small>`;
    menue.appendChild(l);
  });
  let wahl = {}; // k → true (an) | false (aus) | undefined (automatisch)
  function sichtbar(k) { const c = $(`thead th[data-k="${k}"]`); return c.offsetParent !== null && getComputedStyle(c).display !== 'none'; }
  function spaltenStand() {
    if (html.dataset.v !== 'spalten') return;
    const n = $$('thead th').filter(th => sichtbar(th.dataset.k)).length;
    $('[data-spalten-zahl]').textContent = `Spalten ${n} von 8`;
    $$('input', menue).forEach(i => { if (!i.disabled) i.checked = sichtbar(i.value); });
    return n;
  }
  menue.addEventListener('change', e => {
    const k = e.target.value, an = e.target.checked;
    $$(`.k${k}`).forEach(c => { c.classList.toggle('an', an); c.classList.toggle('aus', !an); if (an) { c.classList.remove('neu'); void c.offsetWidth; c.classList.add('neu'); } });
    wahl[k] = an;
    const n = spaltenStand(); pruefeScroll();
    ansage.textContent = `${$(`thead th[data-k="${k}"]`).firstChild.textContent.trim()} ${an ? 'eingeblendet' : 'ausgeblendet'}. ${n} von 8 Spalten sichtbar.`;
  });
  document.addEventListener('click', e => { const d = $('[data-spaltenwahl]'); if (d.open && !d.contains(e.target)) d.open = false; });
  $('[data-spaltenwahl]').addEventListener('keydown', e => { if (e.key === 'Escape') { e.currentTarget.open = false; e.currentTarget.querySelector('summary').focus(); } });

  // Breite testen
  const feld = $('[data-feld]');
  function setzeBreite(b) { feld.dataset.breite = b; $$('[data-breite]', $('.werkbank')).forEach(x => x.setAttribute('aria-pressed', String(x.dataset.breite === b))); setTimeout(() => { pruefeScroll(); kartenRollen(); spaltenStand(); }, reduziert() ? 0 : 320); }
  $$('.segment [data-breite]').forEach(b => b.addEventListener('click', () => setzeBreite(b.dataset.breite)));

  // ================= Varianten =================
  const TL = CSS.supports('animation-timeline: scroll()');
  const WERTE = {
    feste: `th[scope=row] sticky left 0 · Spalte 1 10,5 rem · Tabelle min. 820 px · Schatten 24 px ${TL ? '<b>animation-timeline: scroll(nearest inline)</b>' : '<b>Rückfall scroll-Ereignis</b>'} · tabindex nur wenn breiter`,
    karten: '<b>@container tabelle (max-width: 560px)</b> · tr grid 2 Spalten · td::before attr(data-kopf) · Rollen table/row/cell nachgetragen · thead versteckt, nicht entfernt',
    spalten: 'data-vorrang 1 immer · 2 ab 520 px · 3 ab 900 px Containerbreite · Menü 300/200 ms (0.16,1,0.3,1 | 0.7,0,0.84,0) · Ansage „n von 8 Spalten“'
  };
  const IDS = Object.keys(WERTE); let v = IDS[0];
  function variante(neu) {
    v = IDS.includes(neu) ? neu : IDS[0]; html.dataset.v = v;
    $$('button[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
    $('[data-werte]').innerHTML = WERTE[v];
    $$('.an, .aus').forEach(c => c.classList.remove('an', 'aus')); wahl = {};
    rahmen.scrollLeft = 0;
    requestAnimationFrame(() => { pruefeScroll(); kartenRollen(); spaltenStand(); });
    const q = new URLSearchParams(location.search);
    if (q.get('v') !== v) { q.set('v', v); history.replaceState(null, '', '?' + q.toString().replace(/=(&|$)/g, '$1')); }
  }
  $$('button[data-v]').forEach(b => b.addEventListener('click', () => { pause(); variante(b.dataset.v); }));
  variante(new URLSearchParams(location.search).get('v'));

  // ================= Vorführung =================
  let zeit = [], laeuft = false, rollen = 0;
  const nach = (ms, f) => zeit.push(setTimeout(f, ms));
  function gleite(bis, dauer = 1400) {
    cancelAnimationFrame(rollen);
    const von = rahmen.scrollLeft, ziel = bis === 'ende' ? rahmen.scrollWidth - rahmen.clientWidth : bis;
    if (reduziert()) { rahmen.scrollLeft = ziel; return; }
    const t0 = performance.now();
    const f = t => { const p = Math.min(1, (t - t0) / dauer), e = p < .5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; rahmen.scrollLeft = von + (ziel - von) * e; if (p < 1) rollen = requestAnimationFrame(f); };
    rollen = requestAnimationFrame(f);
  }
  const schmal = () => matchMedia('(max-width: 760px)').matches;
  function runde() {
    if (!laeuft) return;
    if (v === 'feste') {
      if (!schmal()) setzeBreite('tablet');
      nach(800, () => gleite('ende')); nach(2800, () => gleite(0)); nach(4400, runde);
    } else if (v === 'karten') {
      if (schmal()) { nach(600, () => scrollTo({ top: 300, behavior: reduziert() ? 'auto' : 'smooth' })); nach(2400, () => scrollTo({ top: 0, behavior: 'auto' })); nach(3400, runde); return; }
      setzeBreite('voll'); nach(1300, () => setzeBreite('telefon')); nach(3300, runde);
    } else {
      const d = $('[data-spaltenwahl]');
      if (!schmal()) setzeBreite('tablet');
      nach(900, () => { d.open = true; }); nach(1700, () => { const i = $('input[value="4"]', menue); i.checked = true; i.dispatchEvent(new Event('change', { bubbles: true })); });
      nach(2600, () => { d.open = false; }); nach(3800, () => { const i = $('input[value="4"]', menue); i.checked = false; i.dispatchEvent(new Event('change', { bubbles: true })); });
      nach(4600, runde);
    }
  }
  function neustart() { zeit.forEach(clearTimeout); zeit = []; cancelAnimationFrame(rollen); setzeBreite('voll'); $('[data-spaltenwahl]').open = false; variante(v); }
  function vorfuehren() { neustart(); laeuft = true; html.classList.add('vorfuehrung'); runde(); }
  function pause() { laeuft = false; zeit.forEach(clearTimeout); zeit = []; cancelAnimationFrame(rollen); html.classList.remove('vorfuehrung'); }
  function weiter() { if (!laeuft) { laeuft = true; html.classList.add('vorfuehrung'); runde(); } }

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