Zum Inhalt
← Schrift-Animationen

Marquee-Laufband (optional scroll-gekoppelt)

Riesiger Studioname läuft endlos als Band, ein gekippter Leistungsstreifen gegenläufig darunter; Scrollrichtung kehrt beide um, Scrolltempo beschleunigt und neigt sie.

Großer Text läuft endlos waagerecht, meist ein Name, Claim oder Leistungen mit Trennzeichen. Zwei identische Kopien stehen nebeneinander. Ist eine Kopie ganz hinausgelaufen (xPercent −100), springt die Spur unsichtbar auf 0 zurück. In der scroll-gekoppelten Form kehrt die Scrollrichtung die Laufrichtung um. Schnelles Scrollen beschleunigt das Band kurz, danach klingt es auf das Grundtempo ab. Optional neigt es sich mit skewX proportional zum Tempo. Häufig laufen zwei Bänder gegenläufig übereinander.

Das Band schafft Rhythmus und die Präsenz eines Plakats. Es trennt Sektionen wie ein Laufband. Mit Scroll-Kopplung reagiert die Seite spürbar auf den Nutzer, ohne Inhalte zu verzerren. Das Scrollen wird belohnt.

Wann einsetzen

Großer Name im Portfolio-Hero, Leistungs-Schlagworte, Kundenlogos, Footer-Statement („Lass uns reden“), Trenner zwischen Sektionen. Passt zu Event- und Kampagnenseiten und zu lauten Marken.

Wann nicht

Für wichtige Information, denn sie wird nicht vollständig gelesen. Bei ruhigen, luxuriösen Marken. Mehr als ein bis zwei Bänder pro Seite. Als reine Dekoration ohne Inhalt, das gilt 2026 als Template-Klischee.

Varianten

  • Konstantes CSS-Marquee (@keyframes translateX(0 → −50 %), linear)
  • Richtungsumkehr nach Scrollrichtung (timeScale ±1)
  • Tempo-Boost proportional zur Scroll-Velocity, mit Skew
  • Rein scroll-gekoppelt (x = scrollY × Faktor) ohne Grundlauf
  • Zwei oder mehr gegenläufige Bänder
  • Gegenläufige Zeilen ohne Loop (Text-Parallax)
  • Hover pausiert oder verlangsamt das Band
  • Logo-Marquee mit Hover-Pause (siehe logo-wand-social-proof)
  • Vertikaler Ticker
  • Rundes Marquee, Text im Kreis
  • Band entlang eines SVG-Pfads
  • Glühendes SVG-Text-Marquee
  • Marquee-Menü

Umsetzung

Inhalt so oft wiederholen, dass eine Kopie breiter ist als der Viewport. Diese Kopie einmal klonen (aria-hidden, inert). Breite erst nach dem Schriftladen messen (document.fonts.ready).

Konstant: Spur per CSS @keyframes translateX(0 → −50 %) linear infinite. Gegenband mit animation-direction: reverse.

Scroll-gekoppelt: pro Frame x = wrap(−50, 0, x + richtung × grund × (1 + boost) × dt) und als translate3d setzen. Velocity aus ScrollTrigger.getVelocity(), lenis.velocity oder nativen Scroll-Events. Boost = min(max, |v| × k), mit Lerp auf 0 abklingen lassen. Skew aus der geglätteten Velocity. Alternative mit GSAP: timeScale einer Endlos-Tween anpassen.

Fallstricke: Velocity-Spitzen kappen, sonst springt das Band bei Trackpad-Flicks. Ohne aria-hidden liest der Screenreader alles mehrfach. Gegen Subpixel-Ruckeln translate3d und eine eigene Ebene (will-change: transform). Außerhalb des Viewports pausieren (IntersectionObserver).

Robust umsetzen

Telefon. Funktioniert. Touch-Scroll liefert die Velocity über native Scroll-Events. Schriftgröße in vw oder fest groß wählen, damit das Band nicht zu klein wird. Velocity-Kopplung abschwächen.

Ohne Bewegung. Band anhalten und als statische Zeile zeigen, Leistungen dürfen umbrechen. Alternativ sehr langsam laufen lassen, dann mit Pause-Möglichkeit.

Barrierefrei. Nur eine Kopie ist für Screenreader sichtbar, Klone sind aria-hidden und inert. Fokussierbare Elemente im Band vermeiden oder das Band bei Fokus anhalten. Nach WCAG 2.2.2 braucht Bewegung, die länger als 5 s läuft, eine Pause-Möglichkeit. Inhalte dürfen nicht nur im Band stehen.

Code

<!DOCTYPE html>
<!--
  Marquee-Laufband (optional scroll-gekoppelt)
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/marquee-laufband/
  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>Marquee-Laufband · 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: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; 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 {
  --grund: #eeebe3; --tinte: #141311; --leise: rgba(20, 19, 17, .64); --linie: rgba(20, 19, 17, .14);
  --akzent: #ff5a1f;
  --kante: 7.5%;          /* Kantenmaske, gemessen bei Lando Norris */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }

/* Bühne: klebt, solange man durch die Strecke scrollt, damit die Bänder sichtbar reagieren */
.strecke { height: 210svh; }
.buehne { position: sticky; top: 0; height: 100svh; min-height: 560px; display: grid; grid-template-rows: auto 1fr auto; grid-template-columns: minmax(0, 1fr); padding: clamp(18px, 3.4vw, 44px); gap: 16px; overflow: hidden; }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px 20px; flex-wrap: wrap; position: relative; z-index: 2; }
.marke { display: flex; gap: 10px; align-items: center; }
.marke i { width: 9px; height: 9px; border-radius: 50%; background: var(--akzent); }
.steuer { display: flex; gap: 4px; flex-wrap: wrap; }
.steuer 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, background .15s, border-color .15s; }
.steuer button:hover { color: var(--tinte); border-color: rgba(20, 19, 17, .32); }
.steuer button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.steuer .halt { margin-left: 8px; }
.steuer .halt[aria-pressed='true'] { background: var(--akzent); border-color: var(--akzent); color: var(--tinte); }

.baender { align-self: center; display: grid; gap: clamp(8px, 2vw, 22px); margin-inline: calc(-1 * clamp(18px, 3.4vw, 44px)); }

/* Band: Maske an den Kanten, Spur mit zwei identischen Kopien */
.band { overflow: hidden; -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--kante), #000 calc(100% - var(--kante)), transparent); mask-image: linear-gradient(90deg, transparent, #000 var(--kante), #000 calc(100% - var(--kante)), transparent); }
.spur { display: flex; width: max-content; will-change: transform; backface-visibility: hidden; }
.kopie { display: flex; align-items: center; flex: none; }
.satz { display: flex; align-items: center; flex: none; }

/* Band 1: der Name, plakativ */
.name { font-size: clamp(84px, 17.5vw, 300px); font-weight: 600; line-height: .86; letter-spacing: -.055em; padding-block: .06em .1em; }
.name .satz > * { margin-right: .16em; }
.name em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.02em; }
.name .bild { width: 1.32em; height: .66em; border-radius: 999px; object-fit: cover; display: block; transform: translateY(.04em); outline: 1px solid var(--linie); outline-offset: -1px; }
.name .stern { width: .5em; height: .5em; color: var(--akzent); flex: none; }

/* Band 2: Leistungen auf Signalstreifen, gegenläufig und leicht gekippt */
.streifen { background: var(--akzent); transform: rotate(-2.2deg); margin-inline: -4vw; padding-block: clamp(10px, 1.3vw, 18px); }
.leistung { font-size: clamp(19px, 2.7vw, 40px); font-weight: 500; letter-spacing: -.02em; line-height: 1; white-space: nowrap; }
.leistung .satz > * { margin-right: .7em; }
.leistung em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; font-size: 1.12em; }
.leistung .punkt { width: .32em; height: .32em; border-radius: 50%; background: var(--tinte); flex: none; }

/* Variante „Konstant“: reines CSS, Spur läuft um eine Kopie (−50 %) */
@keyframes lauf { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-50%, 0, 0); } }
.css-modus .spur { animation: lauf var(--dauer, 30s) linear infinite; }
.css-modus .gegen .spur { animation-direction: reverse; }
.css-modus .band:hover .spur, .css-modus.angehalten .spur, .css-modus .unsichtbar .spur { animation-play-state: paused; }

.fuss { display: flex; justify-content: space-between; align-items: end; gap: 16px 40px; flex-wrap: wrap; position: relative; z-index: 2; }
.text { font-size: clamp(15px, 1.35vw, 18px); line-height: 1.45; color: var(--leise); max-width: 34ch; }
.text b { color: var(--tinte); font-weight: 500; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; max-width: 640px; justify-content: flex-end; }
.werte b { color: var(--tinte); font-weight: 500; }
.tempo { display: inline-flex; align-items: center; gap: 8px; }
.tempo span { display: block; width: 64px; height: 3px; background: var(--linie); border-radius: 2px; overflow: hidden; }
.tempo span::after { content: ''; display: block; height: 100%; width: 100%; background: var(--akzent); transform-origin: left; transform: scaleX(var(--v, 0)); }

/* Nach der Bühne: Inhalt, der den Text des Bands noch einmal lesbar trägt */
.weiter { padding: clamp(72px, 14vh, 160px) clamp(18px, 3.4vw, 44px) 18vh; border-top: 1px solid var(--linie); display: grid; grid-template-columns: repeat(12, 1fr); gap: 40px 24px; }
.weiter h2 { grid-column: 1 / span 7; font-size: clamp(32px, 4.6vw, 72px); font-weight: 500; line-height: 1.02; letter-spacing: -.035em; }
.weiter h2 em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; }
.weiter .liste { grid-column: 9 / span 4; align-self: end; display: grid; gap: 10px; list-style: none; padding: 0; font-size: 17px; }
.weiter .liste li { border-top: 1px solid var(--linie); padding-top: 10px; display: flex; justify-content: space-between; }
.weiter figure { grid-column: span 6; }
.weiter figure img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; border-radius: 4px; }
.weiter figcaption { margin-top: 10px; }

/* Reduzierte Bewegung: Band steht, Name einmal, Leistungen dürfen umbrechen */
.reduziert .spur { animation: none !important; transform: none !important; width: auto; }
.reduziert .kopie[aria-hidden] { display: none; }
.reduziert .leistung .kopie { flex-wrap: wrap; justify-content: center; row-gap: .5em; padding-inline: 6vw; flex: 1 1 auto; min-width: 0; }
.reduziert .leistung .satz { flex-wrap: wrap; justify-content: center; row-gap: .5em; flex: 1 1 auto; min-width: 0; }
.reduziert .leistung .satz ~ .satz { display: none; }
.reduziert .name .satz ~ .satz { display: none; }
.reduziert .band { -webkit-mask-image: none; mask-image: none; }
.reduziert .name .kopie { padding-left: clamp(18px, 3.4vw, 44px); }
.reduziert .halt, .reduziert .modi, .reduziert .hinweis { display: none; }

@media (max-width: 720px) {
  .weiter h2, .weiter .liste, .weiter figure { grid-column: 1 / -1; }
  .werte { justify-content: flex-start; }
  .steuer .halt { margin-left: 0; }
  .name { font-size: 112px; }
  .reduziert .name { font-size: min(112px, 21vw); }   /* stehendes Band: Name passt ganz in die Breite */
  .baender { gap: 14px; }
}
@media (hover: none) { .steuer button:hover { color: var(--leise); border-color: var(--linie); } }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .strecke { height: auto; }
.vorschau .weiter, .vorschau .hinweis { display: none; }
</style>
</head>
<body>
<div class="strecke">
<section class="buehne">
  <div class="kopf">
    <p class="mono marke"><i></i>Studio Feldmark · Grafik &amp; Raum · Leipzig</p>
    <div class="steuer mono">
      <div class="steuer modi" role="group" aria-label="Variante">
        <button type="button" data-variante="konstant" aria-pressed="false">Konstant</button>
        <button type="button" data-variante="richtung" aria-pressed="false">Richtung</button>
        <button type="button" data-variante="tempo" aria-pressed="true">Tempo + Skew</button>
      </div>
      <button type="button" class="halt" data-halt aria-pressed="false">Anhalten</button>
    </div>
  </div>

  <div class="baender">
    <h1 class="band name" data-band data-dauer="18" data-richtung="-1" aria-label="Studio Feldmark">
      <div class="spur">
        <div class="kopie">
          <div class="satz">
            <span>Feldmark</span>
            <img class="bild" src="bilder/typo-plakat.jpg" alt="" width="1200" height="800">
            <em>Studio</em>
            <svg class="stern" viewBox="0 0 40 40" aria-hidden="true"><path fill="currentColor" d="M17.6 0h4.8l-.9 14.8 12.4-8.2 2.4 4.2L23 17.6 36.3 24l-2.4 4.2-12.4-8.2.9 14.8h-4.8l.9-14.8-12.4 8.2L3.7 24 17 17.6 3.7 10.8l2.4-4.2 12.4 8.2z"/></svg>
            <span>Feldmark</span>
            <img class="bild" src="bilder/beton-treppe.jpg" alt="" width="1200" height="800">
            <em>Grafik&nbsp;&amp;&nbsp;Raum</em>
            <svg class="stern" viewBox="0 0 40 40" aria-hidden="true"><path fill="currentColor" d="M17.6 0h4.8l-.9 14.8 12.4-8.2 2.4 4.2L23 17.6 36.3 24l-2.4 4.2-12.4-8.2.9 14.8h-4.8l.9-14.8-12.4 8.2L3.7 24 17 17.6 3.7 10.8l2.4-4.2 12.4 8.2z"/></svg>
          </div>
        </div>
      </div>
    </h1>
    <div class="streifen">
      <p class="band leistung gegen" data-band data-dauer="10" data-richtung="1">
        <span class="spur">
          <span class="kopie">
            <span class="satz">
              <span>Erscheinungsbild</span><i class="punkt"></i>
              <span>Ausstellungen</span><i class="punkt"></i>
              <em>Leitsysteme</em><i class="punkt"></i>
              <span>Editorial</span><i class="punkt"></i>
              <span>Verpackung</span><i class="punkt"></i>
              <em>Schrift</em><i class="punkt"></i>
            </span>
          </span>
        </span>
      </p>
    </div>
  </div>

  <div class="fuss">
    <p class="text">Seit 2014 gestalten wir Marken, Räume und Bücher, <b>die man auch im Vorbeigehen erkennt.</b> <span class="hinweis">Scrollen Sie: Das Band folgt Richtung und Tempo.</span></p>
    <p class="werte mono" data-werte aria-live="off"></p>
  </div>
</section>
</div>

<section class="weiter">
  <h2>Ein Büro für Grafik und Raum, <em>von der Wortmarke</em> bis zum Leitsystem.</h2>
  <ul class="liste">
    <li><span>Erscheinungsbild</span><span class="mono">01</span></li>
    <li><span>Ausstellungen</span><span class="mono">02</span></li>
    <li><span>Leitsysteme</span><span class="mono">03</span></li>
    <li><span>Editorial &amp; Schrift</span><span class="mono">04</span></li>
  </ul>
  <figure><img src="bilder/marke-eselsohr.jpg" alt="Wortbildmarke Eselsohr, Buchhandlung und Lesecafé: Blatt mit rosa Eselsohr neben kursiver Schrift auf Anthrazit" width="1200" height="800" loading="lazy"><figcaption class="mono">Eselsohr · Erscheinungsbild, 2025</figcaption></figure>
  <figure><img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin Gefüge: Keramik-Stillleben auf Orange und die Überschrift Die Ruhe der Dinge" width="1200" height="800" loading="lazy"><figcaption class="mono">Gefüge · Editorial, 2026</figcaption></figure>
</section>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const fein = matchMedia('(hover: none)').matches;   // Touch: Kopplung abgeschwächt

  // Alle Werte an einer Stelle. Kurve des Laufs: linear (gemessen bei Dennis Snellenberg und Lando Norris).
  const W = {
    css_dauer_s: 30,        // Konstant: 30 s pro Inhaltssatz (Lando Norris)
    dauer_s: { name: 18, leistung: 10 },   // JS-Varianten: 18 s / 10 s pro Inhaltssatz (Dennis Snellenberg)
    richtung_tau_s: 0.18,   // Richtungswechsel: timeScale ±1, geglättet
    k: fein ? 0.0024 : 0.004,    // Tempo-Boost pro px/s Scrolltempo
    boost_max: 6,           // höchstens Grundtempo × (1 + 6)
    v_max: 3000,            // Velocity-Spitzen kappen (Trackpad-Flicks)
    v_tau_s: 0.12,          // gemessenes Scrolltempo fällt ohne neue Events ab
    boost_tau_s: 0.3,       // Boost klingt auf Grundtempo ab
    skew_faktor: fein ? 0.0025 : 0.004,   // Grad pro px/s
    skew_max: 10,
    skew_tau_s: 0.2,
    hover_faktor: 0.25,     // Zeiger über dem Band: Tempo auf 25 %
    hover_tau_s: 0.35,
  };

  const baender = [...document.querySelectorAll('[data-band]')].map(el => ({
    el, spur: el.querySelector('.spur'), dauer: +el.dataset.dauer, richtung: +el.dataset.richtung,
    x: 0, saetze: 1, hover: 0, hoverZiel: 0, sichtbar: true,
  }));

  // Inhalt so oft wiederholen, dass eine Kopie breiter ist als der Viewport, dann klonen (aria-hidden, inert)
  function fuellen() {
    baender.forEach(b => {
      const kopie = b.spur.querySelector('.kopie');
      b.spur.querySelectorAll('.kopie[aria-hidden]').forEach(k => k.remove());
      const saetze = kopie.querySelectorAll('.satz');
      saetze.forEach((s, i) => { if (i) s.remove(); });
      const satz = kopie.querySelector('.satz');
      const breite = satz.getBoundingClientRect().width || 1;
      const noetig = Math.max(1, Math.ceil((innerWidth * 1.1) / breite));
      for (let i = 1; i < noetig; i++) { const c = satz.cloneNode(true); c.setAttribute('aria-hidden', 'true'); kopie.appendChild(c); }
      const klon = kopie.cloneNode(true);
      klon.setAttribute('aria-hidden', 'true'); klon.inert = true;
      b.spur.appendChild(klon);
      b.saetze = noetig;
      b.el.style.setProperty('--dauer', (W.css_dauer_s * noetig) + 's');
    });
  }

  // Scrolltempo aus nativen Scroll-Events (funktioniert mit Rad, Trackpad und Touch)
  let v = 0, vZeit = performance.now(), vY = scrollY, richtung = 1;
  addEventListener('scroll', () => {
    const t = performance.now(), dy = scrollY - vY, dt = Math.max(8, t - vZeit);
    if (dy) richtung = Math.sign(dy);
    v = Math.max(-W.v_max, Math.min(W.v_max, dy / dt * 1000));
    vY = scrollY; vZeit = t;
  }, { passive: true });

  // Zeiger über dem Band: verlangsamen
  baender.forEach(b => {
    b.el.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') b.hoverZiel = 1; });
    b.el.addEventListener('pointerleave', () => { b.hoverZiel = 0; });
  });
  // Außerhalb des Viewports nicht rechnen
  const io = new IntersectionObserver(es => es.forEach(e => {
    const b = baender.find(x => x.el === e.target); b.sichtbar = e.isIntersecting;
    e.target.classList.toggle('unsichtbar', !e.isIntersecting);
  }));
  baender.forEach(b => io.observe(b.el));

  let variante = 'tempo', angehalten = false;
  let ts = 1, boost = 0, sv = 0, skew = 0, letzte = performance.now(), raf = 0;
  let sim = null;   // simuliertes Scrolltempo in der Vorführung
  const glaette = (dt, tau) => 1 - Math.exp(-dt / tau);
  const wrap = x => ((x % 50) - 50) % 50;   // −50 … 0 (Prozent der Spur = eine Kopie)
  const werteEl = document.querySelector('[data-werte]');
  const zahl = n => Math.round(n).toLocaleString('de-DE');

  function bild(t) {
    raf = requestAnimationFrame(bild);
    const dt = Math.min(0.05, (t - letzte) / 1000); letzte = t;
    if (reduziert() || variante === 'konstant') return;

    // Tempo: echtes Scrollen oder simulierter Zeiger
    if (sim) { const s = sim(t); v = s; if (s) richtung = Math.sign(s); }
    else v *= Math.exp(-dt / W.v_tau_s);
    const tempo = Math.abs(v) < 1 ? 0 : v;

    ts += (richtung - ts) * glaette(dt, W.richtung_tau_s);
    if (variante === 'tempo') {
      boost += (Math.min(W.boost_max, Math.abs(tempo) * W.k) - boost) * glaette(dt, W.boost_tau_s);
      sv += (tempo - sv) * glaette(dt, W.skew_tau_s);
      skew = Math.max(-W.skew_max, Math.min(W.skew_max, sv * W.skew_faktor));
    } else { boost = 0; sv = 0; skew = 0; }

    baender.forEach(b => {
      b.hover += (b.hoverZiel - b.hover) * glaette(dt, W.hover_tau_s);
      if (!b.sichtbar) return;
      if (!angehalten) {
        const grund = 50 / (b.dauer * b.saetze);     // % der Spur pro Sekunde
        const faktor = (1 + boost) * (1 - b.hover * (1 - W.hover_faktor));
        b.x = wrap(b.x + grund * b.richtung * ts * faktor * dt);
      }
      const s = b.el.classList.contains('gegen') ? -skew : skew;
      b.spur.style.transform = `translate3d(${b.x.toFixed(3)}%, 0, 0)` + (skew ? ` skewX(${(-s).toFixed(2)}deg)` : '');
    });
    werteLive();
  }

  let tempoEl = null;
  function zeigeWerte() {
    if (reduziert()) { werteEl.innerHTML = '<span>Reduzierte Bewegung: <b>Band steht, Leistungen umbrechend</b></span>'; tempoEl = null; return; }
    const kopf = `<span>Kurve <b>linear</b></span><span>Kante <b>mask ${'7,5 %'}</b></span>`;
    const teile = {
      konstant: `${kopf}<span>Lauf <b>@keyframes −50 %</b></span><span>Dauer <b>30 s / Satz</b></span><span>Hover <b>pausiert</b></span>`,
      richtung: `${kopf}<span>Dauer <b>18 s · 10 s / Satz</b></span><span>Scrollrichtung <b>timeScale ±1</b></span><span>Hover <b>25 %</b></span>`,
      tempo: `${kopf}<span>Dauer <b>18 s · 10 s</b></span><span>Boost <b>|v| × ${String(W.k).replace('.', ',')} · max +6×</b></span><span>Skew <b>max ±10°</b></span><span class="tempo">v <b data-v>0 px/s</b><span></span></span>`,
    };
    werteEl.innerHTML = teile[variante];
    tempoEl = werteEl.querySelector('[data-v]');
  }
  let zuletzt = 0;
  function werteLive() {
    if (!tempoEl) return;
    const n = performance.now(); if (n - zuletzt < 90) return; zuletzt = n;
    tempoEl.textContent = (Math.abs(sv) < 5 ? '0' : (sv > 0 ? '↓ ' : '↑ ') + zahl(Math.abs(sv))) + ' px/s';
    tempoEl.parentElement.style.setProperty('--v', Math.min(1, Math.abs(sv) / W.v_max).toFixed(3));
  }

  function setzeVariante(neu) {
    variante = neu;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === neu)));
    html.classList.toggle('css-modus', neu === 'konstant');
    baender.forEach(b => { b.spur.style.transform = neu === 'konstant' ? '' : b.spur.style.transform; });
    if (neu !== 'konstant') { ts = richtung; boost = 0; sv = 0; skew = 0; }
    zeigeWerte();
  }
  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-variante]'); if (!b) return;
    stoppVorfuehrung(); setzeVariante(b.dataset.variante);
  });
  const haltKnopf = document.querySelector('[data-halt]');
  function setzeHalt(an) {
    angehalten = an;
    html.classList.toggle('angehalten', an);
    haltKnopf.setAttribute('aria-pressed', String(an));
    haltKnopf.textContent = an ? 'Weiter' : 'Anhalten';
  }
  haltKnopf.addEventListener('click', () => { stoppVorfuehrung(); setzeHalt(!angehalten); });

  // Vorführung: gedachter Nutzer scrollt kräftig nach unten, wartet, scrollt zurück
  const ZYKLUS = 4400;
  function profil(t0) {
    return t => {
      const p = (t - t0) % ZYKLUS;
      const welle = (start, dauer, spitze) => (p >= start && p < start + dauer) ? Math.sin(Math.PI * (p - start) / dauer) * spitze : 0;
      return welle(0, 1600, 2400) + welle(2200, 1600, -2400);
    };
  }
  function vorfuehren() {
    if (reduziert()) { zeigeWerte(); return; }
    setzeHalt(false);
    if (variante !== 'tempo') setzeVariante('tempo');
    sim = profil(performance.now());
  }
  function stoppVorfuehrung() { if (sim) { sim = null; v = 0; } }
  function neustart() {
    stoppVorfuehrung(); scrollTo(0, 0);
    baender.forEach(b => { b.x = 0; b.hover = 0; });
    richtung = 1; ts = 1; boost = 0; sv = 0; skew = 0;
    setzeHalt(false); setzeVariante(variante);
  }
  function pause() { stoppVorfuehrung(); setzeHalt(true); }
  function weiter() { setzeHalt(false); if (html.classList.contains('vorschau')) vorfuehren(); }

  document.fonts.ready.then(() => {   // Breite erst nach dem Schriftladen messen
    fuellen();
    setzeVariante(variante);
    raf = requestAnimationFrame(bild);
  });
  let rz = 0, letzteBreite = innerWidth;
  addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(() => { if (innerWidth !== letzteBreite) { letzteBreite = innerWidth; fuellen(); } }, 150); });

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