Zum Inhalt
← Schrift-Animationen

Text auf Pfad

Text fließt auf einer SVG-Kurve, gekoppelt an den Scroll: bricht das Raster und wirkt organisch, als Band, verformter Pfad oder Rund-Label.

Sektionstrenner, Schlagwort-Bänder und Rund-Labels, deren Text beim Scrollen fließt: Praxis, Studio, Marken mit handwerklichem Charakter.

  1. Ein SVG-<path> legt die Kurve fest: Welle, Schlaufe oder Kreis.
  2. Der Text liegt per <textPath href="#kurve"> darauf. Für ein durchgehendes Band stehen gleiche Kopien hintereinander, im Abstand von 40 % der Pfadlänge. Im Vorbild sind es drei (startOffset −40 %, 0 %, 40 %), eine vierte bei 80 % schließt die Lücke am Pfadende.
  3. Beim Scrollen wächst startOffset jeder Kopie um bis zu 40 %. Der Text fließt die Kurve entlang, linear an den Scroll-Fortschritt gekoppelt.
  4. Statt den Text zu verschieben, kann sich auch der Pfad verformen. Der Text folgt ihm.
  5. Auf einem Kreispfad entsteht ein Rund-Label.

Schrift auf einer Kurve bricht das starre Raster. Das wirkt organisch und handwerklich. Weil die Bewegung am Scrollen hängt, steuert der Besucher sie selbst; nichts läuft von allein. Rund-Labels sind ein kleiner, wiedererkennbarer Akzent.

Wann einsetzen

Sektionstrenner und Übergänge zum Footer, Schlagwort-Bänder (Leistungen, Schwerpunkte), Rund-Labels wie „Termin vereinbaren“ oder „Available for work“, Marken mit verspieltem oder handwerklichem Charakter.

Wann nicht

Für wichtige Inhalte: Text auf Kurven liest sich schwer. Kein langer Text auf engen Kurven. Kein Band, das von allein endlos läuft, neben Inhalten, die Ruhe brauchen.

Varianten

  • Scroll-gekoppelter startOffset: Kopien fließen die Kurve entlang (Olivier Larose)
  • Pfad morpht, Text folgt: die Kurve verformt sich mit dem Scroll, der Text bleibt mittig darauf
  • Kreis: Rund-Label, dessen Text sich mit dem Scroll weiterdreht (als Komponente eigener Eintrag kreistext-siegel-rotierend)
  • Radiales Marquee (Osmo „Radial Text Marquee“)
  • Statisch auf einem Kreisbogen, Teil für Teil erklärt: z. B. die Reifengröße auf der Reifenflanke (Demo-Variante reifen)

Umsetzung

<svg viewBox="0 0 B H"><defs><path id="kurve" d="…"/></defs><text><textPath href="#kurve" startOffset="0%">…</textPath>…</text></svg>. Scroll-Fortschritt p (0–1) → tp.setAttribute('startOffset', (-40 + i*40 + p*40) + '%'). Die Schrift skaliert mit der viewBox: viewBox auf die echte Pixelgröße setzen und den Pfad in JS berechnen, dann bleibt die Schriftgröße in px planbar. Text am Pfadende wird abgeschnitten: Pfad über den Rand hinaus zeichnen und Kopien nutzen. textLength = 40 % der Pfadlänge (lengthAdjust="spacing") ergibt ein nahtloses Band. Einen Kreispfad zweimal umlaufen lassen, damit verschobener Text nicht abreißt. SVG kürzt Leerzeichen am Textende, deshalb dort ein geschütztes Leerzeichen setzen. Das Vorbild glättet das Scrollen mit Lenis; ohne Lenis reicht ein Lerp auf p.

Robust umsetzen

Telefon. Funktioniert mit Touch-Scroll. Kurven werden auf schmalen Viewports gestaucht: Schrift größer wählen, weniger Wellen und kürzere Sätze.

Ohne Bewegung. Text steht still auf dem Pfad, ohne Scroll-Kopplung. Ein verformter Pfad zeigt gleich seine Endform, ein Rund-Label dreht sich nicht.

Barrierefrei. Das SVG ist dekorativ (aria-hidden), damit auch alle Kopien. Der Inhalt steht einmal als normaler Text im Dokument, notfalls visuell versteckt. Ein Rund-Label, das als Bild wirkt, bekommt role="img" und ein aria-label. Kontrast von Text zu Band mindestens 4,5:1.

Code

<!DOCTYPE html>
<!--
  Text auf Pfad
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/text-on-path/
  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>Text auf Pfad · 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: 'Hedvig Letters Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hedvig-letters-serif:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Google Sans Flex'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/google-sans-flex:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 1000; 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; }
/* Variante „reifen“: Rezept kfz-werkstatt-autohaus */
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }

/* Welt: Physiotherapie in Kiel (Rezept praxis-gesundheit-ruhig).
   Petrol statt Klinikblau, warme Off-Whites, Apricot nur als Hervorhebung. */
:root {
  --grund: #F5F7F7; --flaeche: #E2E0DE; --marke: #005450; --marke-tief: #00403c;
  --text: #363B3B; --leise: #596666; --linie: rgba(0, 84, 80, .16); --akzent: #FFC776;
  --ui: cubic-bezier(0.2, 0, 0.38, 0.9);   /* Token tech-präzise, nur für UI */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Google Sans Flex', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; border-radius: 4px; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.serif { font-family: 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; font-weight: 400; }
.nur-vorlesen { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Scrollstrecke: die Bühne klebt, der Fortschritt p (0–1) treibt den Pfad */
.strecke { height: 300vh; position: relative; }
.buehne { position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden; display: grid; grid-template-rows: auto 1fr auto; }

.kopf { position: relative; z-index: 3; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); background: color-mix(in srgb, var(--grund) 88%, transparent); }
.marke { display: flex; align-items: center; gap: 12px; text-decoration: none; }
.marke svg { width: 34px; height: 34px; flex: none; }
.marke b { display: block; font: 400 22px/1 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; letter-spacing: -.01em; color: var(--marke-tief); }
.marke small { display: block; margin-top: 4px; font-size: 13px; color: var(--leise); }
.nav { display: flex; gap: 28px; font-size: 15px; font-weight: 450; }
.nav a { text-decoration: none; color: var(--text); transition: color .15s var(--ui); }
.nav a:hover { color: var(--marke); }
.aktionen { display: flex; align-items: center; gap: 18px; }
.tel { font-size: 15px; font-weight: 500; text-decoration: none; color: var(--marke-tief); white-space: nowrap; }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 20px; border-radius: 999px; background: var(--marke); color: #fff; font: 500 16px/1 'Google Sans Flex', sans-serif; text-decoration: none; white-space: nowrap; transition: background-color .15s var(--ui); }
.knopf:hover { background: var(--marke-tief); }

.feld { position: relative; }
.pfade { position: absolute; inset: 0; width: 100%; height: 100%; display: block; overflow: visible; }
.pfade .gruppe { opacity: 0; transition: opacity .25s var(--ui); }
.pfade .gruppe.an { opacity: 1; }
.pfade text { font-family: 'Google Sans Flex', sans-serif; }

.intro { position: absolute; z-index: 2; left: clamp(16px, 4vw, 48px); top: clamp(28px, 6vh, 72px); max-width: min(34rem, 46vw); }
.block { display: none; }
.block.an { display: block; animation: rein .4s var(--ui) both; }
@keyframes rein { from { opacity: 0; transform: translateY(8px); } }
.block h1 { margin-top: 16px; font-size: clamp(2.25rem, 1.4rem + 3.2vw, 4.5rem); line-height: 1.1; letter-spacing: -.01em; color: var(--marke-tief); text-wrap: balance; }
.block p.lauf { margin-top: 18px; font-size: 1.0625rem; line-height: 1.6; color: var(--text); max-width: 40ch; text-wrap: pretty; }
.woche { margin-top: 22px; display: flex; align-items: center; gap: 12px; }
.woche .skala { display: grid; grid-template-columns: repeat(6, 22px); gap: 4px; }
.woche .skala i { height: 4px; border-radius: 2px; background: var(--linie); transition: background-color .15s var(--ui); }
.woche .skala i.voll { background: var(--marke); }

.kreis-inhalt { position: absolute; z-index: 2; display: grid; place-content: center; text-align: center; gap: 6px; border-radius: 50%; background: var(--marke); color: #fff; opacity: 0; pointer-events: none; transition: opacity .25s var(--ui); }
.kreis-inhalt.an { opacity: 1; pointer-events: auto; }
.kreis-inhalt .mono { color: var(--akzent); }
.kreis-inhalt strong { font: 400 clamp(30px, 3.4vw, 46px)/1 'Hedvig Letters Serif', Georgia, serif; font-variation-settings: 'opsz' 24; }
.kreis-inhalt span { font-size: 15px; line-height: 1.4; color: rgba(255, 255, 255, .82); }
.kreis-inhalt.klein span { display: none; }
.kreis-inhalt.klein strong { font-size: 28px; }
.kreis-inhalt a { margin-top: 8px; justify-self: center; font-size: 15px; font-weight: 500; color: var(--marke-tief); background: var(--akzent); border-radius: 999px; padding: 10px 16px; text-decoration: none; }

.leiste { position: relative; z-index: 3; display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding: 14px clamp(16px, 4vw, 48px) 18px; }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s var(--ui), background-color .15s var(--ui), border-color .15s var(--ui); }
.modi button:hover { color: var(--marke-tief); border-color: rgba(0, 84, 80, .4); }
.modi button[aria-pressed='true'] { background: var(--marke); color: #fff; border-color: var(--marke); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 18px; justify-content: flex-end; }
.werte b { color: var(--marke-tief); font-weight: 500; }

/* Danach: ruhige Visitenkarte der Praxis */
.danach { padding: clamp(64px, 12vh, 140px) clamp(16px, 4vw, 48px) 80px; background: var(--marke-tief); color: #eef3f2; }
.danach .gross { max-width: 18ch; font-size: clamp(1.75rem, 1.3rem + 1.6vw, 2.75rem); line-height: 1.15; color: #fff; }
.danach .spalten { margin-top: 48px; display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 32px; }
.danach .mono { color: var(--akzent); margin-bottom: 10px; }
.danach p { font-size: 1.0625rem; line-height: 1.6; }

/* Breit: Titel links, Lauftext rechts auf Grundlinie; die Welle bekommt die volle Breite */
@media (min-width: 860px) {
  .intro { right: clamp(16px, 4vw, 48px); max-width: none; }
  .block[data-block='welle'].an, .block[data-block='aufrichten'].an { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); column-gap: 7vw; align-items: end; }
  .block[data-block='welle'] > .mono, .block[data-block='aufrichten'] > .mono { grid-column: 1 / -1; }
  .block .neben { padding-bottom: .6rem; }
  .block .neben p.lauf { margin-top: 0; }
  .block[data-block='kreis'] { max-width: min(34rem, 46vw); }
}
@media (max-width: 1080px) { .nav { display: none; } }
@media (max-width: 700px) {
  .kopf { padding-block: 12px; }
  .marke small, .tel { display: none; }
  .knopf { height: 40px; padding: 0 16px; font-size: 15px; }
  .intro { max-width: none; right: 16px; top: 24px; }
  .block p.lauf { margin-top: 12px; }
  .leiste { padding-top: 10px; }
  .werte { justify-content: flex-start; }
  .werte .lang { display: none; }
}
@media (max-width: 700px) and (max-height: 760px) { .block p.lauf { display: none; } }

/* Touch: gleiches Scrollverhalten; größere Ziele für die Umschalter */
@media (hover: none) {
  .modi button { height: 36px; padding: 0 14px; }
}

/* Reduzierte Bewegung: keine Scrollstrecke, Text steht still auf dem Pfad */
:root.reduziert .strecke { height: auto; }
:root.reduziert .buehne { position: relative; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .strecke { height: 100vh; }
.vorschau .danach, .vorschau .block p.lauf, .vorschau .werte .lang { display: none; }
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}

/* ===================== Varianten-Umschalter (beide Welten) ===================== */
[hidden] { display: none !important; }
.fassung { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.fassung > span { margin-right: 6px; }
.fassung button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px dashed var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s var(--ui), background-color .15s var(--ui), border-color .15s var(--ui); }
.fassung button:hover { color: var(--marke-tief); border-style: solid; }
.fassung button[aria-pressed='true'] { color: var(--marke-tief); border-style: solid; border-color: var(--marke-tief); }
.vorschau .fassung { display: none; }

/* ===================== Variante „reifen“: stehende Reifenflanke (kfz-werkstatt-autohaus) ===================== */
.rw {
  --r-grund: #F2F1ED; --r-text: #121314; --r-leise: #595E63; --r-asphalt: #232629; --r-asphalt-leise: #B9BEC4;
  --r-gelb: #F5B700; --r-linie: #D9D7D0; --r-gruen: #1F7A3E;
  --r-auf: cubic-bezier(0.16, 1, 0.3, 1); --r-zu: cubic-bezier(0.7, 0, 0.84, 0);   /* token-toggle-asymmetrisch */
  --r-rand: clamp(16px, 4vw, 56px);
  background: var(--r-grund); color: var(--r-text); min-height: 100vh; min-height: 100svh;
  font-family: 'Google Sans Flex', system-ui, sans-serif; font-size: 1.0625rem; line-height: 1.55;
  display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr);
}
.rw :focus-visible { outline: 2px solid var(--r-text); outline-offset: 3px; border-radius: 2px; }
.rw .r-kopf { background: var(--r-asphalt); color: #fff; display: flex; align-items: center; gap: 12px 28px; padding: 14px var(--r-rand); flex-wrap: wrap; }
.rw .r-kopf :focus-visible { outline-color: var(--r-gelb); }
.r-marke { display: flex; align-items: baseline; gap: 12px; text-decoration: none; color: #fff; }
.r-marke b { font: 800 1.5rem/1 'Mona Sans', sans-serif; font-stretch: 80%; letter-spacing: -.02em; }
.r-marke small { font-size: .8125rem; color: var(--r-asphalt-leise); }
.r-nav { display: flex; gap: 24px; margin-left: auto; font-size: .9375rem; }
.r-nav a { color: #fff; text-decoration: none; padding-block: 4px; border-bottom: 2px solid transparent; transition: border-color .12s var(--r-auf); }
.r-nav a:hover, .r-nav a[aria-current] { border-color: var(--r-gelb); }
.r-tel { font: 700 1.25rem/1 'Google Sans Flex', sans-serif; font-variant-numeric: tabular-nums; color: #fff; text-decoration: none; white-space: nowrap; }
.r-termin { display: inline-flex; align-items: center; height: 48px; padding: 0 20px; border-radius: 4px; background: var(--r-gelb); color: var(--r-text); font-weight: 700; text-decoration: none; white-space: nowrap; box-shadow: inset 0 -3px 0 rgba(0, 0, 0, .22); transition: background-color .12s var(--r-auf); }
.r-termin:hover { background: #ffc52a; }
.r-termin.hell { border: 2px solid var(--r-asphalt); }

.r-main { padding: clamp(28px, 5vh, 64px) var(--r-rand) clamp(32px, 6vh, 72px); display: grid; gap: clamp(36px, 6vh, 72px); grid-template-columns: minmax(0, 1fr); align-content: start; }
.r-reifen { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(28px, 4vw, 72px); align-items: center; }
.r-label { font-size: .8125rem; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--r-leise); }
.r-einl h1 { margin-top: 12px; font: 800 clamp(2.5rem, 1.6rem + 3.6vw, 4.25rem)/.98 'Mona Sans', sans-serif; font-stretch: 80%; letter-spacing: -.02em; text-wrap: balance; }
.r-einl > p:not(.r-label) { margin-top: 16px; max-width: 46ch; text-wrap: pretty; }

/* Die Flanke: SVG (aria-hidden), Nabe als Erklärfeld darüber */
.r-flanke { position: sticky; top: 24px; align-self: start; margin: 0; width: 100%; max-width: min(620px, calc(100svh - 150px)); justify-self: center; aspect-ratio: 1; container-type: inline-size; grid-column: 1; grid-row: 1 / span 2; }
.r-reifen > .r-einl, .r-reifen > .r-liste { grid-column: 2; }
.r-flanke svg { display: block; width: 100%; height: 100%; overflow: visible; }
.r-flanke text { font-family: 'Mona Sans', sans-serif; font-stretch: 80%; }
.r-flanke [data-teil] { transition: fill .12s var(--r-auf); }   /* Tastaturweg ist die Liste; der Bogen reagiert zusätzlich auf Zeigen */
.r-groesse-arc { fill: #C9CDD2; }
.r-flanke.hat-aktiv .r-groesse-arc, .r-flanke.hat-aktiv .r-klein { fill: #6B7077; }
.r-flanke [data-teil].an { fill: var(--r-gelb) !important; }
.r-klein { fill: #8B9097; }
.r-nabe { position: absolute; left: 50%; top: 50%; width: 34%; aspect-ratio: 1; translate: -50% -50%; border-radius: 50%; background: var(--r-asphalt); color: #fff; display: grid; place-content: center; text-align: center; gap: .4cqi; box-shadow: 0 0 0 1.2cqi #8D939A, 0 0 0 1.6cqi #5E646B; pointer-events: none; }
.r-nabe-wert { font: 800 9.5cqi/1 'Mona Sans', sans-serif; font-stretch: 75%; font-variant-numeric: tabular-nums; color: var(--r-gelb); }
.r-nabe-wert small { font-size: .42em; font-weight: 700; color: #fff; margin-left: .15em; }
.r-nabe-name { font-size: max(11px, 2.5cqi); font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--r-asphalt-leise); max-width: 26cqi; line-height: 1.25; }
.r-nabe-kurz { font-size: max(12px, 2.9cqi); font-weight: 600; color: #fff; font-variant-numeric: tabular-nums; }
.r-nabe-kurz:empty { display: none; }
.r-nabe.leer .r-nabe-wert { font-size: 5.4cqi; color: #fff; }

.r-liste { display: grid; gap: 18px; align-content: start; }
.r-zeile { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px; }
.r-zeile .r-label { flex: 1 1 auto; }
.r-teile { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--r-linie); }
.r-teile li { border-bottom: 1px solid var(--r-linie); }
.r-teil { width: 100%; display: grid; grid-template-columns: 4.6rem minmax(0, 1fr); column-gap: 16px; align-items: baseline; text-align: left; padding: 11px 12px 11px 0; min-height: 48px; background: none; border: 0; border-left: 4px solid transparent; font: inherit; color: inherit; cursor: pointer; transition: background-color .12s var(--r-auf), border-color .12s var(--r-auf); }
.r-teil .r-wert { grid-row: span 2; padding-left: 12px; font: 800 1.75rem/1 'Mona Sans', sans-serif; font-stretch: 75%; font-variant-numeric: tabular-nums; }
.r-teil .r-name { font-weight: 600; }
.r-teil .r-erkl { color: var(--r-leise); font-size: .9375rem; line-height: 1.45; }
.r-teil:hover, .r-teil.an { background: #E8E6DF; border-left-color: var(--r-gelb); }
.r-teil[aria-pressed='true'] { border-left-color: var(--r-text); }
.r-hinweis { font-size: .875rem; color: var(--r-leise); }

.r-preise { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 20px 40px; align-items: end; background: var(--r-asphalt); color: #fff; border-radius: 6px; padding: clamp(20px, 3vw, 32px); }
.r-preise h2 { font: 750 clamp(1.5rem, 1.2rem + 1.2vw, 2.25rem)/1.05 'Mona Sans', sans-serif; font-stretch: 85%; }
.r-preise table { width: 100%; border-collapse: collapse; margin-top: 14px; font-variant-numeric: tabular-nums; }
.r-preise th, .r-preise td { padding: 10px 0; border-bottom: 1px solid #3A3E42; text-align: left; font-weight: 400; }
.r-preise td { text-align: right; font-weight: 700; font-size: 1.125rem; white-space: nowrap; padding-left: 16px; }
.r-preise .r-stand { margin-top: 10px; font-size: .875rem; color: var(--r-asphalt-leise); }
.r-preise .r-termin { align-self: end; }

.r-leiste { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px 24px; padding: 14px var(--r-rand) 20px; border-top: 1px solid var(--r-linie); }
.rw .mono { color: var(--r-leise); }
.rw .fassung button { color: var(--r-leise); border-color: #BDBAB1; }
.rw .fassung button[aria-pressed='true'], .rw .fassung button:hover { color: var(--r-text); border-color: var(--r-text); }
.r-werte { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.r-werte b { color: var(--r-text); font-weight: 500; }

@media (max-width: 1080px) { .r-nav { display: none; } .r-tel { margin-left: auto; } }
@media (max-width: 860px) {
  .r-reifen { grid-template-columns: minmax(0, 1fr); }
  .r-flanke, .r-reifen > .r-einl, .r-reifen > .r-liste { grid-column: auto; grid-row: auto; }
  .r-flanke { max-width: 520px; position: relative; top: 0; }
  .r-preise { grid-template-columns: minmax(0, 1fr); }
  .r-preise .r-termin { justify-self: start; }
}
@media (max-width: 560px) {
  .r-marke small, .r-tel { display: none; }
  .r-termin { margin-left: auto; height: 44px; padding: 0 16px; }
  .r-teil { grid-template-columns: 3.6rem minmax(0, 1fr); column-gap: 12px; }
  .r-teil .r-wert { font-size: 1.5rem; }
  .r-werte .lang { display: none; }
}
@media (max-width: 360px) { .r-preise td { white-space: normal; } .r-marke b { font-size: 1.25rem; } }
@media (hover: none) { .rw .fassung button, .fassung button { height: 36px; padding: 0 14px; } }
.vorschau .rw .r-nav, .vorschau .rw .r-tel, .vorschau .rw .r-preise, .vorschau .rw .r-leiste, .vorschau .rw .r-einl > p:not(.r-label), .vorschau .rw .r-hinweis, .vorschau .rw .r-erkl { display: none; }
.vorschau .rw .r-main { padding-top: 22px; }
.vorschau .rw .r-flanke { max-width: min(560px, calc(100vh - 120px)); }
.vorschau .rw .r-einl h1 { font-size: 3rem; }
.vorschau .rw .r-teil { min-height: 0; padding-block: 7px; }
</style>
</head>
<body>
<section class="strecke" data-strecke data-welt="basis">
  <div class="buehne" data-buehne>
    <header class="kopf">
      <a class="marke" href="#" aria-label="Seegang Physiotherapie, Startseite">
        <svg viewBox="0 0 34 34" aria-hidden="true"><circle cx="17" cy="17" r="16" fill="#005450"/><path d="M6 14c3.7-3 7.3-3 11 0s7.3 3 11 0M6 19.5c3.7-3 7.3-3 11 0s7.3 3 11 0" fill="none" stroke="#FFC776" stroke-width="1.8" stroke-linecap="round"/></svg>
        <span><b>Seegang</b><small>Physiotherapie · Kiel-Düsternbrook</small></span>
      </a>
      <nav class="nav" aria-label="Hauptnavigation"><a href="#">Behandlungen</a><a href="#">Ablauf</a><a href="#">Team</a><a href="#">Kosten</a><a href="#">Anfahrt</a></nav>
      <div class="aktionen"><a class="tel" href="tel:+494319827400">0431 98 27 40-0</a><a class="knopf" href="#">Termin vereinbaren</a></div>
    </header>

    <div class="feld" data-feld>
      <svg class="pfade" data-svg aria-hidden="true" focusable="false"></svg>

      <div class="intro">
        <div class="block an" data-block="welle">
          <p class="mono">Wobei wir helfen</p>
          <h1 class="serif">Wir behandeln, was Sie im Alltag bremst.</h1>
          <div class="neben"><p class="lauf">Rücken, Schulter, das Knie nach der Operation. Sie beschreiben, was weh tut. Wir finden mit Ihnen heraus, woher es kommt.</p></div>
          <p class="nur-vorlesen">Schwerpunkte: Rücken, Schulter, Knie nach der OP, Nacken, Kiefer, Sportverletzung, Schwindel, Hüfte.</p>
        </div>
        <div class="block" data-block="aufrichten">
          <p class="mono">Ihr Behandlungsplan</p>
          <h1 class="serif">Aus der Schonhaltung zurück in den Alltag.</h1>
          <div class="neben"><p class="lauf">Sechs Wochen, ein klarer Plan. Jede Woche ein wenig mehr Bewegung, die Sie auch zu Hause üben.</p>
          <div class="woche" aria-hidden="true"><span class="mono">Woche <b data-woche>1</b> von 6</span><span class="skala" data-skala><i></i><i></i><i></i><i></i><i></i><i></i></span></div></div>
          <p class="nur-vorlesen">Schritt für Schritt zurück in die Aufrichtung.</p>
        </div>
        <div class="block" data-block="kreis">
          <p class="mono">Ihr erster Termin</p>
          <h1 class="serif">Meist frei innerhalb einer Woche.</h1>
          <p class="lauf">Mit Rezept von Ihrer Ärztin oder als Selbstzahler. Bringen Sie bequeme Kleidung und vorhandene Befunde mit.</p>
          <p class="nur-vorlesen">Angebot: Physiotherapie, Manuelle Therapie, Lymphdrainage, Gerätetraining.</p>
        </div>
      </div>

      <div class="kreis-inhalt" data-kreis-inhalt inert>
        <p class="mono">Erstbefund</p>
        <strong>45 Min.</strong>
        <span>Kasse und privat</span>
        <a href="#">Termin wählen</a>
      </div>
    </div>

    <div class="leiste">
      <div class="modi mono" role="group" aria-label="Variante">
        <button type="button" data-modus="welle" aria-pressed="true">Welle</button>
        <button type="button" data-modus="aufrichten" aria-pressed="false">Pfad morpht</button>
        <button type="button" data-modus="kreis" aria-pressed="false">Kreis</button>
      </div>
      <div class="fassung mono" role="group" aria-label="Variante der Demo"><span>Variante</span>
        <button type="button" data-variante="basis" aria-pressed="true">Scroll-Band</button>
        <button type="button" data-variante="reifen" aria-pressed="false">Reifen</button>
      </div>
      <p class="werte mono" data-werte></p>
    </div>
  </div>
</section>

<footer class="danach" data-welt="basis">
  <p class="mono">Seegang Physiotherapie</p>
  <p class="gross serif">Bewegung ist der Weg zurück. Wir gehen ihn mit Ihnen.</p>
  <div class="spalten">
    <div><p class="mono">Sprechzeiten</p><p>Mo bis Fr 7:30 bis 19:00 Uhr<br>Sa 9:00 bis 13:00 Uhr</p></div>
    <div><p class="mono">Anfahrt</p><p>Niemannsweg 12, 24105 Kiel<br>Bus 41, 42 bis Reventloubrücke</p></div>
    <div><p class="mono">Barrierefrei</p><p>Ebenerdiger Eingang, höhenverstellbare Liegen, Parkplatz am Haus</p></div>
    <div><p class="mono">Im Notfall</p><p>Ärztlicher Bereitschaftsdienst 116 117<br>Rettungsdienst 112</p></div>
  </div>
</footer>

<!-- ===================== Variante „reifen“: Kfz-Werkstatt, die Größe steht auf der Flanke ===================== -->
<div class="rw" data-welt="reifen" hidden>
  <header class="r-kopf">
    <a class="r-marke" href="#r-titel"><b>Rehberg</b><small>Autohaus · Meisterwerkstatt · Hennef</small></a>
    <nav class="r-nav" aria-label="Hauptnavigation"><a href="#r-titel">Werkstatt</a><a href="#r-titel" aria-current="page">Reifen</a><a href="#r-titel">Fahrzeuge</a><a href="#r-titel">Kontakt</a></nav>
    <a class="r-tel" href="tel:+492242918400">02242 91 84-0</a>
    <a class="r-termin" href="#r-preise">Termin buchen</a>
  </header>

  <main class="r-main">
    <section class="r-reifen" aria-labelledby="r-titel">
      <div class="r-einl">
        <p class="r-label">Reifen · Größe lesen</p>
        <h1 id="r-titel">Was auf Ihrem Reifen steht.</h1>
        <p>Die Größe steht auf der Flanke, immer in derselben Reihenfolge. Zeigen Sie auf einen Teil der Angabe oder gehen Sie mit der Tab-Taste durch die Liste.</p>
      </div>

      <figure class="r-flanke" data-flanke>
        <svg viewBox="0 0 800 800" data-reifen-svg aria-hidden="true" focusable="false">
          <defs>
            <radialGradient id="r-gummi" cx="400" cy="400" r="392" gradientUnits="userSpaceOnUse">
              <stop offset=".62" stop-color="#2a2c2f"/><stop offset=".8" stop-color="#202225"/><stop offset=".95" stop-color="#18191b"/><stop offset="1" stop-color="#111213"/>
            </radialGradient>
            <radialGradient id="r-felge" cx="360" cy="340" r="300" gradientUnits="userSpaceOnUse">
              <stop offset="0" stop-color="#e3e6ea"/><stop offset=".6" stop-color="#aab0b7"/><stop offset="1" stop-color="#7f868e"/>
            </radialGradient>
            <radialGradient id="r-scheibe" cx="400" cy="400" r="178" gradientUnits="userSpaceOnUse">
              <stop offset=".5" stop-color="#4a4e54"/><stop offset=".9" stop-color="#666b72"/><stop offset="1" stop-color="#3d4146"/>
            </radialGradient>
            <path id="r-bogen-oben" d="M 88 400 A 312 312 0 0 1 712 400"/>
            <path id="r-bogen-unten" d="M 100 400 A 300 300 0 0 0 700 400"/>
          </defs>
          <circle cx="400" cy="400" r="392" fill="url(#r-gummi)"/>
          <g data-profil fill="#0d0e0f"></g>
          <circle cx="400" cy="400" r="368" fill="none" stroke="#33363a" stroke-width="1.5"/>
          <circle cx="400" cy="400" r="262" fill="none" stroke="#33363a" stroke-width="1.5"/>
          <circle cx="400" cy="400" r="252" fill="#16171a"/>
          <circle cx="400" cy="400" r="244" fill="url(#r-felge)"/>
          <circle cx="400" cy="400" r="226" fill="#17181b"/>
          <circle cx="400" cy="400" r="178" fill="url(#r-scheibe)"/>
          <g data-loecher fill="#2e3136"></g>
          <path d="M 520 268 A 178 178 0 0 1 566 330 L 540 342 A 150 150 0 0 0 502 290 Z" fill="#33363b"/>
          <g data-speichen fill="url(#r-felge)" stroke="#6f767e" stroke-width="1.5"></g>
          <circle cx="400" cy="400" r="226" fill="none" stroke="#5c6269" stroke-width="3"/>
          <text class="r-groesse-arc" font-size="64" font-weight="800" letter-spacing="1.5" dy="0.35em" text-anchor="middle">
            <textPath href="#r-bogen-oben" startOffset="50%"><tspan data-teil="breite">205</tspan>/<tspan data-teil="querschnitt">55</tspan> <tspan data-teil="bauart">R</tspan><tspan data-teil="felge">16</tspan> <tspan data-teil="last">91</tspan><tspan data-teil="tempo">V</tspan></textPath>
          </text>
          <text class="r-klein" font-size="22" font-weight="700" letter-spacing="3" dy="0.35em" text-anchor="middle">
            <textPath href="#r-bogen-unten" startOffset="50%">TUBELESS · RADIAL · DOT 4B7X <tspan data-teil="dot">2423</tspan> · E4</textPath>
          </text>
        </svg>
        <div class="r-nabe leer" data-nabe aria-hidden="true"><span class="r-nabe-wert" data-nabe-wert>205/55 R16</span><span class="r-nabe-name" data-nabe-name>Teil wählen</span><span class="r-nabe-kurz" data-nabe-kurz></span></div>
      </figure>

      <div class="r-liste">
        <div class="r-zeile"><p class="r-label" id="r-liste-titel">205/55 R16 91V, Teil für Teil</p></div>
        <ol class="r-teile" aria-labelledby="r-liste-titel" data-teile></ol>
        <p class="r-hinweis">Ihre Größe steht auch im Fahrzeugschein (Feld 15.1 und 15.2) und innen am Tankdeckel oder an der Fahrertür.</p>
      </div>
    </section>

    <section class="r-preise" id="r-preise" aria-labelledby="r-preise-titel">
      <div>
        <h2 id="r-preise-titel">Reifenservice zum Festpreis</h2>
        <table>
          <caption class="nur-vorlesen">Festpreise Reifenservice, inklusive Mehrwertsteuer</caption>
          <tbody>
            <tr><th scope="row">Räder wechseln (Sommer/Winter, auf Felge)</th><td>35,00 €</td></tr>
            <tr><th scope="row">Reifen montieren und wuchten, 4 Stück</th><td>79,00 €</td></tr>
            <tr><th scope="row">Einlagern je Saison, mit Wäsche und Profilprotokoll</th><td>49,00 €</td></tr>
          </tbody>
        </table>
        <p class="r-stand">Preise inkl. MwSt. für Pkw bis 18 Zoll · Stand 01.10.2026</p>
      </div>
      <a class="r-termin" href="#r-titel">Termin buchen</a>
    </section>
  </main>

  <div class="r-leiste">
    <div class="fassung mono" role="group" aria-label="Variante der Demo"><span>Variante</span>
      <button type="button" data-variante="basis" aria-pressed="false">Scroll-Band</button>
      <button type="button" data-variante="reifen" aria-pressed="true">Reifen</button>
    </div>
    <p class="r-werte mono" data-r-werte></p>
  </div>
</div>

<script>
const DEMOS = {};   // je Variante { neustart, vorfuehren, pause, weiter }

(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const NS = 'http://www.w3.org/2000/svg';
  const $ = s => document.querySelector(s);
  const strecke = $('[data-strecke]'), feld = $('[data-feld]'), svg = $('[data-svg]');
  const werte = $('[data-werte]'), kreisInhalt = $('[data-kreis-inhalt]');

  // Werte aus den Messwerten (Olivier Larose): 4 Kopien im Abstand von 40 %,
  // startOffset = −40 + i·40 + p·40 %, linear an den Scroll gekoppelt.
  // Lenis lässt das Praxis-Rezept nicht zu; geglättet wird nur der Fortschritt (Lerp 0,1).
  const ABSTAND = 40, KOPIEN = 4, LERP = 0.1;
  const KRUEMMUNG_ENDE = 0.12;           // Pfad morpht von 100 % auf 12 % Krümmung
  const KREIS_DREHUNG = 0.4;             // Kreis: +40 % des Umfangs über die Strecke
  const WELLE = ['Rücken', 'Schulter', 'Knie nach der OP', 'Nacken', 'Kiefer', 'Sportverletzung', 'Schwindel', 'Hüfte'];
  const RING = ['Physiotherapie', 'Manuelle Therapie', 'Lymphdrainage', 'Gerätetraining'];

  let modus = 'welle', p = 0, ziel = 0, geo = null;
  let vorf = null, vorfStart = 0, vorfPause = false, pausiertBei = 0;

  const el = (name, attrs = {}, eltern) => {
    const n = document.createElementNS(NS, name);
    for (const k in attrs) n.setAttribute(k, attrs[k]);
    if (eltern) eltern.appendChild(n);
    return n;
  };
  const r1 = v => Math.round(v * 10) / 10;

  // Halbe Sinuswelle als kubische Bézierkurve (Scheitel bei t = 0,5 → k = 4A/3)
  function wellenPfad(x0, breite, haelften, y, A) {
    const h = breite / haelften, k = A * 4 / 3, a = h * 0.36;
    let d = `M${r1(x0)},${r1(y)}`;
    for (let i = 0; i < haelften; i++) {
      const s = x0 + i * h, v = i % 2 ? k : -k;
      d += ` C${r1(s + a)},${r1(y + v)} ${r1(s + h - a)},${r1(y + v)} ${r1(s + h)},${r1(y)}`;
    }
    return d;
  }
  // S-Kurve (liegende Wirbelsäule): c = 1 Schonhaltung, c → 0,12 aufgerichtet
  function sPfad(g, c) {
    const { x0, breite, y, D } = g.auf;
    // links nach unten (unter dem Text), rechts nach oben (freie Fläche)
    return `M${r1(x0)},${r1(y)} C${r1(x0 + breite * .33)},${r1(y + c * D)} ${r1(x0 + breite * .67)},${r1(y - c * D)} ${r1(x0 + breite)},${r1(y)}`;
  }
  // Kreis zweimal umlaufen, damit verschobener Text nie am Pfadende abreißt
  function kreisPfad(cx, cy, r) {
    const bogen = `a${r},${r} 0 1 1 ${2 * r},0 a${r},${r} 0 1 1 ${-2 * r},0`;
    return `M${r1(cx - r)},${r1(cy)} ${bogen} ${bogen}`;
  }

  // Textbreite messen (nach dem Laden der Schriften)
  function miss(text, fs, gewicht, sperrung = 0) {
    const t = el('text', { 'font-size': fs, 'font-weight': gewicht, 'letter-spacing': sperrung * fs, x: -9999, y: -9999 }, svg);
    t.textContent = text;
    const b = t.getComputedTextLength();
    t.remove();
    return b;
  }
  // So viele Begriffe wählen, dass eine Kopie genau `soll` lang wird; Schrift minimal angleichen
  function fuellen(liste, soll, fs, gewicht, sperrung, wiederholen) {
    let beste = null, items = [];
    for (let n = 0; n < (wiederholen ? 40 : liste.length); n++) {
      items.push(liste[n % liste.length]);
      const satz = items.join(' • ') + ' • ';
      const faktor = soll / miss(satz, fs, gewicht, sperrung);
      if (beste && faktor < 0.95) break;   // lieber etwas größer als kleiner setzen
      if (!beste || Math.abs(Math.log(faktor)) < Math.abs(Math.log(beste.faktor))) beste = { items: [...items], faktor };
    }
    return { items: beste.items, fs: r1(fs * beste.faktor) };
  }
  // Begriffe als tspans, Trennpunkte im Akzent
  function setzeText(tp, items, punktFarbe) {
    items.forEach(w => {
      tp.appendChild(document.createTextNode(w + ' '));
      const s = el('tspan', { fill: punktFarbe }, tp); s.textContent = '•';
      tp.appendChild(document.createTextNode('\u00A0'));   // geschütztes Leerzeichen: SVG kürzt sonst das letzte
    });
  }

  // Freie Fläche unter dem Einleitungstext je Variante messen
  function introMasse() {
    const intro = feld.querySelector('.intro'), bloecke = [...intro.querySelectorAll('[data-block]')], m = {};
    bloecke.forEach(b => {
      bloecke.forEach(x => { x.style.display = x === b ? 'block' : 'none'; });
      m[b.dataset.block] = { unten: intro.offsetTop + intro.offsetHeight, rechts: intro.offsetLeft + b.offsetWidth };
    });
    bloecke.forEach(x => { x.style.display = ''; });
    return m;
  }

  function aufbauen() {
    const W = feld.clientWidth, H = feld.clientHeight;
    if (!W || !H) return;
    const schmal = W < 700, luft = schmal ? 22 : 40, rand = 12;
    const intro = introMasse();
    svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
    svg.textContent = '';
    const defs = el('defs', {}, svg);
    const x0 = -0.15 * W, breite = 1.3 * W;
    geo = { W, H, schmal };

    /* Variante 1: Welle mit vier gleitenden Kopien; steigt nach rechts in die freie Fläche */
    {
      const fs0 = schmal ? 21 : Math.max(24, Math.min(W * 0.026, 40)), halb = fs0 * 2.15 * 0.55 + (schmal ? 27 : 39); // Band + Zierlinien
      const { unten } = intro.welle;
      const frei = H - rand - unten - luft;
      const A = Math.max(18, Math.min(schmal ? Math.min(72, H * 0.1) : Math.min(H * 0.13, 112), frei / 2 - halb));
      const y0 = unten + luft + frei / 2;
      const haelften = schmal ? 3 : 4;
      const pfad = el('path', { id: 'pfad-welle', d: wellenPfad(x0, breite, haelften, y0, A) }, defs);
      const L = pfad.getTotalLength();
      const f = fuellen(WELLE, L * ABSTAND / 100, fs0, 460, 0.01, false);
      const band = f.fs * 2.15;
      const g = el('g', { class: 'gruppe', 'data-gruppe': 'welle' }, svg);
      for (let k = 1; k <= 3; k++) for (const s of [-1, 1]) {
        el('path', { d: wellenPfad(x0, breite, haelften, y0 + s * (band / 2 + k * (schmal ? 9 : 13)), A), fill: 'none', stroke: '#005450', 'stroke-opacity': r1((0.2 - k * 0.045) * 100) / 100, 'stroke-width': 1 }, g);
      }
      el('use', { href: '#pfad-welle', fill: 'none', stroke: '#005450', 'stroke-width': r1(band), 'stroke-linecap': 'round' }, g);
      const text = el('text', { 'font-size': f.fs, 'font-weight': 460, 'letter-spacing': r1(f.fs * 0.01), fill: '#F5F7F7', dy: '0.36em' }, g);
      geo.welle = { tps: [], fs: f.fs, L, A: Math.round(A) };
      for (let i = 0; i < KOPIEN; i++) {
        const tp = el('textPath', { href: '#pfad-welle', startOffset: `${-ABSTAND + i * ABSTAND}%`, textLength: r1(L * ABSTAND / 100), lengthAdjust: 'spacing' }, text);
        setzeText(tp, f.items, '#FFC776');
        geo.welle.tps.push(tp);
      }
    }

    /* Variante 2: Pfad morpht, Text folgt (S-Kurve richtet sich auf) */
    {
      const satz = schmal ? 'Schritt für Schritt aufrecht' : 'Schritt für Schritt zurück in die Aufrichtung';
      let fs = schmal ? 20 : Math.max(24, Math.min(W * 0.024, 38));
      const lang = miss(satz, fs, 460), sicht = W * (schmal ? 0.8 : 0.6);
      if (lang > sicht) fs = fs * sicht / lang;
      fs = r1(fs);
      const band = fs * 2.15, { unten } = intro.aufrichten, S = 0.2887; // Scheitel der S-Kurve = 0,2887·D
      const y = (unten + luft + H - rand) / 2;
      const D = Math.max(40, (H - rand - y - band / 2) / S * (schmal ? 0.72 : 1));
      geo.auf = { x0, breite, y, D };
      const g = el('g', { class: 'gruppe', 'data-gruppe': 'aufrichten' }, svg);
      el('line', { x1: 0, x2: W, y1: r1(y), y2: r1(y), stroke: '#005450', 'stroke-opacity': 0.4, 'stroke-width': 1, 'stroke-dasharray': '2 6' }, g);
      const pfad = el('path', { id: 'pfad-auf', d: sPfad(geo, 1) }, defs);
      el('path', { d: sPfad(geo, 1), fill: 'none', stroke: '#005450', 'stroke-opacity': 0.16, 'stroke-width': 1, 'stroke-dasharray': '1 5' }, g);
      el('use', { href: '#pfad-auf', fill: 'none', stroke: '#005450', 'stroke-width': r1(band), 'stroke-linecap': 'round' }, g);
      const text = el('text', { 'font-size': fs, 'font-weight': 460, fill: '#F5F7F7', dy: '0.36em', 'text-anchor': 'middle' }, g);
      const tp = el('textPath', { href: '#pfad-auf', startOffset: '50%' }, text);
      tp.textContent = satz;
      geo.auf.pfad = pfad; geo.auf.fs = fs;
    }

    /* Variante 3: Kreis, Text um 40 % des Umfangs verschoben */
    {
      const fs0 = schmal ? 12 : Math.max(13, Math.min(W * 0.0115, 17)), aussen = fs0 * 1.3 + 30;
      const { unten, rechts } = intro.kreis;
      let r, cx, cy;
      if (schmal) {
        const frei = H - rand - unten - luft;
        r = Math.max(70, Math.min(W * 0.34, frei / 2 - aussen));
        cx = W / 2; cy = unten + luft + frei / 2;
      } else {
        r = Math.min(H / 2 - aussen - 8, (W - rechts) / 2 - aussen - 8, 250);
        cx = rechts + (W - rechts) / 2; cy = H / 2;
      }
      const pfad = el('path', { id: 'pfad-kreis', d: kreisPfad(cx, cy, r) }, defs);
      const C = 2 * Math.PI * r;
      const f = fuellen(RING.map(w => w.toUpperCase()), C, fs0, 520, 0.14, true);
      const band = f.fs * 2.6;
      const g = el('g', { class: 'gruppe', 'data-gruppe': 'kreis' }, svg);
      el('circle', { cx: r1(cx), cy: r1(cy), r: r1(r + band / 2 + 14), fill: 'none', stroke: '#005450', 'stroke-opacity': 0.2, 'stroke-width': 1 }, g);
      el('circle', { cx: r1(cx), cy: r1(cy), r: r1(r + band / 2 + 30), fill: 'none', stroke: '#005450', 'stroke-opacity': 0.1, 'stroke-width': 1 }, g);
      el('circle', { cx: r1(cx), cy: r1(cy), r: r1(r), fill: 'none', stroke: '#E2E0DE', 'stroke-width': r1(band) }, g);
      const text = el('text', { 'font-size': f.fs, 'font-weight': 520, 'letter-spacing': r1(f.fs * 0.14), fill: '#00403c', dy: '0.36em' }, g);
      const tp = el('textPath', { href: '#pfad-kreis', startOffset: 0, textLength: r1(C), lengthAdjust: 'spacing' }, text);
      setzeText(tp, f.items, '#005450');
      geo.kreis = { tp, C, fs: f.fs, r: Math.round(r) };
      const innen = Math.round((r - band / 2 - 10) * 2);
      Object.assign(kreisInhalt.style, { width: innen + 'px', height: innen + 'px', left: Math.round(cx - innen / 2) + 'px', top: Math.round(cy - innen / 2) + 'px' });
      kreisInhalt.classList.toggle('klein', innen < 200);
    }
    zeigeModus(false);
    zeichnen(p);
  }

  function zeichnen(q) {
    if (!geo) return;
    const frac = q - Math.floor(q);
    if (modus === 'welle') {
      geo.welle.tps.forEach((tp, i) => tp.setAttribute('startOffset', r1(-ABSTAND + i * ABSTAND + frac * ABSTAND) + '%'));
    } else if (modus === 'aufrichten') {
      const t = Math.min(Math.max(q, 0), 1);
      const c = 1 - (1 - KRUEMMUNG_ENDE) * t;
      geo.auf.pfad.setAttribute('d', sPfad(geo, c));
      const woche = 1 + Math.round(t * 5);
      $('[data-woche]').textContent = woche;
      $('[data-skala]').querySelectorAll('i').forEach((n, i) => n.classList.toggle('voll', i < woche));
    } else {
      geo.kreis.tp.setAttribute('startOffset', r1(((q * KREIS_DREHUNG) % 1) * geo.kreis.C));
    }
    const pz = document.querySelector('[data-p]');
    if (pz) pz.textContent = (modus === 'aufrichten' ? Math.min(q, 1) : frac).toFixed(2);
  }

  function zeigeWerte() {
    if (!geo) return;
    const still = reduziert();
    const fortschritt = still ? '' : `<span aria-hidden="true">p <b data-p>0.00</b></span>`;
    const kurve = still ? '<span>Reduziert: <b>Text steht still</b></span>' : '<span class="lang">Kurve <b>linear, scrollgebunden</b></span><span class="lang">Glättung <b>Lerp 0,1</b></span>';
    const teil = {
      welle: `<span>startOffset <b>−40 + i·40 + p·40 %</b></span><span class="lang">Kopien <b>${KOPIEN} × 40 %</b></span><span class="lang">Schrift <b>${geo.welle.fs} px</b></span>`,
      aufrichten: `<span>Krümmung <b>100 → 12 %</b></span><span class="lang">startOffset <b>50 %, mittig</b></span><span class="lang">Schrift <b>${geo.auf.fs} px</b></span>`,
      kreis: `<span>startOffset <b>+40 % Umfang</b></span><span class="lang">Ring <b>textLength = Umfang</b></span><span class="lang">Schrift <b>${geo.kreis.fs} px</b></span>`,
    }[modus];
    werte.innerHTML = teil + kurve + fortschritt;
  }

  function zeigeModus(neuerBlock = true) {
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
    svg.querySelectorAll('[data-gruppe]').forEach(g => g.classList.toggle('an', g.dataset.gruppe === modus));
    if (neuerBlock) document.querySelectorAll('[data-block]').forEach(b => b.classList.toggle('an', b.dataset.block === modus));
    kreisInhalt.classList.toggle('an', modus === 'kreis');
    kreisInhalt.inert = modus !== 'kreis';   // unsichtbar (opacity 0) = nicht fokussierbar
    zeigeWerte();
  }
  function setzeModus(neu) {
    modus = neu;
    zeigeModus(true);
    zeichnen(reduziert() ? stillWert() : p);
  }
  // Ruhebild bei reduzierter Bewegung
  const stillWert = () => (modus === 'aufrichten' ? 1 : modus === 'welle' ? 0.5 : 0);

  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b) return;
    stopVorfuehrung();
    setzeModus(b.dataset.modus);
  });

  // Scroll → Ziel-Fortschritt; der Lerp holt p in jedem Frame ein Stück heran
  function scrollZiel() {
    const r = strecke.getBoundingClientRect();
    const weg = r.height - innerHeight;
    return weg > 0 ? Math.min(Math.max(-r.top / weg, 0), 1) : 0;
  }
  addEventListener('scroll', () => { if (!vorf) ziel = scrollZiel(); }, { passive: true });
  ['wheel', 'touchstart', 'keydown'].forEach(t => addEventListener(t, () => { if (vorf && !html.classList.contains('vorschau')) { stopVorfuehrung(); ziel = scrollZiel(); } }, { passive: true }));

  function takt(jetzt) {
    requestAnimationFrame(takt);
    if (reduziert()) return;
    if (vorf && !vorfPause) { vorfuehrSchritt(jetzt); return; }
    if (vorf) return;
    if (Math.abs(ziel - p) < 0.0005) return;
    p += (ziel - p) * LERP;
    zeichnen(p);
  }

  // Vorführung: Fortschritt linear simulieren, alle drei Varianten nacheinander
  const PHASEN = [
    { modus: 'welle', dauer: 7000, p: t => t * 1.4 },
    { modus: 'aufrichten', dauer: 6000, p: t => Math.min(t / 0.7, 1) },
    { modus: 'kreis', dauer: 7000, p: t => t * 2.5 },
  ];
  const GESAMT = PHASEN.reduce((s, x) => s + x.dauer, 0);
  function vorfuehrSchritt(jetzt) {
    let t = (jetzt - vorfStart) % GESAMT, i = 0;
    while (t > PHASEN[i].dauer) { t -= PHASEN[i].dauer; i++; }
    const ph = PHASEN[i];
    if (modus !== ph.modus) setzeModus(ph.modus);
    p = ph.p(t / ph.dauer);
    zeichnen(p);
  }
  function vorfuehren() {
    stopVorfuehrung();
    if (reduziert()) { setzeModus('welle'); return; }
    vorf = true; vorfPause = false; vorfStart = performance.now();
    setzeModus('welle');
  }
  function stopVorfuehrung() { vorf = null; vorfPause = false; }
  function pause() { if (vorf && !vorfPause) { vorfPause = true; pausiertBei = performance.now(); } }
  function weiter() {
    if (vorf && vorfPause) { vorfStart += performance.now() - pausiertBei; vorfPause = false; }
    else if (!vorf && html.classList.contains('vorschau')) vorfuehren();
  }
  function neustart() {
    stopVorfuehrung();
    scrollTo(0, 0);
    p = ziel = 0;
    setzeModus('welle');
  }

  // Feldgröße beobachten: Fenster, Adressleiste und die Werte-Leiste (bricht je Variante anders um)
  let breiteAlt = 0, hoeheAlt = 0, rz = null;
  const beobachter = new ResizeObserver(() => {
    clearTimeout(rz);
    rz = setTimeout(() => {
      const w = feld.clientWidth, h = feld.clientHeight;
      if (w === breiteAlt && h === hoeheAlt) return;
      breiteAlt = w; hoeheAlt = h; aufbauen();
      if (reduziert()) zeichnen(stillWert());
    }, 60);
  });

  document.fonts.ready.then(() => {
    breiteAlt = feld.clientWidth; hoeheAlt = feld.clientHeight;
    ziel = p = reduziert() ? 0 : scrollZiel();
    aufbauen();
    if (reduziert()) zeichnen(stillWert());
    beobachter.observe(feld);
    requestAnimationFrame(takt);
  });

  DEMOS.basis = { neustart, vorfuehren, pause, weiter };
})();

/* ===================== Variante „reifen“: stehende Reifenflanke ===================== */
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const NS = 'http://www.w3.org/2000/svg';
  const flanke = $('[data-flanke]'), svg = $('[data-reifen-svg]'), nabe = $('[data-nabe]'), liste = $('[data-teile]');

  // Reifengröße 205/55 R16 91V und Herstellungsdatum, Teil für Teil
  const TEILE = [
    { id: 'breite', wert: '205', einheit: 'mm', name: 'Breite', erkl: 'Reifenbreite von Flanke zu Flanke in Millimetern.', kurz: '205 mm breit' },
    { id: 'querschnitt', wert: '55', einheit: '%', name: 'Querschnitt', erkl: 'Flankenhöhe in Prozent der Breite: 55 % von 205 mm sind rund 113 mm.', kurz: 'Flanke 113 mm' },
    { id: 'bauart', wert: 'R', einheit: '', name: 'Bauart', erkl: 'Radialreifen. So ist heute fast jeder Pkw-Reifen gebaut.', kurz: 'Radial' },
    { id: 'felge', wert: '16', einheit: 'Zoll', name: 'Felgendurchmesser', erkl: 'Passt auf eine 16-Zoll-Felge, das sind 406 mm.', kurz: '406 mm Felge' },
    { id: 'last', wert: '91', einheit: '', name: 'Lastindex', erkl: 'Tragfähigkeit je Reifen: 615 kg.', kurz: '615 kg je Reifen' },
    { id: 'tempo', wert: 'V', einheit: '', name: 'Geschwindigkeitsindex', erkl: 'Zugelassen bis 240 km/h.', kurz: 'bis 240 km/h' },
    { id: 'dot', wert: '2423', einheit: '', name: 'Herstellungsdatum (DOT)', erkl: 'Kalenderwoche 24 im Jahr 2023. Ab etwa sechs Jahren prüfen wir das Alter genauer.', kurz: 'KW 24 / 2023' },
  ];
  const nachId = Object.fromEntries(TEILE.map(t => [t.id, t]));

  /* Zeichnung: Profilblöcke auf der Lauffläche, fünf Doppelspeichen (Zeichnung, keine Marke) */
  const profil = svg.querySelector('[data-profil]'), speichen = svg.querySelector('[data-speichen]');
  for (let i = 0; i < 64; i++) {
    const r = document.createElementNS(NS, 'rect');
    const w = i % 2 ? 9 : 14;
    Object.entries({ x: 400 - w / 2, y: 6, width: w, height: i % 2 ? 22 : 15, rx: 2, transform: `rotate(${(i * 360 / 64).toFixed(2)} 400 400)` }).forEach(([k, v]) => r.setAttribute(k, v));
    profil.appendChild(r);
  }
  // Bremsscheibe mit Bohrungen hinter der Felge
  const loecher = svg.querySelector('[data-loecher]');
  for (let i = 0; i < 24; i++) {
    const c = document.createElementNS(NS, 'circle'), w = i * Math.PI / 12, r = i % 2 ? 150 : 162;
    c.setAttribute('cx', (400 + Math.cos(w) * r).toFixed(1)); c.setAttribute('cy', (400 + Math.sin(w) * r).toFixed(1)); c.setAttribute('r', 4);
    loecher.appendChild(c);
  }
  // fünf breite Speichen, zur Felge hin breiter, mit Mittelkante
  for (let i = 0; i < 5; i++) {
    const p = document.createElementNS(NS, 'path');
    p.setAttribute('d', 'M 380 300 L 366 182 Q 400 172 434 182 L 420 300 Q 400 296 380 300 Z');
    p.setAttribute('transform', `rotate(${i * 72 + 36} 400 400)`);
    speichen.appendChild(p);
  }

  /* Liste: echter Text, die einzige Fassung für Screenreader */
  liste.innerHTML = TEILE.map(t => `<li><button type="button" class="r-teil" data-teil-knopf="${t.id}" aria-pressed="false"><span class="r-wert">${t.wert}</span><span class="r-name">${t.name}${t.einheit ? ` <span class="r-einheit">(${t.einheit})</span>` : ''}</span><span class="r-erkl">${t.erkl}</span></button></li>`).join('');
  const knoepfe = [...liste.querySelectorAll('[data-teil-knopf]')];
  const bogenTeile = [...svg.querySelectorAll('[data-teil]')];

  let zeigt = null, fest = null;   // zeigt: Hover/Fokus; fest: per Klick gehalten
  function zeige() {
    const id = zeigt || fest, t = id && nachId[id];
    flanke.classList.toggle('hat-aktiv', !!t);
    bogenTeile.forEach(n => n.classList.toggle('an', n.dataset.teil === id));
    knoepfe.forEach(k => { k.classList.toggle('an', k.dataset.teilKnopf === id); k.setAttribute('aria-pressed', String(k.dataset.teilKnopf === fest)); });
    nabe.classList.toggle('leer', !t);
    $('[data-nabe-wert]').innerHTML = t ? `${t.wert}${t.einheit ? `<small>${t.einheit === 'Zoll' ? '″' : t.einheit}</small>` : ''}` : '205/55 R16';
    $('[data-nabe-name]').textContent = t ? t.name : 'Teil wählen';
    $('[data-nabe-kurz]').textContent = t ? t.kurz : '';
  }
  const setzeZeigt = id => { if (zeigt !== id) { zeigt = id; zeige(); } };

  liste.addEventListener('pointerover', e => { const k = e.target.closest('[data-teil-knopf]'); if (k && e.pointerType !== 'touch') { stopp(); setzeZeigt(k.dataset.teilKnopf); } });
  liste.addEventListener('pointerleave', () => setzeZeigt(null));
  liste.addEventListener('focusin', e => { const k = e.target.closest('[data-teil-knopf]'); if (k) { stopp(); setzeZeigt(k.dataset.teilKnopf); } });
  liste.addEventListener('focusout', e => { if (!liste.contains(e.relatedTarget)) setzeZeigt(null); });
  liste.addEventListener('click', e => { const k = e.target.closest('[data-teil-knopf]'); if (!k) return; stopp(); fest = fest === k.dataset.teilKnopf ? null : k.dataset.teilKnopf; zeige(); });
  // Auf der Flanke selbst: Zeigen hebt hervor, Tippen hält fest (Touch)
  svg.addEventListener('pointerover', e => { const n = e.target.closest('[data-teil]'); if (n && e.pointerType !== 'touch') { stopp(); setzeZeigt(n.dataset.teil); } });
  svg.addEventListener('pointerout', e => { if (e.target.closest('[data-teil]') && !e.relatedTarget?.closest?.('[data-teil]')) setzeZeigt(null); });
  svg.addEventListener('click', e => { const n = e.target.closest('[data-teil]'); if (!n) return; stopp(); fest = fest === n.dataset.teil ? null : n.dataset.teil; zeige(); });

  function werteZeigen() {
    $('[data-r-werte]').innerHTML = [
      ['Pfad', 'Kreisbogen r 312 von 9 bis 3 Uhr'],
      ['textPath', 'startOffset 50 %, text-anchor middle'],
      ['Schrift', 'Mona Sans 800, 80 %, 60/800'],
      ['Bewegung', 'keine, steht still'],
      ['Hervorheben', reduziert() ? 'sofort (reduziert)' : '120 ms cubic-bezier(0.16, 1, 0.3, 1)'],
    ].map(([a, b], i) => `<span${i > 2 ? '' : ' class="lang"'}>${a} <b>${b}</b></span>`).join('');
  }
  new MutationObserver(werteZeigen).observe(html, { attributes: true, attributeFilter: ['class'] });

  /* Vorführung: die sieben Teile nacheinander, je 1,1 s, dann 1,4 s die ganze Größe */
  let uhr = null, schritt = 0, angehalten = false;
  function stopp() { clearTimeout(uhr); uhr = null; }
  function takt() {
    if (angehalten) return;
    setzeZeigt(schritt < TEILE.length ? TEILE[schritt].id : null);
    const dauer = schritt < TEILE.length ? 1100 : 1400;
    schritt = (schritt + 1) % (TEILE.length + 1);
    uhr = setTimeout(takt, dauer);
  }
  function neustart() { stopp(); angehalten = false; schritt = 0; zeigt = fest = null; zeige(); werteZeigen(); scrollTo(0, 0); }
  function vorfuehren() { neustart(); takt(); }
  function pause() { angehalten = true; stopp(); }
  function weiter() { if (angehalten) { angehalten = false; takt(); } }
  zeige(); werteZeigen();
  DEMOS.reifen = { neustart, vorfuehren, pause, weiter };
})();

/* ===================== Variante wählen (Regel 13: ?v=, Umschalter schreibt die URL) ===================== */
const VARIANTEN = ['basis', 'reifen'];
let variante = (() => { const v = new URLSearchParams(location.search).get('v'); return VARIANTEN.includes(v) ? v : VARIANTEN[0]; })();
function zeigeVariante(v, schreiben) {
  if (v !== variante) DEMOS[variante].pause();
  variante = v;
  document.querySelectorAll('[data-welt]').forEach(w => { w.hidden = w.dataset.welt !== v; });
  document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
  if (schreiben) {
    const u = new URL(location.href);
    u.searchParams.set('v', v);   // andere Parameter (reduziert, vorschau) bleiben stehen
    history.replaceState(history.state, '', u);
    DEMOS[v].neustart();
  }
}
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => {
  if (b.dataset.variante === variante) return;
  const fokus = document.activeElement === b;
  zeigeVariante(b.dataset.variante, true);
  const ziel = document.querySelector(`[data-welt="${variante}"] [data-variante="${variante}"]`);
  if (ziel && fokus) ziel.focus({ preventScroll: true });   // Fokus bleibt am Umschalter der neuen Welt
}));
zeigeVariante(variante, false);

</script>
</body>
</html>