Zum Inhalt
← Animationsstile

Animationsstil „Physik träge“ (Lerp, Trägheit, Geschwindigkeit)

Ein Arbeitsstreifen mit Masse: Er folgt Zeiger, Rad und Finger per Lerp, Feder oder quickTo, neigt und spreizt sich mit dem Tempo und gleitet nach dem Wurf aus.

Bewegung ohne feste Tweens. Ein Ist-Wert verfolgt in jedem Frame ein Ziel: per Lerp um einen festen Anteil oder gezogen von einer Feder. Die gemessene Geschwindigkeit steuert sekundäre Effekte wie Neigung, Spreizung, Verzerrung oder timeScale. Nach dem Loslassen gleitet alles mit Trägheit aus. Das ist die Grundsprache fast aller Award-Galerien 2026.

Die Seite reagiert auf die Energie der Eingabe. Schnelles Ziehen erzeugt mehr Schwung als langsames. Dadurch fühlt sich Bewegung verbunden und lebendig an, nicht abgespielt.

Wann einsetzen

Drag-Slider, unendliche Leinwände, 3D-Karussells, Smooth Scroll, Cursor-Follower und alle Effekte, die auf Tempo reagieren sollen.

Wann nicht

Für diskrete Zustandswechsel wie Menü auf und zu. Auf Seiten, die vor allem gelesen werden, stört Smooth Scroll eher.

Varianten

  • Lerp, zeitkorrigiert (Lenis-Standard 0,1; beobachtet 0,1 bis 0,165)
  • gsap.quickTo mit power3.out und 0,6 s
  • Feder mit stiffness 120 und damping 14 (schwingt leicht über)
  • InertiaPlugin mit Einrasten
  • Geschwindigkeit als timeScale mit Rückkehr in 1,2 s (Observer)

Umsetzung

Zeitkorrigierter Lerp: current += (target − current) × (1 − Math.pow(1 − f, dt × 60)), f = 0,1 bis 0,165, dt höchstens 1/30 s. Geschwindigkeit = (current − last) / dt, geglättet. Sekundäre Effekte daraus ableiten, etwa skewX = clamp(−10, 10, v × 0,002) und Spreizung = min(180, |v| × 0,05). Ein einziger RAF-Loop (oder gsap.ticker) für alles. Ruhezustand erkennen und den Loop anhalten. Drag erst ab 4 px starten, damit Klicks funktionieren.

Robust umsetzen

Telefon. Native Touch-Trägheit respektieren. Beim Wischen folgt der Inhalt dem Finger direkt, träge ist erst das Ausgleiten. Smooth Scroll auf Touch meist aus (syncTouch nur gezielt).

Ohne Bewegung. Lerp-Faktor 1, also sofort. Kein Wurf, keine sekundären Effekte, kein Smooth Scroll.

Barrierefrei. Kein Scroll-Hijacking: Rad nur über dem Element abfangen, die Seite bleibt normal scrollbar. Tastatur (Pfeiltasten) und Fokus müssen sofort funktionieren. Bilder im Streifen brauchen Alternativtexte, die Region einen Namen.

Code

<!DOCTYPE html>
<!--
  Animationsstil „Physik träge“ (Lerp, Trägheit, Geschwindigkeit)
  Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-physik-traege/
  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>Animationsstil „Physik träge“ · 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: '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; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }

:root {
  --papier: #111311; --tinte: #ecebe4; --leise: rgba(236, 235, 228, .52); --linie: rgba(236, 235, 228, .14);
  --akzent: #c8f03c;                        /* Ist-Wert im Diagramm, Greifer */
  --ui: cubic-bezier(0.25, 1, 0.5, 1);      /* power3.out, nur für UI-Zustände 150 ms */
  --karte: clamp(260px, min(34vw, calc((100svh - 380px) * 1.5)), 540px);
  --luecke: clamp(14px, 1.6vw, 24px);
  --rand: clamp(20px, 4vw, 56px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr auto; overflow-x: hidden; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }

/* Kopf */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 22px var(--rand) 0; flex-wrap: wrap; }
.kopf .marke { color: var(--tinte); }
.modus { display: flex; border: 1px solid var(--linie); border-radius: 999px; padding: 3px; }
.modus input { position: absolute; opacity: 0; pointer-events: none; }
.modus label { font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--leise); padding: 8px 12px; border-radius: 999px; cursor: pointer; transition: background-color .15s var(--ui), color .15s var(--ui); white-space: nowrap; }
.modus input:checked + label { background: var(--akzent); color: #111311; }
.modus input:focus-visible + label { outline: 2px solid var(--akzent); outline-offset: 2px; }

.titel { padding: clamp(28px, 6vh, 64px) var(--rand) clamp(20px, 4vh, 40px); display: flex; justify-content: space-between; align-items: end; gap: 24px; }
.titel h1 { font-weight: 500; font-size: clamp(38px, 6.6vw, 104px); line-height: .92; letter-spacing: -.05em; max-width: 9em; }
.titel h1 em { font: italic 400 1.06em/1 'Instrument Serif', Georgia, serif; letter-spacing: -.02em; color: var(--akzent); }
.titel p { max-width: 24em; font-size: 15px; line-height: 1.45; color: var(--leise); }

/* Bühne: ein endloser Streifen, den Lerp/Feder/quickTo bewegen */
.buehne { position: relative; overflow: hidden; touch-action: pan-y; cursor: grab; user-select: none; -webkit-user-select: none; outline: none; height: calc(var(--karte) * 2 / 3 + 58px); align-self: center; }
.buehne.zieht { cursor: grabbing; }
.buehne:focus-visible { box-shadow: inset 0 0 0 2px var(--akzent); }
.karte { position: absolute; left: 0; top: 0; width: var(--karte); will-change: transform; }
.karte .rahmen { aspect-ratio: 3 / 2; overflow: hidden; border-radius: 2px; background: #1d201c; }
.karte img { display: block; width: 100%; height: 100%; object-fit: cover; scale: 1.16; pointer-events: none; -webkit-user-drag: none; will-change: transform; }
.karte figcaption { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; padding-top: 12px; }
.karte .name { font-size: 17px; font-weight: 500; letter-spacing: -.02em; }
.karte .name .mono { margin-right: 8px; }

/* Fuß: gemessene Werte und Verlauf Ziel/Ist */
.fuss { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 24px; padding: clamp(18px, 3vh, 32px) var(--rand) 22px; }
.werte { display: grid; grid-template-columns: repeat(5, auto); justify-content: start; gap: 6px 28px; }
.werte dt { font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.werte dd { font: 500 13px/1 'Geist Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; color: var(--tinte); min-width: 9ch; }
.verlauf { display: grid; gap: 8px; justify-items: end; }
.verlauf canvas { width: 260px; height: 64px; display: block; border-bottom: 1px solid var(--linie); }
.legende { display: flex; gap: 14px; }
.legende span::before { content: ''; display: inline-block; width: 14px; height: 0; border-top: 1.5px dashed var(--tinte); vertical-align: middle; margin-right: 6px; opacity: .6; }
.legende span + span::before { border-top: 2px solid var(--akzent); opacity: 1; }

/* Folgender Greifer: Lerp 0,165 (Osmo) hinter dem Zeiger */
.greifer { position: fixed; left: 0; top: 0; z-index: 5; width: 86px; height: 86px; margin: -43px 0 0 -43px; border-radius: 50%; background: var(--akzent); color: #111311; display: grid; place-items: center; pointer-events: none; opacity: 0; scale: .4; transition: opacity .15s var(--ui), scale .15s var(--ui); }
.greifer span { font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.greifer.an { opacity: 1; scale: 1; }
.greifer.druck { scale: .78; }
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* Vorschau: nur Bühne, Modus und Verlauf */
.vorschau body { overflow: hidden; height: 100svh; grid-template-rows: auto 1fr auto; }
.vorschau .titel, .vorschau .kopf .hinweis { display: none; }
:root.vorschau { --karte: 470px; }
.vorschau .werte { grid-template-columns: repeat(3, auto); }
.vorschau .werte .neben { display: none; }

/* Touch: kein Greifer, Wischen folgt dem Finger direkt, erst das Ausgleiten ist träge */
@media (hover: none) {
  .greifer { display: none; }
  .buehne { cursor: auto; }
}
@media (max-width: 720px) {
  .titel { display: block; }
  .titel p { margin-top: 14px; }
  .fuss { grid-template-columns: 1fr; }
  .verlauf { justify-items: start; }
  .verlauf canvas { width: 100%; }
  .werte { grid-template-columns: repeat(3, auto); gap: 8px 22px; }
  .werte .neben { display: none; }
  .kopf .hinweis { display: none; }
  :root { --karte: 80vw; }
}
@media (max-width: 420px) { .modus label { padding: 8px 9px; letter-spacing: .08em; } }
@media (max-width: 360px) { .modus label { padding: 8px 6px; letter-spacing: .04em; } }   /* 320 px ohne Querscrollen */

/* Reduzierte Bewegung: Faktor 1, keine Neigung, kein Greifer */
.reduziert .greifer { display: none; }
.reduziert .karte img { transform: none !important; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
  <p class="mono"><span class="marke">Büro Lotlinie</span> · Hamburg · Archiv 2018–2024</p>
  <p class="mono hinweis">Ziehen · Werfen · Scrollen</p>
  <fieldset class="modus" aria-label="Bewegungsmodell">
    <input type="radio" name="modus" id="m-lerp" value="lerp" checked><label for="m-lerp">Lerp 0,1</label>
    <input type="radio" name="modus" id="m-feder" value="feder"><label for="m-feder">Feder 120/14</label>
    <input type="radio" name="modus" id="m-quickto" value="quickto"><label for="m-quickto">quickTo 0,6 s</label>
  </fieldset>
</header>

<section class="titel">
  <h1>Bewegung mit <em>Gewicht.</em></h1>
  <p>Acht Projekte aus dem Büro an der Elbe. Ziehen Sie den Streifen: Er folgt mit Verzögerung, holt auf und gleitet nach dem Loslassen aus.</p>
</section>

<div class="buehne" data-buehne tabindex="0" role="region" aria-roledescription="Karussell" aria-label="Arbeiten, mit Pfeiltasten blättern">
  <figure class="karte"><div class="rahmen"><img src="bilder/magazin-doppelseite.jpg" alt="Gefüge, Magazin-Doppelseite „Die Ruhe der Dinge“" draggable="false"></div><figcaption><span class="name"><span class="mono">01</span>Gefüge</span><span class="mono">Editorial</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/marke-roestkontor.jpg" alt="Röstkontor, Wortmarke in Creme auf Ziegelrot" draggable="false"></div><figcaption><span class="name"><span class="mono">02</span>Röstkontor</span><span class="mono">Marke</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/beton-treppe.jpg" alt="Haus Kaltenbach, schwebende Betontreppe im Streiflicht" draggable="false"></div><figcaption><span class="name"><span class="mono">03</span>Haus Kaltenbach</span><span class="mono">Architektur</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/marke-krume.jpg" alt="Krume, Wortmarke der Backstube auf Senfgelb" draggable="false"></div><figcaption><span class="name"><span class="mono">04</span>Krume</span><span class="mono">Logo</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/flakon.jpg" alt="Ambra Nord, Flakon auf Sockeln vor Kobaltblau" draggable="false"></div><figcaption><span class="name"><span class="mono">05</span>Ambra Nord</span><span class="mono">Kampagne</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/marke-eselsohr.jpg" alt="Eselsohr, Buchhandlung und Lesecafé, Marke auf Anthrazit" draggable="false"></div><figcaption><span class="name"><span class="mono">06</span>Eselsohr</span><span class="mono">Erscheinungsbild</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/typo-plakat.jpg" alt="Licht, Ausstellungsplakat in Neongelb und Orange" draggable="false"></div><figcaption><span class="name"><span class="mono">07</span>Licht</span><span class="mono">Plakat</span></figcaption></figure>
  <figure class="karte"><div class="rahmen"><img src="bilder/stoff-stapel.jpg" alt="Leinwerk, gefaltete Tücher in Rosé und Senf" draggable="false"></div><figcaption><span class="name"><span class="mono">08</span>Leinwerk</span><span class="mono">Web</span></figcaption></figure>
</div>

<footer class="fuss">
  <dl class="werte" aria-live="off">
    <div><dt>Modell</dt><dd data-w="modell">lerp 0.1</dd></div>
    <div><dt>Tempo</dt><dd data-w="v">0 px/s</dd></div>
    <div><dt>Neigung</dt><dd data-w="neigung">0.0°</dd></div>
    <div class="neben"><dt>Spreizung</dt><dd data-w="spreizung">0 px</dd></div>
    <div class="neben"><dt>Abstand</dt><dd data-w="abstand">0 px</dd></div>
  </dl>
  <div class="verlauf" aria-hidden="true">
    <canvas data-verlauf></canvas>
    <p class="mono legende"><span>Ziel</span><span>Ist</span></p>
  </div>
</footer>

<div class="greifer" data-greifer aria-hidden="true"><span>Werfen</span></div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#ecebe4" stroke="#111311" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const buehne = document.querySelector('[data-buehne]');
  const karten = [...buehne.querySelectorAll('.karte')];
  const bilder = karten.map(k => k.querySelector('img'));
  const greifer = document.querySelector('[data-greifer]');
  const zeiger = document.querySelector('[data-zeiger]');
  const leinwand = document.querySelector('[data-verlauf]');
  const w = Object.fromEntries([...document.querySelectorAll('[data-w]')].map(e => [e.dataset.w, e]));

  // Werte (Token). Quellen: Lenis lerp 0.1, Codrops-Karussell (quickTo 0,6 s power3, Feder 120/14,
  // Zeitschritt max. 1/30 s, Drag-Minimum 4 px), Osmo lerp 0.165 für den Greifer.
  const T = {
    lerp: 0.1,                 // Anteil pro Frame bei 60 fps, zeitkorrigiert
    feder: { steif: 120, daempf: 14 },
    quickto: 600,              // ms, power3.out
    greiferLerp: 0.165,
    dtMax: 1 / 30,
    dragMin: 4,
    wurf: 0.35,                // s Ausgleiten: Ziel += Wurftempo × 0,35
    wurfMax: 4000,             // px/s
    vGlaett: 0.2,              // Glättung der gemessenen Geschwindigkeit
    neigung: 0.002, neigungMax: 10,   // skewX = clamp(±10°, v × 0,002)
    spreizung: 0.05, spreizungMax: 180,
    bildVersatz: 0.006, bildMax: 24,
    rad: 1
  };
  const power3out = p => 1 - Math.pow(1 - p, 3);
  const klemm = (a, b, x) => Math.max(a, Math.min(b, x));

  let modus = 'lerp';
  let S = 0, G = 0, K = 0, breite = 0, gesamt = 0;            // Slot, Rand, Kartenbreite
  let ziel = 0, ist = 0, istV = 0, letzterIst = 0, v = 0;
  let tween = { von: 0, nach: 0, t0: 0 };
  let laeuft = false, letzte = 0;
  const gr = { x: -200, y: -200, zx: -200, zy: -200, an: false };
  const verlauf = [];                                          // [t, ziel, ist]

  function mass() {
    const r = karten[0].getBoundingClientRect();
    K = r.width;
    const messe = v => { const e = document.createElement('div'); e.style.cssText = `position:absolute;left:0;top:0;height:0;visibility:hidden;width:var(${v})`; document.body.appendChild(e); const b = e.getBoundingClientRect().width; e.remove(); return b; };
    const luecke = messe('--luecke'); G = messe('--rand');
    S = K + luecke; breite = buehne.clientWidth; gesamt = S * karten.length;
    const dpr = Math.min(2, devicePixelRatio || 1);
    leinwand.width = leinwand.clientWidth * dpr; leinwand.height = leinwand.clientHeight * dpr;
    malen(); zeichnen();
  }
  const einrasten = x => Math.round(x / S) * S;

  function malen() {
    const sk = reduziert() ? 0 : klemm(-T.neigungMax, T.neigungMax, v * T.neigung);
    const sp = reduziert() ? 0 : Math.min(T.spreizungMax, Math.abs(v) * T.spreizung);
    const bx = reduziert() ? 0 : klemm(-T.bildMax, T.bildMax, -v * T.bildVersatz);
    const mitte = breite / 2;
    karten.forEach((k, i) => {
      let x = (((i * S + ist + S) % gesamt) + gesamt) % gesamt - S + G;
      const d = klemm(-2.5, 2.5, (x + K / 2 - mitte) / S);
      x += d / 2.5 * sp;
      k.style.transform = `translate3d(${x.toFixed(2)}px,0,0) skewX(${sk.toFixed(3)}deg)`;
      bilder[i].style.transform = `translate3d(${bx.toFixed(2)}px,0,0)`;
    });
    w.v.textContent = `${Math.round(v)} px/s`;
    w.neigung.textContent = `${(Math.abs(sk) < 0.05 ? 0 : sk).toFixed(1)}°`;
    w.spreizung.textContent = `${Math.round(sp)} px`;
    w.abstand.textContent = `${Math.round(ziel - ist)} px`;
  }

  // Verlauf Ziel (gestrichelt) und Ist (Akzent) der letzten 3,5 s
  function zeichnen() {
    const c = leinwand.getContext('2d'), W = leinwand.width, H = leinwand.height;
    c.clearRect(0, 0, W, H);
    if (verlauf.length < 2) return;
    const jetzt = verlauf[verlauf.length - 1][0], fenster = 3500;
    let lo = Infinity, hi = -Infinity;
    for (const [, a, b] of verlauf) { lo = Math.min(lo, a, b); hi = Math.max(hi, a, b); }
    const spanne = Math.max(120, hi - lo), m = (hi + lo) / 2;
    const px = t => W - (jetzt - t) / fenster * W, py = y => H / 2 - (y - m) / spanne * (H * .8);
    const dpr = W / leinwand.clientWidth;
    const linie = (idx, farbe, breiteL, strich) => {
      c.beginPath(); c.setLineDash(strich.map(s => s * dpr)); c.lineWidth = breiteL * dpr; c.strokeStyle = farbe;
      verlauf.forEach((s, i) => i ? c.lineTo(px(s[0]), py(s[idx])) : c.moveTo(px(s[0]), py(s[idx])));
      c.stroke();
    };
    linie(1, 'rgba(236,235,228,.5)', 1.25, [3, 3]);
    linie(2, '#c8f03c', 2, []);
  }

  function schritt(t) {
    const dt = Math.min(T.dtMax, Math.max(0.001, (t - letzte) / 1000)); letzte = t;
    if (reduziert()) { ist = ziel; istV = 0; }
    else if (drag.an && drag.touch) { ist = ziel; istV = 0; tween = { von: ist, nach: ziel, t0: t }; }
    else if (modus === 'lerp') {
      ist += (ziel - ist) * (1 - Math.pow(1 - T.lerp, dt * 60));
    } else if (modus === 'feder') {
      const n = Math.ceil(dt / (1 / 240)), h = dt / n;
      for (let i = 0; i < n; i++) { istV += (T.feder.steif * (ziel - ist) - T.feder.daempf * istV) * h; ist += istV * h; }
    } else {
      if (tween.nach !== ziel) tween = { von: ist, nach: ziel, t0: t };
      const p = Math.min(1, (t - tween.t0) / T.quickto);
      ist = tween.von + (tween.nach - tween.von) * power3out(p);
    }
    const roh = (ist - letzterIst) / dt; letzterIst = ist;
    v += (roh - v) * (1 - Math.pow(1 - T.vGlaett, dt * 60));
    if (reduziert()) v = 0;

    // Greifer folgt dem Zeiger mit Lerp 0,165
    const kg = 1 - Math.pow(1 - T.greiferLerp, dt * 60);
    gr.x += (gr.zx - gr.x) * kg; gr.y += (gr.zy - gr.y) * kg;
    greifer.style.transform = `translate3d(${gr.x.toFixed(1)}px,${gr.y.toFixed(1)}px,0)`;

    verlauf.push([t, ziel, ist]);
    while (verlauf.length && t - verlauf[0][0] > 3600) verlauf.shift();
    malen(); zeichnen();

    const ruhig = !drag.an && Math.abs(ziel - ist) < 0.05 && Math.abs(v) < 0.5 && Math.abs(istV) < 0.5
      && Math.abs(gr.zx - gr.x) < 0.1 && Math.abs(gr.zy - gr.y) < 0.1;
    if (ruhig && !demo.an) { ist = ziel; v = 0; malen(); laeuft = false; return; }
    requestAnimationFrame(schritt);
  }
  function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(schritt); } }

  // Ziehen und Werfen (Maus, Stift, Finger und die Vorführung nutzen dieselben Funktionen)
  const drag = { an: false, bereit: false, x0: 0, z0: 0, touch: false, proben: [] };
  function druck(x, t, touch) { drag.bereit = true; drag.an = false; drag.x0 = x; drag.z0 = ziel; drag.touch = touch; drag.proben = [[t, x]]; }
  function zug(x, t) {
    if (!drag.bereit) return;
    if (!drag.an && Math.abs(x - drag.x0) < T.dragMin) return;
    if (!drag.an) { drag.an = true; buehne.classList.add('zieht'); greifer.classList.add('druck'); }
    ziel = drag.z0 + (x - drag.x0);
    drag.proben.push([t, x]); while (drag.proben.length > 2 && t - drag.proben[0][0] > 100) drag.proben.shift();
    starten();
  }
  function los(t) {
    if (!drag.bereit) return;
    drag.bereit = false; buehne.classList.remove('zieht'); greifer.classList.remove('druck');
    if (drag.an) {
      const p = drag.proben, a = p[0], b = p[p.length - 1];
      const tempo = t - b[0] > 80 || b[0] === a[0] ? 0 : (b[1] - a[1]) / ((b[0] - a[0]) / 1000);
      ziel = einrasten(ziel + (reduziert() ? 0 : klemm(-T.wurfMax, T.wurfMax, tempo) * T.wurf));
    }
    drag.an = false; starten();
  }

  buehne.addEventListener('pointerdown', e => {
    if (e.button !== 0) return;
    stoppDemo();
    buehne.setPointerCapture(e.pointerId);
    druck(e.clientX, e.timeStamp, e.pointerType !== 'mouse');
  });
  buehne.addEventListener('pointermove', e => {
    if (e.pointerType === 'mouse' && !demo.an) { gr.zx = e.clientX; gr.zy = e.clientY; if (!gr.an) { gr.x = gr.zx; gr.y = gr.zy; gr.an = true; greifer.classList.add('an'); } starten(); }
    zug(e.clientX, e.timeStamp);
  });
  buehne.addEventListener('pointerup', e => los(e.timeStamp));
  buehne.addEventListener('pointercancel', e => los(e.timeStamp));
  buehne.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse' && !demo.an) { gr.an = false; greifer.classList.remove('an'); } });

  // Rad und Trackpad: nur über dem Streifen, Rad-Faktor 1
  buehne.addEventListener('wheel', e => {
    const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
    if (!d) return;
    e.preventDefault(); stoppDemo();
    ziel -= d * (e.deltaMode === 1 ? 16 : 1) * T.rad; starten();
  }, { passive: false });

  // Tastatur: eine Karte pro Pfeil, gleitet mit demselben Modell
  buehne.addEventListener('keydown', e => {
    const r = { ArrowRight: -1, ArrowLeft: 1 }[e.key];
    if (!r) return;
    e.preventDefault(); stoppDemo();
    ziel = einrasten(ziel) + r * S; starten();
  });

  document.querySelectorAll('[name=modus]').forEach(r => r.addEventListener('change', () => setzeModus(r.value)));
  function setzeModus(m) {
    modus = m; istV = v; tween = { von: ist, nach: ziel, t0: performance.now() };
    document.getElementById('m-' + m).checked = true;
    w.modell.textContent = reduziert() ? 'sofort' : { lerp: 'lerp 0.1', feder: 'k120 d14', quickto: 'power3 0.6s' }[m];
  }

  // Vorführung: ein gedachter Zeiger greift, wirft nach links, dann nach rechts, dann Rad.
  // Jede Runde wechselt das Modell, damit der Verlauf den Unterschied zeigt.
  const demo = { an: false, uhr: 0, t0: 0, runde: 0, phase: -1 };
  const RUNDE = 7200, FOLGE = ['lerp', 'feder', 'quickto'];
  function demoBild(t) {
    if (!demo.an) return;
    const z = t - demo.t0, r = Math.floor(z / RUNDE), s = (z % RUNDE) / 1000;
    if (r !== demo.runde) { demo.runde = r; setzeModus(FOLGE[r % 3]); }
    const b = buehne.getBoundingClientRect(), y0 = b.top + b.height * 0.42, mx = b.left + b.width * 0.5;
    let x = mx + 120, y = y0;
    const ein = (a, e, p) => a + (e - a) * p;
    if (s < 0.6) { x = ein(b.right + 60, mx + 120, power3out(s / 0.6)); }
    else if (s < 0.95) { const p = (s - 0.6) / 0.35; x = mx + 120 - 330 * p * p; }
    else if (s < 3.1) { x = ein(mx - 210, mx - 160, power3out((s - 0.95) / 2.15)); y = y0 + 18 * Math.sin((s - 0.95) * 2); }
    else if (s < 3.4) { const p = (s - 3.1) / 0.3; x = mx - 160 + 300 * p * p; }
    else if (s < 5.4) { x = ein(mx + 140, mx + 60, power3out((s - 3.4) / 2)); }
    else { x = mx + 60 + (s - 5.4) * 30; }
    const phase = s < 0.6 ? 0 : s < 0.95 ? 1 : s < 3.1 ? 2 : s < 3.4 ? 3 : s < 5.4 ? 4 : 5;
    if (phase !== demo.phase) {
      if (phase === 1 || phase === 3) druck(x, t, false);
      if (phase === 2 || phase === 4) los(t);
      demo.phase = phase;
    }
    if (phase === 1 || phase === 3) zug(x, t);
    if (phase === 5 && s < 6.1) { ziel -= 26 * Math.sin((s - 5.4) / 0.7 * Math.PI); }   // Radstoß
    if (phase === 5 && s >= 6.1 && s < 6.15) ziel = einrasten(ziel);
    gr.zx = x; gr.zy = y;
    zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
    starten();
    demo.uhr = requestAnimationFrame(demoBild);
  }
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    demo.an = true; demo.t0 = performance.now(); demo.runde = -1; demo.phase = -1;
    const b = buehne.getBoundingClientRect();
    gr.x = gr.zx = b.right + 60; gr.y = gr.zy = b.top + b.height * 0.42;
    greifer.classList.add('an');
    demo.uhr = requestAnimationFrame(demoBild);
  }
  function pause() { cancelAnimationFrame(demo.uhr); demo.uhr = 0; demo.an = false; if (drag.bereit) los(performance.now()); }
  function stoppDemo() { if (!html.classList.contains('vorfuehrung')) return; pause(); html.classList.remove('vorfuehrung'); greifer.classList.remove('an'); gr.an = false; }
  function neustart() {
    stoppDemo(); html.classList.remove('vorfuehrung');
    ziel = ist = letzterIst = 0; v = istV = 0; verlauf.length = 0;
    { const t = performance.now(); verlauf.push([t - 3600, 0, 0], [t, 0, 0]); }
    setzeModus(document.querySelector('[name=modus]:checked').value);
    malen(); zeichnen(); starten();
  }
  function weiter() { if (!demo.uhr && html.classList.contains('vorfuehrung')) vorfuehren(); }

  addEventListener('resize', () => { mass(); });
  mass(); setzeModus('lerp');
  { const t = performance.now(); verlauf.push([t - 3600, 0, 0], [t, 0, 0]); zeichnen(); }
  if (document.fonts) document.fonts.ready.then(mass);
  addEventListener('load', () => { setzeModus(modus); malen(); });

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