Zum Inhalt
← Galerien & Slider

Drag-Slider mit Trägheit und Endlosschleife

Eine endlose Projektreihe, die dem Zeiger 1:1 folgt, mit Schwung ausgleitet und auf dem nächsten Slide einrastet.

Eine Reihe von Slides läuft ohne Anfang und Ende. Eine einzige Zahl (die x-Position eines unsichtbaren Proxys) bestimmt alles: Jeder Slide sitzt bei i mal Slidebreite plus Position, per Modulo in die Gesamtbreite gefaltet. Wer zieht, bewegt den Proxy mit Draggable 1:1. Beim Loslassen übernimmt InertiaPlugin den Schwung, und eine Snap-Funktion rundet die natürliche Landeposition auf den nächsten Slide. Pfeile und Tasten springen per Tween zum Nachbarn. Zähler und Bildunterschrift folgen dem aktiven Slide.

Die Reihe fühlt sich an wie ein Gegenstand: Sie folgt dem Finger ohne Verzug und gleitet glaubwürdig aus. Das Einrasten schafft danach wieder Ordnung. Weil es kein Ende gibt, entsteht kein Gefühl von Begrenzung.

Wann einsetzen

Für Projektreihen, Produktkollektionen, Teamfotos, Referenzlogos und Stimmen von Kunden. Überall dort, wo man stöbern soll und nicht der Reihe nach lesen muss.

Wann nicht

Bei weniger als vier Elementen. Wenn die Reihenfolge verbindlich ist, etwa bei Schritten oder Preisen. Wenn Karten Formularelemente enthalten, denn dann konkurrieren Ziehen und Klicken.

Varianten

  • Einrasten: aktiver Slide in der Mitte, Nachbarn gedimmt, Wurf endet immer auf einem Slide
  • Tempo: die Reihe neigt sich beim schnellen Ziehen (skewX nach Geschwindigkeit), das Bild im Slide läuft 10 bis 20 Prozent des Wegs gegen
  • Laufband: läuft von selbst weiter, lässt sich greifen und werfen und kehrt danach in seine Bewegung zurück, in Richtung des letzten Wurfs
  • Scroll-gekoppelt: Mausrad oder Trackpad treiben dieselbe Position (GSAP Observer)

Umsetzung

Draggable auf ein Proxy-Element, nicht auf die Slides. Die Proxy-x-Position ist die Wahrheit; die Slides werden pro Frame daraus gesetzt (Modulo-Wrap) oder über den GSAP-Helfer horizontalLoop als Zeitleiste geführt. Snap rechnet in Slidebreiten, so wählt der Wurf automatisch den kürzesten Weg über den Loop-Rand. Fallstricke: bei Resize Breiten neu messen und die Position im Verhältnis umrechnen. Bewegung unter etwa 10 px als Klick werten und den letzten Snap behalten. Bilder mit draggable=false und user-select: none. Vor jedem eigenen Tween den Proxy auf die aktuelle Position setzen, sonst springt er. Jeder Pfeil-Button braucht ein eigenes aria-label.

Robust umsetzen

Telefon. Draggable funktioniert mit Touch. Vertikales Scrollen der Seite darf nicht blockiert werden: allowNativeTouchScrolling bzw. lockAxis. Alternative auf Mobilgeräten: natives overflow-x mit scroll-snap.

Ohne Bewegung. Kein Autoplay, kein Schwung, keine Neigung. Beim Loslassen sofort einrasten. Navigation als Sprung oder mit kurzer Blende von 200 ms.

Barrierefrei. Container mit role=region und aria-roledescription=carousel, jeder Slide als Gruppe mit aria-label "n von m". Pfeiltasten und sichtbare Buttons mit eigenen Labels. Slides außerhalb des Bildes mit aria-hidden oder inert. Läuft die Reihe von selbst, braucht sie einen Pause-Button (WCAG 2.2.2).

Code

<!DOCTYPE html>
<!--
  Drag-Slider mit Trägheit und Endlosschleife
  Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/drag-slider-traegheit-loop/
  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>Drag-Slider mit Trägheit und Endlosschleife · 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 {
  --papier: #ecebe5; --tinte: #161513; --leise: rgba(22, 21, 19, .66); --linie: rgba(22, 21, 19, .15);
  --akzent: #c4512c;
  --ui: cubic-bezier(0.625, 0.05, 0, 1);      /* UI-Übergänge (Osmo) */
  --nav: cubic-bezier(0.25, 1, 0.5, 1);       /* power3.out, Navigation 725 ms */
  --rand: clamp(20px, 4vw, 48px);
  --karte: max(200px, clamp(17vw, min(24vw, 37svh), 24vw));
  --abstand: clamp(14px, 1.6vw, 24px);
}
* { 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 { overflow-x: hidden; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
button { font: inherit; color: inherit; }

.buehne { min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr auto; gap: clamp(16px, 2.6vh, 28px); padding: var(--rand); }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px 16px; flex-wrap: wrap; }
.modi { display: flex; gap: 4px; }
.modi button { 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; }
.modi button:hover { color: var(--tinte); border-color: rgba(22, 21, 19, .35); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }

/* Titelzeile: Überschrift links, Zähler und Pfeile rechts */
.titelzeile { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px 32px; flex-wrap: wrap; }
.titel { font-size: clamp(34px, 5.4vw, 80px); font-weight: 560; line-height: .95; letter-spacing: -.045em; max-width: 13ch; }
.titel em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.02em; color: var(--akzent); padding-right: .04em; }
.steuer { display: flex; align-items: flex-end; gap: clamp(14px, 2vw, 28px); }
.zaehler { display: flex; align-items: flex-end; gap: 6px; font-variant-numeric: tabular-nums; }
.zaehler .mono { line-height: 1; padding-bottom: .5em; }
.zaehler-maske { position: relative; display: grid; height: 1lh; min-width: 1.14em; overflow: hidden; font-size: clamp(40px, 5vw, 72px); font-weight: 500; letter-spacing: -.05em; line-height: 1; }
.ziffer { grid-area: 1 / 1; display: block; }
.knoepfe { display: flex; gap: 6px; padding-bottom: 4px; }
.knopf { width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--linie); background: none; display: grid; place-items: center; cursor: pointer; transition: background .15s, color .15s, border-color .15s; }
.knopf:hover, .knopf.gedrueckt { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.knopf:focus-visible, .modi button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
.knopf svg { width: 16px; height: 16px; }
.knopf[hidden] { display: none; }

/* Die Bahn: randlos, Karten werden per JS auf einer Endlosschleife verteilt */
.bahn { position: relative; margin-inline: calc(var(--rand) * -1); align-self: center; height: calc(var(--karte) * 1.25 + 44px); overflow-x: clip; outline: none; cursor: grab; user-select: none; -webkit-user-select: none; }
.bahn:focus-visible::after { content: ''; position: absolute; inset: -6px var(--rand); border: 2px solid var(--akzent); border-radius: 8px; pointer-events: none; }
.bahn.zieht { cursor: grabbing; }
.karte { position: absolute; left: 0; top: 0; width: var(--karte); opacity: .42; transition: opacity .4s var(--ui); will-change: transform; }
.karte.aktiv { opacity: 1; }
.rahmen { position: relative; aspect-ratio: 4 / 5; overflow: hidden; border-radius: 4px; background: #d8d6cf; }
.rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; pointer-events: none; -webkit-user-drag: none; transition: scale .8s var(--nav); }
.tempo .rahmen img { scale: 1.24; }
.karte figcaption { display: flex; justify-content: space-between; gap: 10px; padding-top: 12px; }
.karte figcaption span:first-child { color: var(--tinte); }

/* Unterzeile: Bildunterschrift des aktiven Slides, Werte */
.fuss { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px 40px; flex-wrap: wrap; border-top: 1px solid var(--linie); padding-top: 16px; }
.unterschrift { display: grid; gap: 6px; min-height: 76px; }
.unterschrift > * { overflow: hidden; }
.unterschrift .zeile { display: block; }
.u-name { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-size: clamp(26px, 2.6vw, 36px); line-height: 1.05; letter-spacing: -.01em; }
.u-text { font-size: 15px; line-height: 1.4; color: var(--leise); max-width: 46ch; }
.rechts { display: grid; justify-items: end; gap: 14px; }
.spur { position: relative; width: 168px; height: 2px; background: var(--linie); overflow: hidden; }
.spur i { position: absolute; inset: 0 auto 0 0; width: 12.5%; background: var(--tinte); will-change: transform; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px 16px; max-width: 520px; }
.werte b { color: var(--tinte); font-weight: 500; }

/* Eigener Zeiger über der Bahn (nur Maus) und in der Vorführung */
.greifer { position: fixed; left: 0; top: 0; z-index: 9; width: 84px; height: 84px; margin: -42px 0 0 -42px; border-radius: 50%; background: var(--tinte); color: var(--papier); display: grid; place-items: center; pointer-events: none; scale: 0; transition: scale .4s var(--ui); }
.greifer .mono { color: inherit; font-size: 10px; }
.greifer.an { scale: 1; }
.greifer.an.druck { scale: .78; }
.hat-greifer .bahn { cursor: none; }

/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .werte { display: none; }

@media (hover: none) {
  .greifer { display: none; }
}
@media (max-width: 640px) {
  :root { --karte: 62vw; }
  .titelzeile { align-items: flex-end; flex-wrap: nowrap; }
  .titel { font-size: clamp(32px, 9.6vw, 44px); }
  .zaehler .mono { display: none; }
  .knoepfe { flex-direction: column; }
  .knopf { width: 40px; height: 40px; }
  .werte { justify-content: flex-start; }
  .rechts { justify-items: start; }
  .u-text { font-size: 14px; }
}
/* Reduzierte Bewegung: kein Schwung, kein Laufband, Sprünge mit kurzem Überblenden */
.reduziert .rahmen img, .reduziert .greifer { transition: none; }
.reduziert .karte { transition: opacity .1s linear; }
.reduziert .tempo .rahmen img { scale: 1; }
.reduziert .bahn.blende .karte { opacity: 0; transition: opacity .1s linear; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="buehne">
  <div class="kopf">
    <p class="mono">Ausgewählte Arbeiten · 2018–2024</p>
    <div class="modi mono" role="group" aria-label="Variante">
      <button type="button" data-modus="einrasten" aria-pressed="true">Einrasten</button>
      <button type="button" data-modus="tempo" aria-pressed="false">Tempo</button>
      <button type="button" data-modus="laufband" aria-pressed="false">Laufband</button>
    </div>
  </div>

  <div class="titelzeile">
    <h1 class="titel">Marken, die man <em>anfassen</em> möchte.</h1>
    <div class="steuer">
      <p class="zaehler" aria-hidden="true"><span class="zaehler-maske" data-zaehler><span class="ziffer">01</span></span><span class="mono">/ 08</span></p>
      <div class="knoepfe">
        <button type="button" class="knopf" data-schritt="-1" aria-label="Vorherige Arbeit"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
        <button type="button" class="knopf" data-schritt="1" aria-label="Nächste Arbeit"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
        <button type="button" class="knopf" data-pause aria-label="Laufband anhalten" aria-pressed="false" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><path data-pause-icon d="M5 3v10M11 3v10" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></button>
      </div>
    </div>
  </div>

  <div class="bahn" data-bahn role="region" aria-roledescription="Karussell" aria-label="Ausgewählte Arbeiten, mit Pfeiltasten blättern" tabindex="0">
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="1 von 8" data-name="Röstkontor" data-art="Erscheinungsbild · 2024" data-text="Eine Bohne im Kreis, darunter breite Versalien. Ziegelrot und Creme, auf Tüte, Tasse und Schild.">
      <div class="rahmen"><img src="bilder/marke-roestkontor-hoch.jpg" alt="Wortbildmarke Röstkontor: Kaffeebohne im Kreis über cremefarbenen Versalien auf Ziegelrot" draggable="false"></div>
      <figcaption class="mono"><span>01 Röstkontor</span><span>2024</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="2 von 8" data-name="Krume" data-art="Marke · 2023" data-text="Fette Kleinbuchstaben und ein Brotlaib als Zeichen für eine Backstube, die um halb vier den Ofen anwirft.">
      <div class="rahmen"><img src="bilder/marke-krume-hoch.jpg" alt="Wortmarke krume in fetten dunkelbraunen Kleinbuchstaben unter einem Brotlaib auf Senfgelb" draggable="false"></div>
      <figcaption class="mono"><span>02 Krume</span><span>2023</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="3 von 8" data-name="Eselsohr" data-art="Logo &amp; Website · 2022" data-text="Ein Blatt mit rosa Ecke und eine weiche Kursive: das Zeichen für eine Buchhandlung mit Lesecafé.">
      <div class="rahmen"><img src="bilder/marke-eselsohr-hoch.jpg" alt="Wortbildmarke Eselsohr: Blatt mit rosa Eselsohr über kursivem Schriftzug auf Anthrazit" draggable="false"></div>
      <figcaption class="mono"><span>03 Eselsohr</span><span>2022</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="4 von 8" data-name="Ambra 9" data-art="Packaging · 2024" data-text="Bernstein im kantigen Glas, schwarzer Verschluss, kein Etikett. Das Blau gehört zur Kampagne.">
      <div class="rahmen"><img src="bilder/flakon-hoch.jpg" alt="Bernsteinfarbener Parfumflakon auf weißem Podest vor kräftigem Kobaltblau" draggable="false"></div>
      <figcaption class="mono"><span>04 Ambra</span><span>2024</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="5 von 8" data-name="Werkschau Licht" data-art="Plakat &amp; Print · 2020" data-text="Neongelb, ein oranger Kreis und Versalien, die über den Rand laufen. Gedruckt im Siebdruck.">
      <div class="rahmen"><img src="bilder/typo-plakat-hoch.jpg" alt="Plakat in Neongelb: riesige schwarze Versalien LICHT, oranger Kreis und kursive Zeile „und was es übrig lässt“" draggable="false"></div>
      <figcaption class="mono"><span>05 Licht</span><span>2020</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="6 von 8" data-name="Leise Dinge" data-art="Editorial · 2018" data-text="Ein Magazin über Handwerk und Räume. Viel Weißraum, eine Antiqua, Bilder ohne Rahmen.">
      <div class="rahmen"><img src="bilder/magazin-doppelseite-hoch.jpg" alt="Aufgeschlagenes Magazin mit der Überschrift „Die Ruhe der Dinge“ und zweispaltigem Text" draggable="false"></div>
      <figcaption class="mono"><span>06 Leise Dinge</span><span>2018</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="7 von 8" data-name="Werkstatt Lehm" data-art="Fotografie · 2024" data-text="Gefäße auf Sockeln vor Terrakotta, für den Katalog einer kleinen Töpferei.">
      <div class="rahmen"><img src="bilder/keramik-stillleben-hoch.jpg" alt="Keramik-Stillleben: helle Flasche, schwarze Kugelvase, gelber Becher und Schalen auf Sockeln vor Terrakotta" draggable="false"></div>
      <figcaption class="mono"><span>07 Lehm</span><span>2024</span></figcaption>
    </figure>
    <figure class="karte" role="group" aria-roledescription="Slide" aria-label="8 von 8" data-name="Leinenwerk" data-art="Markenkonzept · 2019" data-text="Gefaltete Tücher in fünf Farben: die Palette einer Weberei, vom Stapel abgelesen.">
      <div class="rahmen"><img src="bilder/stoff-stapel-hoch.jpg" alt="Stapel gefalteter Leinentücher in Weiß, Rosé, Senf und Beere auf einem hellen Holztisch" draggable="false"></div>
      <figcaption class="mono"><span>08 Leinen</span><span>2019</span></figcaption>
    </figure>
  </div>

  <div class="fuss">
    <div class="unterschrift" aria-live="polite" data-unterschrift>
      <p class="u-name"><span class="zeile" data-u-name>Röstkontor</span></p>
      <p class="mono"><span class="zeile" data-u-art>Erscheinungsbild · 2024</span></p>
      <p class="u-text"><span class="zeile" data-u-text>Eine Bohne im Kreis, darunter breite Versalien. Ziegelrot und Creme, auf Tüte, Tasse und Schild.</span></p>
    </div>
    <div class="rechts">
      <div class="spur" aria-hidden="true"><i data-spur></i></div>
      <p class="werte mono" data-werte></p>
    </div>
  </div>
</section>
<div class="greifer" data-greifer aria-hidden="true"><span class="mono" data-greifer-text>Ziehen</span></div>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Draggable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/InertiaPlugin.min.js"></script>
<script>
(() => {
  gsap.registerPlugin(Draggable, InertiaPlugin);
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const bahn = $('[data-bahn]');
  const karten = [...bahn.querySelectorAll('.karte')];
  const bilder = karten.map(k => k.querySelector('img'));
  const N = karten.length;
  const greifer = $('[data-greifer]'), greiferText = $('[data-greifer-text]');
  const pauseKnopf = $('[data-pause]');
  const spur = document.querySelector('[data-spur]');

  // Gemessene Werte (Osmo, Trionn) und eigene Werte für die Tempo-Variante
  const W = {
    nav: 0.725, navEase: 'power3.out',          // Pfeile, Tasten, Klick auf Nachbar
    zaehler: 0.45,                              // Zähler rollt, power3.out
    text: 525, textVerz: 300, textEase: 'cubic-bezier(0.625, 0.05, 0, 1)',
    klick: 10,                                  // unter 10 px gilt als Klick, Snap bleibt
    einrasten: { throwResistance: 1000, overshootTolerance: 0, dragResistance: 0 },
    laufband: { throwResistance: 2500, dragResistance: 0.12, tempo: 100, lerp: 0.05 },
    neigung: { faktor: 0.003, max: 6, lerp: 0.12 },   // skewX = −v · 0,003°, höchstens ±6°
    parallaxe: 0.1,                             // Bild läuft 10 % des Wegs gegen
  };

  let modus = 'einrasten';
  let pos = 0, schritt = 300, kartenB = 240, breite = 960, mitte = 360, gesamt = 2400;
  let aktiv = -1, letzterSnap = 0, startX = 0;
  let laufTempo = 0, richtung = -1, angehalten = false, nutzerPause = false, ueber = false;
  let neigung = 0, letztePos = 0, geschw = 0;
  let zieht = false, wirft = false;

  // Unsichtbarer Proxy: Draggable bewegt ihn, seine x-Position ist die einzige Wahrheit
  const proxy = document.createElement('div');
  const wrap = v => ((v % gesamt) + gesamt) % gesamt;

  function messen() {
    const alt = schritt;
    kartenB = karten[0].offsetWidth;
    schritt = kartenB + gsap.utils.clamp(14, 24, innerWidth * 0.016);   // = --abstand
    breite = bahn.clientWidth;
    mitte = (breite - kartenB) / 2;
    gesamt = N * schritt;
    pos = pos / alt * schritt; letzterSnap = letzterSnap / alt * schritt;
    gsap.set(proxy, { x: pos });
  }
  const naechster = v => Math.round(v / schritt) * schritt;

  function malen() {
    for (let i = 0; i < N; i++) {
      let rel = wrap(i * schritt + pos);
      if (rel > gesamt / 2) rel -= gesamt;            // Karten hinter der Mitte springen nach links
      const x = mitte + rel;
      karten[i].style.transform = `translate3d(${x.toFixed(2)}px,0,0)` + (neigung ? ` skewX(${neigung.toFixed(2)}deg)` : '');
      if (modus === 'tempo' && !reduziert()) {
        const versatz = gsap.utils.clamp(-0.12 * kartenB, 0.12 * kartenB, -(rel) * W.parallaxe);
        bilder[i].style.translate = `${versatz.toFixed(2)}px 0`;
      } else bilder[i].style.translate = '';
      const sichtbar = x + kartenB > 0 && x < breite;
      if (karten[i].ariaHidden !== String(!sichtbar)) karten[i].setAttribute('aria-hidden', String(!sichtbar));
    }
    spur.style.transform = `translate3d(${(wrap(-pos + schritt / 2) / gesamt * 800 - 50).toFixed(2)}%,0,0)`;   // Fortschritt, läuft mit
    const a = ((Math.round(-pos / schritt) % N) + N) % N;
    if (a !== aktiv) setzeAktiv(a);
  }

  // Zähler und Bildunterschrift
  const zaehler = $('[data-zaehler]');
  const uName = $('[data-u-name]'), uArt = $('[data-u-art]'), uText = $('[data-u-text]');
  function setzeAktiv(a) {
    const vorher = aktiv; aktiv = a;
    karten.forEach((k, i) => k.classList.toggle('aktiv', i === a));
    const k = karten[a];
    if (vorher < 0) return;
    const vor = ((a - vorher + N) % N) <= N / 2 ? 1 : -1;
    const alt = zaehler.lastElementChild;
    const neu = document.createElement('span');
    neu.className = 'ziffer'; neu.textContent = String(a + 1).padStart(2, '0');
    zaehler.append(neu);
    if (reduziert()) { [...zaehler.children].forEach(c => c !== neu && c.remove()); }
    else {
      gsap.fromTo(neu, { yPercent: 100 * vor }, { yPercent: 0, duration: W.zaehler, ease: 'power3.out' });
      gsap.to(alt, { yPercent: -100 * vor, duration: W.zaehler, ease: 'power3.out', onComplete: () => alt.remove() });
    }
    uName.textContent = k.dataset.name; uArt.textContent = k.dataset.art; uText.textContent = k.dataset.text;
    [uName, uArt, uText].forEach((el, i) => {
      el.getAnimations().forEach(x => x.cancel());
      el.animate(reduziert()
        ? [{ opacity: 0 }, { opacity: 1 }]
        : [{ transform: 'translateY(105%)', opacity: 0 }, { transform: 'none', opacity: 1 }],
        reduziert()
        ? { duration: 200, fill: 'backwards' }
        : { duration: W.text, delay: W.textVerz + i * 40, easing: W.textEase, fill: 'backwards' });
    });
  }

  // Pro Bild: Laufband vorrücken, Neigung nachführen, alles malen
  function tick(_, dtMs) {
    const dt = Math.min(dtMs, 64) / 1000, k = f => 1 - Math.pow(1 - f, dt * 60);
    if (modus === 'laufband' && !zieht && !wirft && !reduziert()) {
      const ziel = (angehalten || nutzerPause || ueber) ? 0 : richtung * W.laufband.tempo;
      laufTempo += (ziel - laufTempo) * k(W.laufband.lerp);
      pos += laufTempo * dt;
    }
    if (dt > 0) geschw += ((pos - letztePos) / dt - geschw) * k(0.25);
    letztePos = pos;
    const zielNeigung = modus === 'tempo' && !reduziert()
      ? gsap.utils.clamp(-W.neigung.max, W.neigung.max, -geschw * W.neigung.faktor) : 0;
    neigung += (zielNeigung - neigung) * k(W.neigung.lerp);
    if (Math.abs(neigung) < 0.01) neigung = 0;
    malen();
  }

  // Draggable je nach Variante neu aufsetzen
  let ziehen = null;
  function draggableAufsetzen() {
    if (ziehen) ziehen.kill();
    const lb = modus === 'laufband', cfg = lb ? W.laufband : W.einrasten;
    ziehen = Draggable.create(proxy, {
      type: 'x', trigger: bahn, inertia: !reduziert(),
      allowNativeTouchScrolling: true,
      dragResistance: cfg.dragResistance, throwResistance: cfg.throwResistance,
      overshootTolerance: W.einrasten.overshootTolerance,
      snap: lb ? null : { x: v => (Math.abs(v - startX) < W.klick ? letzterSnap : naechster(v)) },
      onPressInit() {
        vorfuehrungStop();
        this.vars.inertia = !reduziert();
        gsap.killTweensOf(proxy);
        gsap.set(proxy, { x: pos }); this.update();
        startX = pos; zieht = true; wirft = false;
        bahn.classList.add('zieht'); greifer.classList.add('druck');
      },
      onDrag() { pos = this.x; },
      onThrowUpdate() { pos = this.x; },
      onRelease() {
        zieht = false; bahn.classList.remove('zieht'); greifer.classList.remove('druck');
        if (lb && Math.abs(this.x - startX) > W.klick) richtung = this.x < startX ? -1 : 1;
        if (!this.tween || reduziert()) {                  // ohne Schwung: sofort auf den Slide
          if (!lb) { pos = Math.abs(pos - startX) < W.klick ? letzterSnap : naechster(pos); gsap.set(proxy, { x: pos }); letzterSnap = pos; }
        } else wirft = true;
      },
      onThrowComplete() { wirft = false; pos = this.x; if (!lb) letzterSnap = naechster(pos); },
      onClick(e) {                                         // Klick auf eine Nachbarkarte springt dorthin
        const k = e.target.closest && e.target.closest('.karte');
        if (!k) return;
        const r = k.getBoundingClientRect(), b = bahn.getBoundingClientRect();
        const d = Math.round(((r.left + r.width / 2) - (b.left + b.width / 2)) / schritt);
        if (d) gehe(d);
      },
    })[0];
  }

  // Navigation per Pfeil, Taste, Klick: 725 ms power3.out bzw. Sprung mit Überblenden
  function gehe(d, sanft = true) {
    gsap.killTweensOf(proxy);
    wirft = false;
    const ziel = naechster(pos) - d * schritt;
    letzterSnap = ziel;
    if (modus === 'laufband' && d) richtung = d > 0 ? -1 : 1;
    if (reduziert() || !sanft) {
      bahn.classList.add('blende');
      setTimeout(() => { pos = ziel; gsap.set(proxy, { x: pos }); malen(); bahn.classList.remove('blende'); }, 100);
      return;
    }
    gsap.set(proxy, { x: pos });
    wirft = true;
    gsap.to(proxy, { x: ziel, duration: W.nav, ease: W.navEase, onUpdate: () => { pos = gsap.getProperty(proxy, 'x'); }, onComplete: () => { wirft = false; } });
  }
  document.querySelectorAll('[data-schritt]').forEach(b => b.addEventListener('click', () => { vorfuehrungStop(); gehe(+b.dataset.schritt); }));
  bahn.addEventListener('keydown', e => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); vorfuehrungStop(); gehe(e.key === 'ArrowRight' ? 1 : -1); }
  });
  pauseKnopf.addEventListener('click', () => {
    nutzerPause = !nutzerPause;
    pauseKnopf.setAttribute('aria-pressed', String(nutzerPause));
    pauseKnopf.setAttribute('aria-label', nutzerPause ? 'Laufband fortsetzen' : 'Laufband anhalten');
    $('[data-pause-icon]').setAttribute('d', nutzerPause ? 'M5 3v10l8-5Z' : 'M5 3v10M11 3v10');
  });

  // Eigener Greifer-Zeiger (nur Maus)
  const gz = { x: -200, y: -200 }, gi = { x: -200, y: -200 };
  const fein = matchMedia('(hover: hover) and (pointer: fine)');
  bahn.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { ueber = true; if (fein.matches) { html.classList.add('hat-greifer'); gz.x = gi.x = e.clientX; gz.y = gi.y = e.clientY; greifer.classList.add('an'); } } });
  bahn.addEventListener('pointerleave', e => { if (e.pointerType === 'mouse') { ueber = false; if (!html.classList.contains('vorfuehrung')) { greifer.classList.remove('an'); html.classList.remove('hat-greifer'); } } });
  addEventListener('pointermove', e => { if (e.pointerType === 'mouse') { gz.x = e.clientX; gz.y = e.clientY; } });
  gsap.ticker.add(() => {
    const f = reduziert() ? 1 : 0.2;
    gi.x += (gz.x - gi.x) * f; gi.y += (gz.y - gi.y) * f;
    greifer.style.transform = `translate3d(${gi.x}px, ${gi.y}px, 0)`;
  });

  // Werte-Anzeige
  const werte = $('[data-werte]');
  function zeigeWerte() {
    const t = {
      einrasten: ['Wurf <b>inertia</b>', 'Einrasten <b>nächster Slide</b>', 'overshoot <b>0</b>', 'Klick <b>&lt; 10 px</b>', 'Pfeile <b>725 ms power3.out</b>', 'Text <b>525 ms +300</b>'],
      tempo: ['Neigung <b>skewX −v·0.003°, max 6°</b>', 'Bildparallaxe <b>10 %</b>', 'Einrasten <b>nächster Slide</b>', 'Pfeile <b>725 ms power3.out</b>'],
      laufband: ['Lauf <b>100 px/s</b>', 'dragResistance <b>0.12</b>', 'throwResistance <b>2500</b>', 'Anlauf <b>lerp 0.05</b>', 'Richtung <b>folgt dem Wurf</b>'],
    }[modus];
    werte.innerHTML = reduziert()
      ? '<span>Reduziert <b>kein Schwung</b></span><span>Laufband <b>aus</b></span><span>Pfeile <b>Sprung, Blende 200 ms</b></span>'
      : t.map(s => `<span>${s}</span>`).join('');
  }

  function setzeModus(neu) {
    modus = neu;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === neu)));
    bahn.classList.toggle('tempo', neu === 'tempo');
    pauseKnopf.hidden = neu !== 'laufband' || reduziert();
    laufTempo = 0;
    gsap.killTweensOf(proxy); wirft = false;
    if (neu !== 'laufband') { pos = naechster(pos); letzterSnap = pos; gsap.set(proxy, { x: pos }); }
    draggableAufsetzen();
    zeigeWerte();
  }
  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b) return;
    const lief = !!tl;
    setzeModus(b.dataset.modus);
    if (lief) vorfuehren();
  });

  // Vorführung: ein gedachter Greifer zieht, wirft, klickt
  let tl = null;
  function werfen(v) {
    const lb = modus === 'laufband';
    if (lb) richtung = v < 0 ? -1 : 1;
    gsap.set(proxy, { x: pos });
    wirft = true;
    gsap.to(proxy, {
      inertia: { x: lb ? { velocity: v } : { velocity: v, end: naechster } , resistance: (lb ? W.laufband : W.einrasten).throwResistance },
      overshootTolerance: 0,
      onUpdate: () => { pos = gsap.getProperty(proxy, 'x'); },
      onComplete: () => { wirft = false; letzterSnap = naechster(pos); },
    });
  }
  function zug(dist, dauer, v) {
    const s = { p: 0 };
    let p0 = 0, gx0 = 0;
    return gsap.timeline()
      .call(() => { gsap.killTweensOf(proxy); wirft = false; zieht = true; p0 = pos; gx0 = gz.x; greifer.classList.add('druck'); })
      .to(s, { p: 1, duration: dauer, ease: 'power1.in', onUpdate() { pos = p0 + dist * s.p; gz.x = gx0 + dist * s.p; } })
      .call(() => { zieht = false; greifer.classList.remove('druck'); werfen(v); });
  }
  function vorfuehrungStop() {
    if (!tl) return;
    tl.kill(); tl = null;
    html.classList.remove('vorfuehrung');
    if (!ueber) { greifer.classList.remove('an'); html.classList.remove('hat-greifer'); }
  }
  function vorfuehren() {
    vorfuehrungStop();
    angehalten = false;
    html.classList.add('vorfuehrung');
    if (reduziert()) {                                    // nur Sprünge, kein Greifer
      tl = gsap.timeline({ repeat: -1 }).call(() => gehe(1), null, 1.6).to({}, { duration: 0.4 });
      return;
    }
    const b = () => bahn.getBoundingClientRect();
    const knopf = document.querySelector('[data-schritt="1"]');
    tl = gsap.timeline({ repeat: -1 });
    tl.call(() => { const r = b(); gz.x = gi.x = r.left + r.width * 0.7; gz.y = gi.y = r.top + r.height * 0.42; greifer.classList.add('an'); html.classList.add('hat-greifer'); greiferText.textContent = 'Ziehen'; })
      .to({}, { duration: 0.5 })
      .add(zug(-Math.min(260, innerWidth * 0.3), 0.5, -1700))
      .to({}, { duration: 2.0 })
      .call(() => { const r = b(); gz.x = r.left + r.width * 0.3; })
      .to({}, { duration: 0.6 })
      .add(zug(Math.min(200, innerWidth * 0.24), 0.45, 1300))
      .to({}, { duration: 1.9 })
      .call(() => { greifer.classList.remove('an'); knopf.classList.add('gedrueckt'); gehe(1); })
      .call(() => knopf.classList.remove('gedrueckt'), null, '+=0.35')
      .to({}, { duration: 1.2 })
      .call(() => { knopf.classList.add('gedrueckt'); gehe(1); })
      .call(() => knopf.classList.remove('gedrueckt'), null, '+=0.35')
      .to({}, { duration: 1.2 });
  }
  function pause() { angehalten = true; if (tl) tl.pause(); gsap.getTweensOf(proxy).forEach(t => t.pause()); }
  function weiter() { angehalten = false; gsap.getTweensOf(proxy).forEach(t => t.resume()); if (tl) tl.resume(); }
  function neustart() {
    vorfuehrungStop();
    angehalten = false;
    gsap.killTweensOf(proxy);
    zieht = wirft = false; pos = 0; letzterSnap = 0; laufTempo = 0; richtung = -1; neigung = 0; geschw = 0; letztePos = 0;
    gsap.set(proxy, { x: 0 });
    malen();
  }

  // Schalter der Detailseite setzt .reduziert auch nachträglich
  new MutationObserver(() => { pauseKnopf.hidden = modus !== 'laufband' || reduziert(); zeigeWerte(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
  messen();
  setzeModus('einrasten');
  malen();
  gsap.ticker.add(tick);
  addEventListener('resize', () => { messen(); malen(); });
  if (document.fonts) document.fonts.ready.then(() => { messen(); malen(); });

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