Gepinnte Scrub-Sequenz (Scrollytelling)
Eine Logo-Fallstudie in vier Kapiteln: Die Bühne bleibt stehen, der Scroll zeichnet, färbt und enthüllt das fertige Zeichen und spult beim Zurückscrollen exakt zurück.
Eine Section mit 100vh wird an der Oberkante fixiert (pin). Während man eine festgelegte Strecke weiterscrollt, etwa 200 bis 400 % der Viewporthöhe, läuft eine Timeline mit mehreren Schritten synchron zum Scrollbalken: Headline wechselt, Bild skaliert, Zahlen zählen hoch, Ebenen schieben sich ein. Am Ende der Strecke wird die Section freigegeben und der folgende Inhalt schiebt nach. Rückwärts scrollen spult die Timeline exakt zurück.
Der Nutzer bestimmt das Tempo selbst und erlebt die Abfolge wie einen Film. Nichts fliegt am Rand vorbei. Der feste Bildausschnitt bündelt den Blick und trägt auch Vorher-nachher-Erklärungen.
Wann einsetzen
Produkt- und Prozesserklärungen, Kapitel einer Case Study, Jahresberichte, ein Portfolio-Intro mit drei bis fünf klaren Schritten.
Wann nicht
Bei langen Lesetexten und Formularen oder wenn der Inhalt auch ohne Choreografie verständlich ist. Höchstens ein bis zwei solcher Sequenzen pro Seite, sonst fühlt sich die Seite gefangen an.
Varianten
- Kapitel-Sequenz mit snap auf die Timeline-Labels
- Split-Screen: Der Text scrollt normal, das Bild daneben bleibt sticky und wechselt pro Textblock
- Nur CSS: position: sticky und view-timeline auf dem hohen Wrapper, animation-range: contain 0% contain 100%
- Zahlen- und Diagramm-Story: Zähler und SVG-Balken hängen am Scroll
Umsetzung
gsap.timeline({ scrollTrigger: { trigger: '.story', pin: true, start: 'top top', end: '+=200%', scrub: 0.6, anticipatePin: 1 } }) mit ease: 'none' für alle Schritte. Kapitel mit tl.addLabel('k2') trennen; für Einrasten snap: { snapTo: 'labels', duration: { min: 0.2, max: 0.6 }, ease: 'power3' } ergänzen. Ein Pin pro ScrollTrigger. pinSpacing ist standardmäßig an, in display:flex-Eltern aus. Fallstricke: Ein Pin in transformierten Eltern bricht. Bilder ohne feste Höhe verschieben Start und Ende, daher ScrollTrigger.refresh() nach dem Laden von Bildern und Schriften. Mobil mit gsap.matchMedia() eine kürzere Strecke oder keinen Pin wählen. CSS-Variante: Wrapper height: 400vh, Kind position: sticky; top: 0, Animationen mit animation-timeline: --story und animation-range: contain 0% contain 100%.
Robust umsetzen
Telefon. Strecke kürzen, etwa 150 bis 200 % (TRIONN nutzt auf Touch +=150 %). normalizeScroll nur bei springender Adressleiste einsetzen. Auf sehr kleinen Bildschirmen den Pin aufheben und die Schritte untereinander stapeln.
Ohne Bewegung. Pin aufheben und die Schritte als statische Abfolge untereinander zeigen. Zustandswechsel als Überblendung ohne Bewegung.
Barrierefrei. Alle Inhalte im DOM lassen, nicht nur im Canvas. Kapitelüberschriften als echte h2 oder h3. Tastaturnutzer müssen die Sequenz mit Pfeiltasten und Leertaste ohne Sprünge durchlaufen können. Nichts darf nur über eine Scrollposition erreichbar sein, Kapitelmarken als Buttons helfen beim Anspringen.
Code
<!DOCTYPE html>
<!--
Gepinnte Scrub-Sequenz (Scrollytelling)
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/pinned-scrub-scrollytelling/
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 Scrub-Sequenz (Scrollytelling) · 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-style: italic; font-weight: 400; font-display: swap; }
@font-face { font-family: 'Jost'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/jost@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
:root {
--grund: #121413; --text: #f1eee7; --leise: rgba(241, 238, 231, .58); --linie: rgba(241, 238, 231, .14);
--papier: #ece8de; --tinte: #1f272a; --ziegel: #d14129; --akzent: #e2674c; --kreide: #f3e8d6; --bohne: #261815;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, nur für UI und Split-Screen */
--rand: clamp(16px, 3.4vw, 48px);
}
* { 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 { background: var(--grund); overflow-x: clip; }
.mono { font: 500 11px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.mono b { color: var(--text); font-weight: 500; }
/* Kopf */
.kopf { min-height: 74svh; display: grid; grid-template-rows: auto 1fr auto; align-items: end; gap: 32px; padding: var(--rand); }
.zeile { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; }
.kopf__mitte { display: flex; justify-content: space-between; align-items: end; gap: 32px; flex-wrap: wrap; }
.daten { display: grid; grid-template-columns: auto auto; gap: 10px 28px; padding-bottom: 14px; }
.daten dd { color: var(--text); }
.titel { align-self: end; font-size: clamp(48px, 8.4vw, 132px); font-weight: 600; letter-spacing: -.048em; line-height: .9; }
.titel em { font: italic 400 1.04em/1 'Instrument Serif', serif; letter-spacing: -.02em; color: var(--akzent); }
.lead { max-width: 40ch; font-size: clamp(16px, 1.3vw, 19px); line-height: 1.45; color: var(--leise); }
.lead b { color: var(--text); font-weight: 500; }
.werte { max-width: 52ch; text-align: right; }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: background-color .15s, color .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: var(--leise); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:disabled { opacity: .35; cursor: not-allowed; }
button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
/* Story: gepinnte Bühne */
.story { height: 100svh; display: grid; grid-template-columns: minmax(280px, .78fr) 1.6fr; grid-template-rows: auto minmax(0, 1fr) auto; gap: 22px clamp(24px, 4vw, 64px); padding: 22px var(--rand); background: var(--grund); }
.story__kopf { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto 1fr; align-items: baseline; gap: 16px; white-space: nowrap; border-bottom: 1px solid var(--linie); padding-bottom: 14px; }
.story__text { position: relative; }
.kapitel { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; gap: 18px; }
.kapitel h2 { font-size: clamp(30px, 3.3vw, 54px); font-weight: 560; letter-spacing: -.036em; line-height: 1; text-wrap: balance; }
.kapitel p:not(.mono) { font-size: clamp(15px, 1.1vw, 17px); line-height: 1.5; color: var(--leise); max-width: 34ch; }
.kapitel .nr { color: var(--akzent); }
.pin .kapitel:not(:first-child) { opacity: 0; visibility: hidden; }
.fakten { display: flex; gap: clamp(18px, 2.4vw, 36px); margin-top: 6px; }
.fakten div { display: grid; gap: 4px; }
.fakten dd { font-size: clamp(34px, 3.6vw, 58px); font-weight: 600; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; }
.fakten dt { order: 2; }
.buehne { position: relative; overflow: hidden; border-radius: 6px; background: var(--papier); min-height: 0; isolation: isolate; }
.nacht { position: absolute; inset: 0; background: var(--ziegel); clip-path: inset(100% 0% 0% 0%); }
.buehne svg, .ergebnis { position: absolute; inset: 0; width: 100%; height: 100%; }
.ergebnis { object-fit: cover; clip-path: inset(34% 37% 30% 37% round 999px); opacity: 0; z-index: 3; }
.raster { opacity: 0; }
.raster * { fill: none; stroke: var(--tinte); stroke-width: 1; vector-effect: non-scaling-stroke; }
.raster .gestrichelt { stroke-dasharray: 5 6; }
.raster text { fill: var(--tinte); stroke: none; font: 500 19px 'Geist Mono', monospace; letter-spacing: .1em; }
.strich { fill: none; stroke: var(--tinte); stroke-dasharray: 1; stroke-dashoffset: 1; }
.bohne { stroke-width: 6; fill: rgba(38, 24, 21, 0); }
.kerbe { stroke-width: 15; stroke-linecap: round; stroke-linejoin: round; }
.marke text { fill: var(--kreide); opacity: 0; font-family: 'Jost', 'Instrument Sans', sans-serif; font-weight: 500; }
.wort { font-size: 130px; letter-spacing: .6em; }
.unter { font-size: 14px; letter-spacing: .4em; }
.klein { font-size: 13px; letter-spacing: .12em; }
.zitat { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 18px; padding: 8%; text-align: center; color: var(--tinte); z-index: 2; }
.zitat blockquote { font: italic 400 clamp(26px, 3.2vw, 54px)/1.04 'Instrument Serif', serif; max-width: 15ch; letter-spacing: -.01em; }
.zitat .mono { color: rgba(31, 39, 42, .7); }
.farben { position: absolute; left: 4%; bottom: 5%; display: flex; gap: 14px; z-index: 2; }
.farben span { display: flex; align-items: center; gap: 8px; color: rgba(241, 238, 231, .7); opacity: 0; }
.farben i { width: 14px; height: 14px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1px rgba(241, 238, 231, .3); }
/* Fortschritt mit Kapitelmarken */
.story__fuss { grid-column: 1 / -1; }
.spur { position: relative; height: 40px; }
.spur__linie, .spur__fuellung { position: absolute; left: 0; right: 0; top: 7px; height: 1px; background: var(--linie); }
.spur__fuellung { background: var(--akzent); transform-origin: 0 50%; transform: scaleX(0); }
.marke-k { position: absolute; top: 0; display: grid; justify-items: start; gap: 8px; font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 0; cursor: pointer; padding: 0; transition: color .15s; }
.marke-k::before { content: ''; width: 15px; height: 15px; border-radius: 50%; background: var(--grund); box-shadow: inset 0 0 0 1px var(--leise); transition: background-color .15s, box-shadow .15s; }
.marke-k:nth-child(3) { left: 0; }
.marke-k:nth-child(4) { left: 33.333%; transform: translateX(-7px); }
.marke-k:nth-child(5) { left: 66.666%; transform: translateX(-7px); }
.marke-k:nth-child(6) { right: 0; justify-items: end; }
.marke-k.an { color: var(--text); }
.marke-k.an::before { background: var(--akzent); box-shadow: inset 0 0 0 1px var(--akzent); }
.live { text-align: center; font-variant-numeric: tabular-nums; }
.zaehler { text-align: right; }
/* Variante Split-Screen: Text scrollt, Bühne bleibt sticky */
.split .story { height: auto; grid-template-rows: auto auto; align-items: start; }
.split .story__text { display: grid; }
.split .kapitel { position: static; min-height: 78svh; opacity: .28; transition: opacity .6s var(--kurve); }
.split .kapitel.aktiv { opacity: 1; }
.split .buehne { position: sticky; top: 11svh; height: 78svh; margin: 11svh 0; grid-column: 2; grid-row: 2; }
.split .story__text { grid-column: 1; grid-row: 2; padding: 11svh 0; }
.split .story__fuss { display: none; }
/* Abspann: zeigt, dass die Section danach freigegeben wird */
.abspann { display: grid; grid-template-columns: 1fr 1.2fr; gap: clamp(24px, 4vw, 64px); align-items: end; padding: 18vh var(--rand) 14vh; border-top: 1px solid var(--linie); }
.abspann h3 { font-size: clamp(30px, 3.6vw, 58px); font-weight: 560; letter-spacing: -.036em; line-height: 1; margin-top: 16px; text-wrap: balance; }
.abspann h3 em { font: italic 400 1.05em 'Instrument Serif', serif; color: var(--akzent); }
.abspann img { display: block; width: 100%; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 6px; }
/* Reduzierte Bewegung: kein Pin, Kapitel untereinander, Bühne blendet nur über */
.reduziert .kapitel { transition: none; }
.vorschau body { overflow: hidden; }
.vorschau .kopf, .vorschau .abspann { display: none; }
@media (max-width: 1100px) { .zusatz { display: none; } }
@media (max-width: 760px) {
.kopf { min-height: 0; gap: 28px; padding-bottom: 48px; }
.daten { padding-bottom: 0; }
.story { grid-template-columns: minmax(0, 1fr); }
.story__kopf { grid-template-columns: minmax(0, 1fr) auto; }
.story__kopf .live, .story__kopf span:first-child span, .story__kopf .vor { display: none; }
.werte { text-align: left; }
.story { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(210px, auto) minmax(0, 1fr) auto; gap: 16px; padding: 16px var(--rand); }
.story__text { min-height: 214px; }
.kapitel { justify-content: flex-start; gap: 12px; }
.kapitel h2 { font-size: clamp(27px, 8vw, 34px); }
.fakten dd { font-size: 34px; }
.marke-k span { display: none; }
.split .story { grid-template-rows: auto auto auto; }
.split .buehne { grid-column: 1; grid-row: 2; top: 0; height: 46svh; margin: 0; z-index: 2; box-shadow: 0 -16px 0 var(--grund); }
.split .story__text { grid-column: 1; grid-row: 3; padding: 4svh 0 30svh; }
.split .kapitel { min-height: 52svh; }
.farben { gap: 10px; } .farben span { font-size: 9px; }
.abspann { grid-template-columns: 1fr; padding: 12vh var(--rand); }
}
/* Niedrige Fenster (z. B. 200 % Zoom): Text und Bühne nebeneinander, damit die Bühne Höhe behält */
@media (max-width: 760px) and (max-height: 500px) and (min-width: 520px) {
.story { grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); grid-template-rows: auto minmax(0, 1fr) auto; gap: 12px 20px; padding-block: 12px; }
.story__text { min-height: 0; }
.kapitel { justify-content: center; gap: 8px; }
.kapitel h2 { font-size: 24px; }
.kapitel p:not(.mono) { font-size: 14px; line-height: 1.4; }
.fakten dd { font-size: 26px; }
.zitat { gap: 10px; padding: 6%; }
}
@media (hover: none) { .modi button:hover { color: var(--leise); border-color: var(--linie); } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<div class="zeile">
<p class="mono">Scroll · Pin + Scrub</p>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-modus="scrub" aria-pressed="true">Scrub</button>
<button type="button" data-modus="snap" aria-pressed="false">Kapitel-Snap</button>
<button type="button" data-modus="split" aria-pressed="false">Split-Screen</button>
</div>
</div>
<div class="kopf__mitte">
<h1 class="titel">Eine Bohne,<br><em>vier Kapitel.</em></h1>
<dl class="daten mono">
<dt>Kunde</dt><dd>Röstkontor</dd>
<dt>Leistung</dt><dd>Zeichen, Schrift, Farbe</dd>
<dt>Studio</dt><dd>Atelier Mahlgrad</dd>
<dt>Jahr</dt><dd>2026</dd>
</dl>
</div>
<div class="zeile">
<p class="lead"><b>Fallstudie Kaffeerösterei.</b> Die Bühne bleibt stehen, Ihr Scrollen spult die Geschichte vor und zurück.</p>
<p class="mono werte" data-werte></p>
</div>
</header>
<section class="story" aria-label="Fallstudie Röstkontor in vier Kapiteln" data-story>
<div class="story__kopf mono">
<span><span class="vor">Fallstudie · </span><b>Röstkontor</b><span class="zusatz">, Kaffeerösterei</span></span>
<span class="live" data-live aria-hidden="true">Scroll 0.00 → Timeline 0.00</span>
<span class="zaehler">Kapitel <b data-kap>01</b> / 04</span>
</div>
<div class="story__text">
<article class="kapitel" id="k1">
<p class="mono nr">01 · Zuhören</p>
<h2>Erst zuhören, dann zeichnen.</h2>
<p>Eine Rösterei in einem alten Kontorhaus: vorne der Ausschank, hinten der Trommelröster. Das Zeichen soll beides tragen, ohne nach Kaffeekette auszusehen.</p>
</article>
<article class="kapitel" id="k2">
<p class="mono nr">02 · Skizzieren</p>
<h2>Aus Kreis und Kerbe wird eine Bohne.</h2>
<p>Ein Kreis, zwei halbe Kreise für die Kerbe, eine Grundlinie. Wir zeichnen auf einem festen Raster, damit die Kurven auch auf dem Kassenbon noch halten.</p>
</article>
<article class="kapitel" id="k3">
<p class="mono nr">03 · Verdichten</p>
<h2>Ziegel, Bohne und viel Luft.</h2>
<p>Ein warmer Grund wie die Ziegel im Kontor, ein dunkles Braun für die Bohne. Die Versalien laufen breit und bleiben unter dem Zeichen ruhig.</p>
</article>
<article class="kapitel" id="k4">
<p class="mono nr">04 · Übergeben</p>
<h2>Ein Zeichen für Tüte, Schild und Tasse.</h2>
<dl class="fakten">
<div><dt class="mono">Skizzen</dt><dd data-zahl="14">14</dd></div>
<div><dt class="mono">Runden</dt><dd data-zahl="3">3</dd></div>
<div><dt class="mono">Linie</dt><dd data-zahl="1">1</dd></div>
</dl>
</article>
</div>
<div class="buehne" data-buehne>
<div class="nacht"></div>
<svg viewBox="0 0 800 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<g class="raster">
<line x1="0" y1="327" x2="800" y2="327" class="gestrichelt"/>
<line x1="400" y1="100" x2="400" y2="900" class="gestrichelt"/>
<circle cx="400" cy="327" r="84"/>
<circle cx="400" cy="285" r="42"/>
<circle cx="400" cy="369" r="42"/>
<circle cx="400" cy="327" r="150" class="gestrichelt"/>
<line x1="40" y1="571" x2="760" y2="571" class="gestrichelt"/>
<line x1="40" y1="688" x2="760" y2="688" class="gestrichelt"/>
<text x="500" y="226">R 84</text>
<text x="456" y="446">R 42</text>
<text x="40" y="722">Grundlinie</text>
</g>
<path class="strich bohne" pathLength="1" d="M400 243 A84 84 0 1 1 399.9 243 Z"/>
<path class="strich kerbe" pathLength="1" d="M400 243 A42 42 0 0 1 400 327 A42 42 0 0 0 400 411"/>
<g class="marke">
<text class="wort" x="400" y="571" text-anchor="middle">RÖST</text>
<text class="wort" x="400" y="688" text-anchor="middle">KONTOR</text>
<text class="klein" x="40" y="60">KAFFEERÖSTEREI</text>
<text class="unter" x="400" y="763" text-anchor="middle">BOHNEN · RÖSTUNG · AUSSCHANK</text>
<text class="klein" x="760" y="60" text-anchor="end">Nº 07 — FILTER</text>
</g>
</svg>
<div class="zitat">
<blockquote>„Es soll nach Röstung riechen und ein bisschen nach altem Papier.“</blockquote>
<p class="mono">Aus dem Briefing · März 2026</p>
</div>
<div class="farben mono" aria-hidden="true">
<span style="--c:#d14129"><i></i>Ziegel D14129</span>
<span style="--c:#261815"><i></i>Bohne 261815</span>
<span style="--c:#f3e8d6"><i></i>Creme F3E8D6</span>
</div>
<img class="ergebnis" src="bilder/marke-roestkontor-hoch.jpg" alt="Fertiges Zeichen: dunkle Bohne mit roter S-Kerbe im Kreis, darunter Röstkontor in breiten cremefarbenen Versalien auf Ziegelrot" width="800" height="1000">
</div>
<div class="story__fuss mono">
<div class="spur" role="group" aria-label="Kapitel anspringen">
<span class="spur__linie"></span><span class="spur__fuellung" data-fuellung></span>
<button type="button" class="marke-k an" data-ziel="0"><span>01 Zuhören</span></button>
<button type="button" class="marke-k" data-ziel="1"><span>02 Skizzieren</span></button>
<button type="button" class="marke-k" data-ziel="2"><span>03 Verdichten</span></button>
<button type="button" class="marke-k" data-ziel="3"><span>04 Übergeben</span></button>
</div>
</div>
</section>
<section class="abspann" aria-label="Nächste Fallstudie">
<div>
<p class="mono">Nächste Fallstudie · 02 / 09</p>
<h3>Krume: <em>eine Backstube</em> zeigt, was im Ofen liegt.</h3>
</div>
<img src="bilder/marke-krume.jpg" alt="Wortmarke krume: fette dunkelbraune Kleinbuchstaben unter einem Brotlaib auf Senfgelb" width="1200" height="800" loading="lazy">
</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 touch = matchMedia('(hover: none)').matches;
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const story = $('[data-story]'), buehne = $('[data-buehne]');
const kapitel = $$('.kapitel'), marken = $$('.marke-k'), zahlen = $$('[data-zahl]');
const live = $('[data-live]'), kapAnzeige = $('[data-kap]'), fuellung = $('[data-fuellung]');
// Gemessene Werte (TRIONN Keyfacts-Timeline): pin, start top top, end +=200 % (Touch +=150 %), scrub .6, ease none
const WERTE = { start: 'top top', end: touch ? '+=150%' : '+=200%', scrub: 0.6, anticipatePin: 1, ease: 'none',
snap: { snapTo: 'labels', duration: { min: 0.2, max: 0.6 }, ease: 'power3' }, split: { dauer: 1.2, ease: 'expo.out' } };
const TEXT = {
scrub: `pin · start top top · end ${WERTE.end} · scrub 0.6 · ease none · anticipatePin 1`,
snap: `wie Scrub + snap labels · 0.2–0.6 s · power3`,
split: `position sticky · Kapitel-Wechsel tweenTo 1.2 s expo.out`,
};
let modus = 'scrub', ctx = null, tl = null, aktivK = 0;
// Bühnen-Choreografie: Kapitel k liegt auf Label k (0–3), Übergang jeweils von k−0.85 bis k−0.15
function buehnenSchritte(t) {
const zahl = { v: 0 };
t.addLabel('k1', 0).addLabel('k2', 1).addLabel('k3', 2).addLabel('k4', 3);
// 01 → 02: Zitat geht, Raster und Linie werden gezeichnet
t.to('.zitat', { autoAlpha: 0, y: -30, duration: 0.3 }, 0.15)
.to('.raster', { opacity: 0.55, duration: 0.3 }, 0.3)
.to('.bohne', { strokeDashoffset: 0, duration: 0.45 }, 0.4)
.to('.kerbe', { strokeDashoffset: 0, duration: 0.2 }, 0.65)
// 02 → 03: Farbe, Schrift, Farbmuster
.to('.nacht', { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.4 }, 1.15)
.to('.raster', { opacity: 0.12, duration: 0.4 }, 1.15)
.to('.raster *', { stroke: '#f3e8d6', fill: (i, el) => el.tagName === 'text' ? '#f3e8d6' : 'none', duration: 0.4 }, 1.15)
.to('.bohne', { stroke: '#f3e8d6', fill: 'rgba(38, 24, 21, 1)', duration: 0.4 }, 1.15)
.to('.kerbe', { stroke: '#d14129', duration: 0.4 }, 1.15)
.to('.marke text', { opacity: 1, duration: 0.35, stagger: 0.05 }, 1.4)
.fromTo('.wort', { letterSpacing: '0.6em' }, { letterSpacing: '0.22em', duration: 0.45 }, 1.4)
.fromTo('.farben span', { y: 16 }, { y: 0, autoAlpha: 1, duration: 0.3, stagger: 0.08 }, 1.5)
// 03 → 04: Skizze öffnet sich zum fertigen Logo, Zahlen zählen
.to('.farben span', { autoAlpha: 0, duration: 0.2 }, 2.15)
.to('.ergebnis', { opacity: 1, duration: 0.1 }, 2.2)
.fromTo('.ergebnis', { scale: 1.12 }, { clipPath: 'inset(0% 0% 0% 0% round 0px)', scale: 1, duration: 0.6 }, 2.2)
.to(zahl, { v: 1, duration: 0.5, onUpdate: () => zahlen.forEach(d => d.textContent = Math.round(d.dataset.zahl * zahl.v)) }, 2.35);
return t;
}
function anzeigen(scrollP, tlP) {
const k = Math.min(3, Math.round(tlP * 3));
if (k !== aktivK || !kapAnzeige.textContent) { aktivK = k; kapAnzeige.textContent = '0' + (k + 1); marken.forEach((m, i) => m.classList.toggle('an', i <= k)); }
fuellung.style.transform = `scaleX(${tlP.toFixed(4)})`;
live.textContent = `Scroll ${scrollP.toFixed(2)} → Timeline ${tlP.toFixed(2)}`;
}
function aufbauen() {
if (ctx) {
ctx.revert();
// Revert schreibt Startwerte teils als Inline-Stil zurück: sauber leeren (Farbmuster behalten ihr --c)
gsap.set([buehne, ...kapitel, ...kapitel.flatMap(k => [...k.children]), ...$$('.nacht, .zitat, .raster, .raster *, .strich, .marke text, .ergebnis')], { clearProps: 'all' });
gsap.set('.farben span', { clearProps: 'opacity,visibility,transform,translate,rotate,scale' });
}
html.classList.toggle('pin', modus !== 'split');
html.classList.toggle('split', modus === 'split');
$('[data-werte]').textContent = reduziert() ? 'ohne Pin · position sticky · Kapitel-Wechsel als Überblendung 0.25 s linear' : TEXT[modus];
$$('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
kapitel.forEach(k => k.classList.remove('aktiv'));
aktivK = -1;
ctx = gsap.context(() => {
if (!reduziert()) zahlen.forEach(d => d.textContent = '0');
if (modus !== 'split') {
tl = gsap.timeline({
defaults: { ease: WERTE.ease },
scrollTrigger: {
trigger: story, pin: true, start: WERTE.start, end: WERTE.end, scrub: WERTE.scrub, anticipatePin: WERTE.anticipatePin,
snap: modus === 'snap' ? WERTE.snap : false,
},
onUpdate() { anzeigen(this.scrollTrigger ? this.scrollTrigger.progress : 0, this.progress()); },
});
buehnenSchritte(tl);
// Texte: altes Kapitel geht nach oben, neues kommt von unten (linear, an den Scroll gekoppelt)
for (let k = 1; k < 4; k++) {
tl.to(kapitel[k - 1], { autoAlpha: 0, y: -40, duration: 0.3 }, k - 0.85)
.fromTo(kapitel[k].children, { autoAlpha: 0, y: 48 }, { autoAlpha: 1, y: 0, duration: 0.35, stagger: 0.06 }, k - 0.5)
.set(kapitel[k], { autoAlpha: 1 }, k - 0.5);
}
anzeigen(0, 0);
} else {
tl = buehnenSchritte(gsap.timeline({ paused: true, defaults: { ease: WERTE.ease } }));
kapitel.forEach((k, i) => ScrollTrigger.create({
trigger: k, start: 'top 58%', end: 'bottom 58%',
onToggle: self => { if (self.isActive) aktivieren(i); },
}));
aktivieren(0, true);
}
});
ScrollTrigger.refresh();
}
function aktivieren(i, sofort) {
kapitel.forEach((k, j) => k.classList.toggle('aktiv', j === i));
kapAnzeige.textContent = '0' + (i + 1);
live.textContent = `Kapitel ${i + 1} → ${reduziert() ? 'seek' : 'tweenTo'}('k${i + 1}')`;
if (sofort || reduziert()) {
tl.seek('k' + (i + 1));
if (!sofort) gsap.fromTo(buehne, { opacity: 0.2 }, { opacity: 1, duration: 0.25, ease: 'none' }); // Crossfade statt Bewegung
} else {
tl.tweenTo('k' + (i + 1), { duration: WERTE.split.dauer, ease: WERTE.split.ease });
}
}
// Zielpositionen der vier Kapitel im aktuellen Modus (für Kapitelmarken und Vorführung)
function ziele() {
if (modus !== 'split' && tl && tl.scrollTrigger) {
const st = tl.scrollTrigger;
return [0, 1, 2, 3].map(k => st.start + (st.end - st.start) * k / 3);
}
return kapitel.map(k => {
const r = k.getBoundingClientRect();
return Math.max(0, scrollY + r.top + r.height / 2 - innerHeight * 0.58);
});
}
marken.forEach(m => m.addEventListener('click', () => {
pause();
scrollTo({ top: ziele()[+m.dataset.ziel], behavior: reduziert() ? 'auto' : 'smooth' });
}));
$$('[data-modus]').forEach(b => {
b.addEventListener('click', () => {
if (b.dataset.modus === modus) return;
pause(); modus = b.dataset.modus;
const y = story.getBoundingClientRect().top + scrollY;
aufbauen(); scrollTo(0, Math.min(scrollY, y));
});
});
// Vorführung: Selbst-Scroll durch die vier Kapitel (Halt je Kapitel), danach zurückspulen
let uhr = null, t0 = 0;
const HALT = 900, FAHRT = 1500, ZURUECK = 2200;
const weich = x => x * x * (3 - 2 * x);
function position(t, z) {
let rest = t;
for (let i = 0; i < z.length; i++) {
if (rest < HALT) return z[i];
rest -= HALT;
if (i < z.length - 1) { if (rest < FAHRT) return z[i] + (z[i + 1] - z[i]) * weich(rest / FAHRT); rest -= FAHRT; }
}
if (rest < ZURUECK) return z[z.length - 1] + (z[0] - z[z.length - 1]) * weich(rest / ZURUECK);
return null;
}
function vorfuehren() {
pause(); gsap.globalTimeline.resume();
const z = ziele();
scrollTo(0, z[0]);
t0 = performance.now() + 300;
const schritt = jetzt => {
let y = position(Math.max(0, jetzt - t0), z);
if (y === null) { t0 = jetzt; y = z[0]; }
scrollTo(0, y);
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; }
function neustart() { pause(); scrollTo(0, 0); aufbauen(); }
['wheel', 'touchstart', 'keydown'].forEach(e => addEventListener(e, () => { if (!html.classList.contains('vorschau')) pause(); }, { passive: true }));
// Start erst nach dem Rahmen-Skript, damit ?reduziert schon als Klasse am <html> steht
addEventListener('DOMContentLoaded', () => {
if (reduziert()) {
modus = 'split';
$$('[data-modus]').forEach(b => { if (b.dataset.modus !== 'split') { b.disabled = true; b.title = 'Bei reduzierter Bewegung ohne Pin'; } });
}
aufbauen();
});
document.fonts.ready.then(() => ScrollTrigger.refresh());
addEventListener('load', () => ScrollTrigger.refresh());
})();
</script>
</body>
</html>