Zum Inhalt
← Scroll & Section-Animationen

Gepinnte Horizontal-Scroll-Section

Eine Section bleibt stehen, und ein breiter Projektstreifen zieht beim senkrechten Scrollen seitlich vorbei, die Bilder darin wandern leicht mit.

Eine Section wird gepinnt. Ein breiter Streifen darin, etwa Projektkarten oder eine Zeitachse, wandert beim senkrechten Scrollen nach links, bis sein rechtes Ende am rechten Rand steht. Die senkrechte Scrollstrecke entspricht der Überlänge des Streifens. Innere Animationen wie leicht mitwandernde Bilder oder einblendende Titel hängen an der waagerechten Position. Danach scrollt die Seite wieder senkrecht.

Der Richtungswechsel bricht die Gleichförmigkeit des senkrechten Scrollens und markiert einen besonderen Abschnitt. Es fühlt sich an wie das Durchblättern einer Galerie oder eines Filmstreifens. Viele gleichrangige Elemente bekommen nacheinander die volle Bühne, ohne Raster.

Wann einsetzen

Ausgewählte Projekte, Zeitachsen, Prozessschritte, Fotostrecken und Produktreihen mit 4 bis 8 Elementen.

Wann nicht

Bei mehr als etwa 10 Elementen, weil die Strecke zu lang wird. Bei Inhalten, die man vergleichen muss. Für Kerninformationen, die jeder sehen muss. Auf dem Telefon nicht als erzwungenes Pinning.

Varianten

  • GSAP: x-Tween auf dem Streifen mit Pin, innere Animationen per containerAnimation
  • Nur CSS: sticky und view-timeline, translateX(calc(-100% + 100vw))
  • Mit Snap auf jede Karte
  • Mobil: native waagerechte Leiste mit scroll-snap statt Pin
  • Galerie mit Bild-Parallaxe im Streifen
  • Zeitachse mit Jahreszahlen
  • Eigenständiger Horizontal-Bereich, etwa für Fotografie
  • Ziehbare Galerie ohne Pin

Umsetzung

GSAP: const tween = gsap.to(track, { x: () => -(track.scrollWidth - innerWidth), ease: 'none', scrollTrigger: { trigger: section, pin: true, scrub: true, end: () => '+=' + (track.scrollWidth - innerWidth), invalidateOnRefresh: true } }). Innere Elemente: scrollTrigger: { containerAnimation: tween, trigger: card, start: 'left right' }. Die Container-Animation muss ease: 'none' haben. Pin und Snap gehen innerhalb von containerAnimation nicht. CSS: äußere Hülle mit Höhe 100svh plus Überlänge und view-timeline-name: --pin; innen position: sticky; top: 0; height: 100svh; overflow: clip. Der Streifen läuft mit animation: move linear both; animation-timeline: --pin; animation-range: contain 0% contain 100% und @keyframes move { to { transform: translateX(calc(-100% + 100vw)) } }. Fallstrick: overflow: hidden auf einem Vorfahren bricht sticky, dort overflow: clip oder visible verwenden. Bei Größenänderung die Überlänge neu messen (invalidateOnRefresh).

Robust umsetzen

Telefon. Kein Pin. Stattdessen eine native waagerechte Leiste mit overflow-x: auto und scroll-snap-type: x mandatory. Seitliches Wischen ist auf Touch natürlicher als senkrechtes Scrollen, das seitlich wirkt. Alternativ die Elemente senkrecht stapeln.

Ohne Bewegung. Kein Pin. Die Elemente als normales Raster oder als frei scrollbare Scroll-Snap-Leiste zeigen.

Barrierefrei. Liste semantisch als ul/li auszeichnen. Bei einem GSAP-Pin scrollt der Browser nicht automatisch zu einer fokussierten Karte außerhalb des Bilds. Ein focusin-Handler muss die passende Scrollposition setzen (Pin-Start plus Versatz der Karte, mit Lenis über scrollTo). Den Fokus deutlich sichtbar machen. Bei der nativen Leiste helfen Pfeilknöpfe oder ein CSS-Carousel mit ::scroll-button (Chrome 135+).

Code

<!DOCTYPE html>
<!--
  Gepinnte Horizontal-Scroll-Section
  Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/horizontal-scroll-section/
  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>Gepinnte Horizontal-Scroll-Section · 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'; 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; }

:root {
  --grund: #ebe7de; --text: #161513; --leise: rgba(22, 21, 19, .64); --linie: rgba(22, 21, 19, .16);
  --akzent: #c2502b;
  --rand: clamp(20px, 3.4vw, 48px);
  --parallaxe: 4rem;                 /* innere Bildverschiebung 0 → 4rem (Lando) */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
img { display: block; }
a { color: inherit; text-decoration: none; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.mono b { color: var(--text); font-weight: 500; }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }

/* Kopf: normal vertikal */
.kopf { min-height: 52svh; display: grid; align-content: space-between; gap: 40px; padding: var(--rand); }
.kopf__zeile { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.kopf h1 { font-size: clamp(40px, 7.2vw, 112px); font-weight: 600; letter-spacing: -.045em; line-height: .94; max-width: 13ch; }
.kopf h1 em { letter-spacing: -.02em; }

/* Bühne: die gepinnte Section */
.buehne { position: relative; height: 100svh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr); overflow: clip; container-type: inline-size; border-top: 1px solid var(--linie); }
.leiste-oben, .leiste-unten { display: flex; justify-content: space-between; align-items: center; gap: 12px 20px; padding: 18px var(--rand); flex-wrap: wrap; position: relative; z-index: 2; }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button, .blaettern 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: background-color 150ms, color 150ms, border-color 150ms; }
.modi button:hover, .blaettern button:hover { color: var(--text); border-color: var(--text); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:disabled { opacity: .35; cursor: not-allowed; }
.modi-hinweis { display: none; }

.spur { display: flex; align-items: center; gap: clamp(28px, 4.2vw, 76px); width: max-content; height: 100%; padding: 0 var(--rand); list-style: none; will-change: transform; }
.spur > li { flex: none; }

.einleitung { width: min(36vw, 520px); display: grid; gap: 28px; align-self: center; }
.einleitung h2 { font-size: clamp(44px, 5.6vw, 96px); font-weight: 600; letter-spacing: -.045em; line-height: .92; }
.einleitung p:not(.mono) { font-size: clamp(16px, 1.25vw, 19px); line-height: 1.45; max-width: 30ch; color: rgba(22, 21, 19, .78); }

.karte { display: grid; gap: 14px; outline: none; }
.karte:focus-visible .rahmen { outline: 2px solid var(--akzent); outline-offset: 6px; }
.rahmen { position: relative; overflow: hidden; background: #d9d4c9; }
.rahmen img { position: absolute; top: 0; left: calc(-1 * var(--parallaxe)); width: calc(100% + var(--parallaxe)); height: 100%; object-fit: cover; will-change: transform; transition: scale 1.2s cubic-bezier(0.16, 1, 0.3, 1); }
.karte:hover .rahmen img { scale: 1.03; }
.karte--hoch .rahmen { height: min(56svh, 600px); aspect-ratio: 4 / 5; }
.karte--quer .rahmen { height: min(42svh, 440px); aspect-ratio: 3 / 2; }
.oben { align-self: flex-start; margin-top: 4svh; }
.unten { align-self: flex-end; margin-bottom: 4svh; }
.unterzeile { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; align-items: baseline; }
.unterzeile .nr { grid-row: span 2; }
.unterzeile h3 { font-size: clamp(18px, 1.5vw, 22px); font-weight: 600; letter-spacing: -.02em; line-height: 1.15; }

.schluss { width: min(30vw, 420px); display: grid; gap: 22px; align-self: center; padding-right: 2vw; }
.schluss p.gross { font-size: clamp(44px, 5vw, 84px); line-height: .95; letter-spacing: -.03em; }
.schluss p:not(.mono):not(.gross) { font-size: 17px; line-height: 1.45; max-width: 26ch; color: rgba(22, 21, 19, .78); }
.schluss a { justify-self: start; color: var(--text); border-bottom: 1px solid var(--akzent); padding-bottom: 4px; }

.fortschritt { position: absolute; left: var(--rand); right: var(--rand); top: 0; height: 1px; background: var(--linie); }
.fortschritt i { position: absolute; inset: 0; background: var(--text); transform-origin: 0 50%; transform: scaleX(var(--p, 0)); }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.zaehler { display: flex; align-items: center; gap: 14px; }
.zaehler b { font-variant-numeric: tabular-nums; }
.blaettern { display: none; gap: 4px; }
.blaettern button { width: 30px; padding: 0; }

/* Variante CSS: sticky + view-timeline, ganz ohne GSAP */
.css-modus .huelle { height: calc(100svh + var(--ueberlaenge, 0px)); view-timeline-name: --pin; }
.css-modus .buehne { position: sticky; top: 0; }
@supports (animation-timeline: view()) {
  .css-modus .spur { animation: quer linear both; animation-timeline: --pin; animation-range: contain 0% contain 100%; }
  .css-modus .rahmen img { animation: treiben linear both; animation-timeline: --pin; animation-range: contain 0% contain 100%; }
  .css-modus .fortschritt i { animation: fuellen linear both; animation-timeline: --pin; animation-range: contain 0% contain 100%; }
}
@keyframes quer { to { transform: translateX(calc(-100% + 100cqw)); } }
@keyframes treiben { to { transform: translateX(var(--parallaxe)); } }
@keyframes fuellen { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* Telefon, Touch und reduzierte Bewegung: native Leiste mit scroll-snap statt Pin */
.leiste .buehne { height: auto; min-height: 0; gap: 8px; }
.leiste .spur { width: auto; overflow-x: auto; scroll-snap-type: x mandatory; overscroll-behavior-x: contain; scrollbar-width: none; scroll-padding-inline: var(--rand); padding-block: 8px 4px; }
.leiste .spur::-webkit-scrollbar { display: none; }
.leiste .spur > li { scroll-snap-align: start; }
.leiste .oben, .leiste .unten { align-self: flex-start; margin: 0; }
.leiste .spur { align-items: center; }
.leiste .karte--hoch .rahmen { height: auto; width: min(64vw, 400px, 46svh); }
.leiste .karte--quer .rahmen { height: auto; width: min(80vw, 560px, 60svh); }
.nur-leiste { display: none; }
.leiste .nur-leiste { display: inline; }
.leiste .nur-pin { display: none; }
.leiste .einleitung, .leiste .schluss { width: min(70vw, 440px); align-self: center; }
.leiste .einleitung h2 { font-size: clamp(40px, 10vw, 72px); }
.leiste .blaettern { display: flex; }
.leiste .modi { display: none; }
.leiste .modi-hinweis { display: block; }
.leiste .rahmen img { left: 0; width: 100%; }
.reduziert .rahmen img { transition: none; }
.reduziert .karte:hover .rahmen img { scale: 1; }
.reduziert .spur { scroll-behavior: auto; }

/* Danach geht es wieder vertikal weiter */
.danach { padding: clamp(64px, 12vh, 140px) var(--rand) var(--rand); display: grid; gap: clamp(48px, 9vh, 96px); border-top: 1px solid var(--linie); }
.danach h2 { font-size: clamp(32px, 4.4vw, 68px); font-weight: 600; letter-spacing: -.04em; line-height: 1; max-width: 18ch; }
.leistungen { display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px; list-style: none; border-top: 1px solid var(--linie); padding-top: 24px; }
.leistungen li { display: grid; gap: 10px; }
.leistungen strong { font-size: 19px; font-weight: 600; letter-spacing: -.01em; }
.leistungen span:not(.mono) { font-size: 15px; line-height: 1.5; color: rgba(22, 21, 19, .72); }
.fuss { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }

.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .danach { display: none; }
.vorschau .buehne { border-top: 0; }

@media (max-width: 900px) { .leistungen { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) {
  .kopf { min-height: 34svh; gap: 28px; }
  .kopf__zeile { flex-direction: column; gap: 4px; }
  .leiste-unten .werte { order: 2; width: 100%; }
  .leistungen { grid-template-columns: 1fr; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
  <div class="kopf__zeile">
    <p class="mono"><b>Studio Kante</b> · Marken &amp; Erscheinungsbilder</p>
    <p class="mono">Köln · seit 2014</p>
  </div>
  <h1>Marken, die man <em>wiedererkennt.</em></h1>
  <p class="mono">Ausgewählte Arbeiten ↓</p>
</header>

<div class="huelle">
<section class="buehne" aria-labelledby="werkschau">
  <div class="leiste-oben">
    <p class="mono">Werkschau · <b>2019–2025</b></p>
    <div class="modi mono" role="group" aria-label="Umsetzung">
      <button type="button" data-modus="gsap" aria-pressed="true">GSAP · Pin</button>
      <button type="button" data-modus="snap" aria-pressed="false">Mit Snap</button>
      <button type="button" data-modus="css" aria-pressed="false">Nur CSS</button>
    </div>
    <p class="mono modi-hinweis">Native Leiste · <b>scroll-snap</b></p>
  </div>

  <ul class="spur" data-spur>
    <li class="einleitung">
      <p class="mono">Sieben Projekte</p>
      <h2 id="werkschau">Sieben Marken, <em>ein Streifen.</em></h2>
      <p>Erscheinungsbilder für Cafés, Handwerk, Kultur und Verlag. <span class="nur-pin">Scrollen Sie einfach weiter. Die Arbeiten ziehen seitlich vorbei.</span><span class="nur-leiste">Wischen Sie seitlich durch die Arbeiten.</span></p>
    </li>
    <li class="unten"><a class="karte karte--quer" href="#werkschau">
      <div class="rahmen"><img src="bilder/marke-krume.jpg" alt="Wortmarke krume in fetter dunkelbrauner Schrift auf Sonnengelb, darüber ein Brotlaib" width="1200" height="800"></div>
      <div class="unterzeile"><span class="mono nr">01</span><h3>Krume</h3><span class="mono">Backstube · Erscheinungsbild · 2021</span></div>
    </a></li>
    <li class="oben"><a class="karte karte--hoch" href="#werkschau">
      <div class="rahmen"><img src="bilder/keramik-stillleben-hoch.jpg" alt="Vasen, Becher und Schalen auf weißen Sockeln vor Terrakotta, harte Schatten" width="800" height="1000"></div>
      <div class="unterzeile"><span class="mono nr">02</span><h3>Glasurhaus</h3><span class="mono">Handwerk · Bildsprache · 2019</span></div>
    </a></li>
    <li class="unten"><a class="karte karte--quer" href="#werkschau">
      <div class="rahmen"><img src="bilder/typo-plakat.jpg" alt="Ausstellungsplakat in Neongelb: großes schwarzes LICHT, ein orangefarbener Kreis und die Kursive und was es übrig lässt" width="1200" height="800"></div>
      <div class="unterzeile"><span class="mono nr">03</span><h3>Licht</h3><span class="mono">Ausstellung · Plakatreihe · 2020</span></div>
    </a></li>
    <li class="oben"><a class="karte karte--hoch" href="#werkschau">
      <div class="rahmen"><img src="bilder/marke-roestkontor-hoch.jpg" alt="Wortmarke Röstkontor in breiten weißen Versalien auf Ziegelrot, darüber eine Kaffeebohne im Kreis" width="800" height="1000"></div>
      <div class="unterzeile"><span class="mono nr">04</span><h3>Röstkontor</h3><span class="mono">Rösterei · Wortmarke · 2023</span></div>
    </a></li>
    <li class="unten"><a class="karte karte--quer" href="#werkschau">
      <div class="rahmen"><img src="bilder/flakon.jpg" alt="Bernsteinfarbener Parfumflakon auf weißem Sockel vor kräftigem Blau, daneben eine weiße Kugel" width="1200" height="800"></div>
      <div class="unterzeile"><span class="mono nr">05</span><h3>Hesper</h3><span class="mono">Parfum · Marke &amp; Verpackung · 2025</span></div>
    </a></li>
    <li class="oben"><a class="karte karte--hoch" href="#werkschau">
      <div class="rahmen"><img src="bilder/magazin-doppelseite-hoch.jpg" alt="Aufgeschlagenes Magazin mit dem Titel Die Ruhe der Dinge in Serifenschrift" width="800" height="1000"></div>
      <div class="unterzeile"><span class="mono nr">06</span><h3>Die Ruhe der Dinge</h3><span class="mono">Verlag · Editorial · 2024</span></div>
    </a></li>
    <li class="unten"><a class="karte karte--quer" href="#werkschau">
      <div class="rahmen"><img src="bilder/marke-eselsohr.jpg" alt="Wortmarke Eselsohr in heller Kursive auf Anthrazit, daneben ein Blatt mit umgeknickter rosa Ecke" width="1200" height="800"></div>
      <div class="unterzeile"><span class="mono nr">07</span><h3>Eselsohr</h3><span class="mono">Buchhandlung · Logo &amp; Schilder · 2022</span></div>
    </a></li>
    <li class="schluss">
      <p class="mono">Nächstes Kapitel</p>
      <p class="gross"><em>Nummer 08?</em></p>
      <p>Für 2027 nehmen wir drei neue Marken an. Erzählen Sie uns von Ihrer.</p>
      <a class="mono" href="#kontakt">Projekt anfragen →</a>
    </li>
  </ul>

  <div class="leiste-unten">
    <span class="fortschritt" aria-hidden="true"><i data-balken></i></span>
    <p class="werte mono" data-werte></p>
    <div class="zaehler mono">
      <span><b data-nr>01</b> / 07</span>
      <span class="blaettern"><button type="button" data-schritt="-1" aria-label="Vorige Arbeit">←</button><button type="button" data-schritt="1" aria-label="Nächste Arbeit">→</button></span>
    </div>
  </div>
</section>
</div>

<section class="danach" id="kontakt">
  <h2>Danach läuft die Seite wieder senkrecht. <em>Wie gewohnt.</em></h2>
  <ul class="leistungen">
    <li><span class="mono">01</span><strong>Wortmarken</strong><span>Zeichen, die auf dem Kassenbon genauso halten wie auf der Fassade.</span></li>
    <li><span class="mono">02</span><strong>Erscheinungsbilder</strong><span>Farbe, Schrift und Regeln, die ein Team selbst anwenden kann.</span></li>
    <li><span class="mono">03</span><strong>Schilder &amp; Räume</strong><span>Vom Fensterbild bis zur Speisekarte an der Wand.</span></li>
    <li><span class="mono">04</span><strong>Editorial</strong><span>Magazine, Kataloge und Jahresberichte mit klarem Raster.</span></li>
  </ul>
  <div class="fuss"><p class="mono">Studio Kante · Ehrenfeld, Köln</p><p class="mono">hallo@studio-kante.example</p></div>
</section>

<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/ScrollTrigger.min.js"></script>
<script>
(() => {
  gsap.registerPlugin(ScrollTrigger);
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const buehne = document.querySelector('.buehne');
  const huelle = document.querySelector('.huelle');
  const spur = document.querySelector('[data-spur]');
  const karten = [...spur.querySelectorAll('.karte')];
  const bilder = karten.map(k => k.querySelector('.rahmen img'));
  const balken = document.querySelector('[data-balken]');
  const nrFeld = document.querySelector('[data-nr]');
  const werte = document.querySelector('[data-werte]');
  const kannCss = CSS.supports('animation-timeline: view()');
  const telefon = matchMedia('(max-width: 760px), (hover: none) and (pointer: coarse)');
  const rem = () => parseFloat(getComputedStyle(html).fontSize) || 16;

  let modus = 'gsap', aktiv = '', tween = null, st = null, ueber = 0;

  // Überlänge: so weit muss die Spur nach links, bis ihr rechtes Ende am Rand steht
  const messen = () => (ueber = Math.max(0, spur.scrollWidth - buehne.clientWidth));
  const px = n => Math.round(n).toLocaleString('de-DE');

  function zeigeWerte() {
    if (aktiv === 'leiste') {
      werte.innerHTML = reduziert()
        ? '<span>Reduzierte Bewegung: <b>kein Pin, freie Leiste</b></span><span>Snap <b>x mandatory</b></span>'
        : '<span>Kein Pin · <b>overflow-x: auto</b></span><span>Snap <b>x mandatory</b></span>';
      return;
    }
    const pin = `<span>Pin <b>+=${px(ueber)} px</b></span>`;
    const weg = `<span>x <b>0 → −${px(ueber)} px</b></span>`;
    const bild = '<span>Bild <b>0 → 4rem</b></span>';
    werte.innerHTML = aktiv === 'css'
      ? `<span>Kurve <b>linear</b></span><span>sticky + <b>view-timeline</b></span>${weg.replace('x <b>0', 'translateX <b>0')}${bild}`
      : `<span>Kurve <b>none</b></span><span>scrub <b>true</b></span>${pin}${weg}${bild}${aktiv === 'snap' ? '<span>Snap <b>Kartenmitte · expo.out</b></span>' : ''}`;
  }

  // Fortschritt und Zähler (aktive Karte = die, deren Mitte der Bildmitte am nächsten ist)
  function anzeigen(p, versatz) {
    balken.style.setProperty('--p', p.toFixed(4));
    const mitte = versatz + buehne.clientWidth / 2;
    let best = 0, abstand = Infinity;
    karten.forEach((k, i) => {
      const li = k.parentElement, m = li.offsetLeft + li.offsetWidth / 2, d = Math.abs(m - mitte);
      if (d < abstand) { abstand = d; best = i; }
    });
    nrFeld.textContent = String(best + 1).padStart(2, '0');
  }

  // Ziel-Versatz, damit eine Karte mittig steht
  const mittig = i => {
    const li = karten[i].parentElement;
    return gsap.utils.clamp(0, ueber, li.offsetLeft + li.offsetWidth / 2 - buehne.clientWidth / 2);
  };

  function abbauen() {
    ScrollTrigger.getAll().forEach(t => t.kill());
    if (tween) tween.kill();
    tween = st = null;
    gsap.set([spur, ...bilder, ...spur.querySelectorAll('.unterzeile')], { clearProps: 'all' });
    html.classList.remove('leiste', 'css-modus');
    balken.style.removeProperty('--p');
    spur.scrollLeft = 0;
  }

  function aufbauen() {
    abbauen();
    aktiv = (reduziert() || telefon.matches) ? 'leiste' : (modus === 'css' && !kannCss ? 'gsap' : modus);
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === aktiv)));

    if (aktiv === 'leiste') {
      html.classList.add('leiste');
      leisteAnzeigen();
    } else if (aktiv === 'css') {
      html.classList.add('css-modus');
      messen();
      huelle.style.setProperty('--ueberlaenge', ueber + 'px');
      cssAnzeigen();
    } else {
      messen();
      // Die Container-Animation muss linear laufen (ease: 'none'), sonst stimmen die inneren Trigger nicht
      tween = gsap.to(spur, {
        x: () => -ueber,
        ease: 'none',
        scrollTrigger: {
          trigger: buehne,
          start: 'top top',
          end: () => '+=' + ueber,
          pin: true,
          scrub: true,
          anticipatePin: 1,
          invalidateOnRefresh: true,
          onRefresh: () => { messen(); zeigeWerte(); },
          onUpdate: self => anzeigen(self.progress, self.progress * ueber),
          snap: aktiv === 'snap' ? {
            snapTo: () => karten.map((_, i) => mittig(i) / (ueber || 1)),
            duration: { min: 0.3, max: 0.8 }, delay: 0.08, ease: 'expo.out',
          } : false,
        },
      });
      st = tween.scrollTrigger;
      // Innen: Bild wandert 0 → 4rem, an die horizontale Position der Karte gekoppelt
      karten.forEach((k, i) => {
        gsap.fromTo(bilder[i], { x: 0 }, {
          x: () => 4 * rem(), ease: 'none',
          scrollTrigger: { containerAnimation: tween, trigger: k, start: 'left right', end: 'right left', scrub: true, invalidateOnRefresh: true },
        });
        gsap.from(k.querySelector('.unterzeile'), {
          opacity: 0, y: 24, ease: 'none',
          scrollTrigger: { containerAnimation: tween, trigger: k, start: 'left 96%', end: 'left 66%', scrub: true },
        });
      });
      anzeigen(0, 0);
    }
    zeigeWerte();
  }

  // CSS-Variante: Fortschritt nur für Balken-Zähler aus der Scrollposition lesen
  function cssAnzeigen() {
    if (aktiv !== 'css') return;
    const oben = huelle.getBoundingClientRect().top;
    const p = gsap.utils.clamp(0, 1, -oben / (ueber || 1));
    anzeigen(p, p * ueber);
  }
  function leisteAnzeigen() {
    if (aktiv !== 'leiste') return;
    const max = spur.scrollWidth - spur.clientWidth;
    anzeigen(max ? spur.scrollLeft / max : 0, spur.scrollLeft);
  }
  addEventListener('scroll', cssAnzeigen, { passive: true });
  spur.addEventListener('scroll', leisteAnzeigen, { passive: true });

  // Tastatur: Fokus auf einer Karte außerhalb des Bilds zieht die Scrollposition nach
  spur.addEventListener('focusin', e => {
    const k = e.target.closest('.karte');
    if (!k || aktiv === 'leiste') return;
    const start = aktiv === 'css' ? huelle.offsetTop : st.start;
    scrollTo(0, start + mittig(karten.indexOf(k)));
  });
  karten.forEach(k => k.addEventListener('click', e => e.preventDefault()));

  // Pfeiltasten der Leiste
  document.querySelectorAll('[data-schritt]').forEach(b => b.addEventListener('click', () => {
    const li = karten[0].parentElement;
    spur.scrollBy({ left: +b.dataset.schritt * (li.offsetWidth + parseFloat(getComputedStyle(spur).columnGap || 40)), behavior: reduziert() ? 'auto' : 'smooth' });
  }));

  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b || b.disabled) return;
    const lief = !!uhr;
    pause();
    modus = b.dataset.modus;
    aufbauen();
    if (lief) vorfuehren();
  });
  if (!kannCss) { const b = document.querySelector('[data-modus="css"]'); b.disabled = true; b.title = 'Dieser Browser kann noch keine scroll-gekoppelten CSS-Animationen'; }

  telefon.addEventListener('change', () => { pause(); aufbauen(); });
  new MutationObserver(() => { const r = reduziert(); if (r !== war) { war = r; pause(); aufbauen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
  let war = reduziert();
  addEventListener('resize', () => { if (aktiv === 'css') { messen(); huelle.style.setProperty('--ueberlaenge', ueber + 'px'); zeigeWerte(); } });

  // Vorführung: Selbst-Scroll durch die gepinnte Strecke und wieder zurück (Schleife)
  let uhr = null, t0 = 0, weiterAb = 0;
  const RUNDE = 11000;
  const sanft = q => 0.5 - 0.5 * Math.cos(Math.PI * gsap.utils.clamp(0, 1, q));
  function lage(p) {              // 0..1 Runde → 0..1 Strecke
    if (p < 0.06) return 0;
    if (p < 0.72) return sanft((p - 0.06) / 0.66);
    if (p < 0.8) return 1;
    return 1 - sanft((p - 0.8) / 0.2);
  }
  function vorfuehren() {
    pause();
    if (aktiv === 'snap') return snapVorfuehren();
    t0 = performance.now() - weiterAb; weiterAb = 0;
    if (aktiv === 'leiste') spur.style.scrollSnapType = 'none';
    const schritt = t => {
      const q = lage(((t - t0) % RUNDE) / RUNDE);
      if (aktiv === 'leiste') spur.scrollLeft = (spur.scrollWidth - spur.clientWidth) * q;
      else scrollTo(0, (aktiv === 'css' ? huelle.offsetTop : st.start) + ueber * q);
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  // Snap-Variante: Karte für Karte anfahren (expo.out), kurz halten, weiter
  let snapI = 0, snapT = null;
  function snapVorfuehren() {
    const ziele = [0, ...karten.map((_, i) => mittig(i)), ueber];
    const fahre = () => {
      const von = scrollY, nach = st.start + ziele[snapI];
      const o = { y: von };
      snapT = gsap.to(o, { y: nach, duration: 1, ease: 'expo.out', onUpdate: () => scrollTo(0, o.y), onComplete: () => {
        snapI = (snapI + 1) % ziele.length;
        uhr = setTimeout(fahre, snapI === 0 ? 1600 : 700);
      } });
    };
    uhr = setTimeout(fahre, 200);
  }
  function pause() {
    if (uhr) { cancelAnimationFrame(uhr); clearTimeout(uhr); }
    if (snapT) snapT.kill();
    uhr = snapT = null;
    spur.style.scrollSnapType = '';
  }
  function neustart() { pause(); snapI = 0; scrollTo(0, 0); aufbauen(); }

  document.addEventListener('DOMContentLoaded', aufbauen);
  addEventListener('load', () => { if (aktiv === 'gsap' || aktiv === 'snap') ScrollTrigger.refresh(); });
  document.fonts.ready.then(() => { if (aktiv === 'gsap' || aktiv === 'snap') ScrollTrigger.refresh(); });

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