Sections, die übereinander gleiten (Vorhang-Übergang)
Die aktuelle Section bleibt sticky stehen, die nächste fährt darüber, die alte tritt zurück: Kapitelwechsel mit Tiefe statt harter Kante.
Die aktuelle Section wird sticky (top: 0) und bleibt stehen. Die nächste fährt von unten über sie. Dabei tritt die alte zurück: Sie skaliert auf etwa 0,8, dreht sich leicht (−5°) und dunkelt ab. Die neue kann eine Gegenbewegung machen (Rotation +5° → 0, Scale 0,8 → 1) oder mit gerundeter Oberkante kommen, die beim Ankommen flach wird.
Sections werden zu Ebenen, die sich übereinanderlegen. Der Wechsel hat Tiefe und Rhythmus statt einer harten Kante. So entsteht Kapitelgefühl ohne gepinnte Timeline.
Wann einsetzen
Zwischen Hero und Inhalt, zwischen Kapiteln einer Story oder Fallstudien, vor dem Footer.
Wann nicht
Wenn am unteren Rand der alten Section noch Inhalt gelesen werden muss. Bei Sections, die höher als der Viewport sind: Dort bleibt nur das untere Ende stehen. Dann top auf einen negativen Wert setzen (Section-Höhe minus Viewport-Höhe).
Varianten
- Perspektive: alte Section skaliert und dreht sich weg, neue kommt mit Gegenbewegung
- Abdunkeln: alte Section dunkelt ab und schrumpft kaum (Blur möglich, aber teuer)
- Runde Kante: neue Section kommt mit großer gerundeter Oberkante, die beim Ankommen flach wird
- SVG-Maske: eine Form öffnet die neue Section
- Animation der Sticky-Section beim Verlassen des Viewports (Codrops „Sticky Sections“)
Umsetzung
Alle Sections position: sticky; top: 0; height: 100svh. Spätere Sections liegen im DOM weiter hinten und damit oben. Den Fortschritt der neuen Section aus ihrer Oberkante berechnen (0 am unteren Rand, 1 oben) und als CSS-Variable setzen; die alte Section liest denselben Wert. Rein in CSS geht es mit Scroll-driven Animations: Die nächste Section bekommt eine benannte View-Timeline (view-timeline: --s2; timeline-scope: --s2 am gemeinsamen Elternelement), die alte animiert ihr Inneres darauf: animation: recede linear both; animation-timeline: --s2; animation-range: entry 0% entry 100%; @keyframes recede { to { scale: .8; rotate: -5deg } }. Die eigene view()-Timeline der Sticky-Section taugt dafür nicht, weil sie sich beim Kleben nicht bewegt. Mit GSAP: ScrollTrigger mit trigger: nextSection, start: 'top bottom', end: 'top top', scrub: true, animiert prevSection.inner. Fallstricke: overflow: hidden auf body oder einem Elternelement bricht sticky, overflow: clip nutzen. filter: blur auf großen Flächen ist teuer, lieber ein schwarzes Overlay mit Opacity.
Robust umsetzen
Telefon. Gut geeignet. Rotation weglassen, Scale auf 0,9 abschwächen, gerundete Kante flacher halten.
Ohne Bewegung. Nur die Sticky-Überlagerung ohne Scale und Rotation, oder normaler Fluss.
Barrierefrei. Eine ganz verdeckte Section darf keine fokussierbaren Elemente behalten. Sobald sie vollständig überdeckt ist, inert setzen und ausblenden (visibility: hidden). Jede Section bekommt eine Überschrift oder ein aria-label, damit die Kapitel per Screenreader auffindbar sind.
Code
<!DOCTYPE html>
<!--
Sections, die übereinander gleiten (Vorhang-Übergang)
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/section-overlap-uebergang/
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>Sections, die übereinander gleiten (Vorhang-Übergang) · 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: #000;
--rand: clamp(20px, 4vw, 56px);
/* Werte der aktiven Variante, setzt das Skript (eine Quelle für CSS und Werte-Anzeige) */
--skala: .2; --skala-ein: .2; --rot: 5deg; --rot-ein: 5deg; --dunkel: .5; --radius: 0px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: #f1eee7; font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
.mono { font: 500 11px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
/* ---------- Kopfzeile (fest) ---------- */
.kopf { position: fixed; inset: 0 0 auto; z-index: 20; mix-blend-mode: difference; color: #fff; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 18px var(--rand); pointer-events: none; }
.kopf > * { pointer-events: auto; }
.marke { display: flex; gap: 18px; align-items: baseline; }
.marke b { font: 600 15px/1 'Instrument Sans', sans-serif; letter-spacing: -.01em; text-transform: none; }
.fuss { position: fixed; left: var(--rand); right: var(--rand); bottom: 16px; z-index: 20; display: flex; justify-content: space-between; align-items: end; gap: 10px; pointer-events: none; }
.fuss > * { pointer-events: auto; }
.modi { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: rgba(18, 17, 16, .86); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); box-shadow: inset 0 0 0 1px rgba(241, 238, 231, .08); }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: rgba(241, 238, 231, .62); background: none; border: 0; border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: background-color 150ms linear, color 150ms linear; }
.modi button:hover { color: #f1eee7; }
.modi button[aria-pressed='true'] { background: #f1eee7; color: #121110; }
.modi button:focus-visible { outline: 1px solid #f1eee7; outline-offset: 2px; }
/* ---------- Werte-Anzeige (fest, dezent) ---------- */
.werte { display: flex; flex-wrap: wrap; gap: 3px 14px; max-width: calc(100% - 400px); padding: 9px 14px; border-radius: 12px; background: rgba(18, 17, 16, .86); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); box-shadow: inset 0 0 0 1px rgba(241, 238, 231, .08); color: rgba(241, 238, 231, .56); font-size: 10px; }
.werte b { color: #f1eee7; font-weight: 500; }
.werte [data-fortschritt] { font-variant-numeric: tabular-nums; }
/* ---------- Der Effekt ---------- */
.stapel { overflow-x: clip; } /* clip statt hidden: bricht sticky nicht */
.kapitel { position: sticky; top: 0; height: 100vh; height: 100svh; --ein: 1; --aus: 0; }
.kapitel:last-child { position: relative; }
.innen {
position: relative; height: 100%; overflow: hidden;
background: var(--bg); color: var(--fg);
/* --ein: 0 → 1, solange diese Section von unten hereinfährt; --aus: 0 → 1, solange die nächste sie überdeckt */
scale: calc(1 - var(--skala) * var(--aus) - var(--skala-ein) * (1 - var(--ein)));
rotate: calc(var(--rot-ein) * (1 - var(--ein)) - var(--rot) * var(--aus));
border-top-left-radius: 50% calc(var(--radius) * (1 - var(--ein)));
border-top-right-radius: 50% calc(var(--radius) * (1 - var(--ein)));
will-change: scale, rotate;
}
.kapitel + .kapitel .innen { box-shadow: 0 -24px 60px -12px rgba(0, 0, 0, .45); }
.innen::after { content: ''; position: absolute; inset: 0; background: #000; opacity: calc(var(--dunkel) * var(--aus)); pointer-events: none; }
.kapitel.verdeckt { visibility: hidden; } /* ganz überdeckt: aus dem Bild und aus der Tab-Reihenfolge */
/* ---------- Inhalte ---------- */
.raster { height: 100%; display: grid; grid-template-columns: repeat(12, 1fr); grid-template-rows: auto 1fr auto; column-gap: clamp(16px, 2vw, 28px); padding: clamp(76px, 11vh, 104px) var(--rand) clamp(84px, 12vh, 108px); }
.zeile { grid-column: 1 / -1; display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; color: var(--leise); }
.leise { color: var(--leise); }
.k1 { --bg: #191715; --fg: #f1eee7; --leise: rgba(241, 238, 231, .5); }
.k1 h1 { grid-column: 1 / -1; align-self: center; font-size: clamp(54px, 10.4vw, 168px); font-weight: 600; letter-spacing: -.05em; line-height: .88; }
.k1 h1 em { font-size: 1.08em; letter-spacing: -.02em; }
.k1 .lead { grid-column: 8 / -1; align-self: end; font-size: clamp(16px, 1.35vw, 20px); line-height: 1.45; max-width: 34ch; margin-bottom: 28px; color: rgba(241, 238, 231, .78); }
.fall .nummer { grid-column: 1 / 3; grid-row: 2; align-self: start; margin-top: clamp(16px, 4vh, 40px); font-family: 'Instrument Serif', serif; font-style: italic; font-size: clamp(64px, 8vw, 132px); line-height: .8; color: var(--akzent); }
.fall .text { grid-column: 1 / 6; grid-row: 2; align-self: end; display: grid; gap: 18px; padding-bottom: 6px; }
.fall h2 { font-size: clamp(38px, 5.2vw, 84px); font-weight: 600; letter-spacing: -.045em; line-height: .92; }
.fall p { font-size: clamp(15px, 1.2vw, 18px); line-height: 1.5; max-width: 36ch; }
.fall dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; padding-top: 14px; border-top: 1px solid var(--linie); }
.fall dt { color: var(--leise); }
.fall figure { grid-column: 7 / -1; grid-row: 2; align-self: end; margin-bottom: 6px; }
.fall img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; border-radius: 6px; }
.fall figcaption { margin-top: 10px; color: var(--leise); }
.k2 { --bg: #ece6da; --fg: #1d1b18; --leise: rgba(29, 27, 24, .66); --linie: rgba(29, 27, 24, .16); --akzent: #b9603a; }
.k3 { --bg: #3a3437; --fg: #eef1ee; --leise: rgba(238, 241, 238, .62); --linie: rgba(238, 241, 238, .15); --akzent: #e8aa9b; }
.k4 { --bg: #1f40d4; --fg: #ffffff; --leise: rgba(255, 255, 255, .74); --linie: rgba(255, 255, 255, .22); --akzent: #ffffff; }
.k4 img { box-shadow: 0 30px 60px -30px rgba(0, 0, 40, .5); }
.k5 { --bg: #ee8a4e; --fg: #1a120c; --leise: rgba(26, 18, 12, .76); --linie: rgba(26, 18, 12, .2); }
.k5 h2 { grid-column: 1 / 11; align-self: center; font-size: clamp(48px, 8.4vw, 140px); font-weight: 600; letter-spacing: -.05em; line-height: .9; }
.k5 .aufruf { grid-column: 1 / -1; align-self: end; display: flex; justify-content: space-between; align-items: end; gap: 20px; flex-wrap: wrap; margin-bottom: 26px; }
.k5 a { color: var(--fg); font-size: clamp(18px, 1.6vw, 24px); font-weight: 500; text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 4px; }
.k5 a:focus-visible { outline: 2px solid var(--fg); outline-offset: 6px; }
/* ---------- Reduzierte Bewegung: nur die Sticky-Überlagerung, kein Scale, keine Rotation, keine Rundung ---------- */
.reduziert .innen { scale: none !important; rotate: none !important; border-radius: 0 !important; }
.reduziert .modi button { transition: none; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .modi { display: none; }
.vorschau .werte { max-width: 100%; }
/* ---------- Telefon ---------- */
@media (max-width: 760px) {
.raster { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; padding: 72px 20px 84px; }
.k1 h1 { font-size: clamp(52px, 17vw, 84px); }
.k1 .lead { grid-column: 1; margin-bottom: 16px; }
.fall .inhalt { grid-row: 2; display: grid; align-content: center; gap: 22px; }
.fall .nummer { display: none; }
.fall .text, .fall figure { grid-column: 1; grid-row: auto; }
.fall .text { gap: 12px; }
.fall h2 { font-size: 40px; }
.fall p { font-size: 15px; }
.fall figure { order: -1; }
.fall figcaption { display: none; }
.k5 h2 { grid-column: 1; font-size: 54px; }
.fuss { flex-direction: column; align-items: stretch; left: 12px; right: 12px; bottom: 12px; gap: 6px; }
.modi { justify-content: space-between; }
.modi button { flex: 1; padding: 0 6px; }
.werte { max-width: none; font-size: 9.5px; padding: 7px 12px; justify-content: space-between; }
.werte .lang, .werte [data-w="formel"] { display: none; }
.raster { padding-bottom: 112px; }
}
@media (min-width: 761px) { .fall .inhalt { display: contents; } }
@media (min-width: 761px) and (max-width: 1100px) { .werte .lang { display: none; } }
@media (hover: none) { .modi button { height: 32px; padding: 0 11px; } }
</style>
</head>
<body>
<header class="kopf">
<p class="marke mono"><b>Atelier Kranich</b><span data-zaehler>Kapitel 01 / 05</span></p>
</header>
<main class="stapel">
<section class="kapitel k1" aria-label="Studio">
<div class="innen"><div class="raster">
<div class="zeile mono"><span>Marke & Editorial</span><span>Köln-Ehrenfeld</span></div>
<h1>Zeichen,<br>die <em>bleiben.</em></h1>
<p class="lead">Wir entwickeln Marken für Cafés, Werkstätten und Kultur. Vier Arbeiten aus dem letzten Jahr, Kapitel für Kapitel.</p>
<div class="zeile mono"><span>01 · Studio</span><span>Weiter scrollen ↓</span></div>
</div></div>
</section>
<section class="kapitel k2 fall" aria-labelledby="t2">
<div class="innen"><div class="raster">
<div class="zeile mono"><span>Arbeiten</span><span>2025</span></div>
<span class="nummer" aria-hidden="true">02</span>
<div class="inhalt">
<div class="text">
<h2 id="t2">Die Ruhe der Dinge</h2>
<p>Ein Hausmagazin für eine Keramikwerkstatt. Eine ruhige Antiqua, viel Weißraum und Fotos, die jedem Gefäß Zeit lassen.</p>
<dl class="mono"><dt>Umfang</dt><dd>Editorial · Fotografie · Druck</dd></dl>
</div>
<figure><img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin: links ein Keramik-Stillleben auf Terrakotta, rechts der Titel Die Ruhe der Dinge in Serifenschrift" width="1200" height="800"><figcaption class="mono">Doppelseite, Heft 01</figcaption></figure>
</div>
</div></div>
</section>
<section class="kapitel k3 fall" aria-labelledby="t3">
<div class="innen"><div class="raster">
<div class="zeile mono"><span>Arbeiten</span><span>2025</span></div>
<span class="nummer" aria-hidden="true">03</span>
<div class="inhalt">
<div class="text">
<h2 id="t3">Esels­ohr</h2>
<p>Ein Blatt mit umgeknickter Ecke. Ein Zeichen für eine Buchhandlung, in der man liest, bevor man kauft, und auf einen Kaffee bleibt.</p>
<dl class="mono"><dt>Umfang</dt><dd>Logo · Schaufenster · Tüten</dd></dl>
</div>
<figure><img src="bilder/marke-eselsohr.jpg" alt="Wortmarke Eselsohr in heller Kursive auf Anthrazit, links ein Blatt mit umgeknickter rosa Ecke" width="1200" height="800"><figcaption class="mono">Wortmarke, eine rosa Ecke</figcaption></figure>
</div>
</div></div>
</section>
<section class="kapitel k4 fall" aria-labelledby="t4">
<div class="innen"><div class="raster">
<div class="zeile mono"><span>Arbeiten</span><span>2026</span></div>
<span class="nummer" aria-hidden="true">04</span>
<div class="inhalt">
<div class="text">
<h2 id="t4">Nachtfalter</h2>
<p>Ein Flakon in Bernstein vor Kobalt. Die Marke spricht leise, damit der Duft das letzte Wort behält.</p>
<dl class="mono"><dt>Umfang</dt><dd>Marke · Verpackung · Kampagne</dd></dl>
</div>
<figure><img src="bilder/flakon.jpg" alt="Bernsteinfarbener Parfumflakon mit schwarzer Kappe auf weißem Sockel vor Kobaltblau, daneben eine weiße Kugel" width="1200" height="800"><figcaption class="mono">Kampagnenmotiv auf Kobalt</figcaption></figure>
</div>
</div></div>
</section>
<section class="kapitel k5" aria-labelledby="t5">
<div class="innen"><div class="raster">
<div class="zeile mono"><span>05 · Kontakt</span><span>Antwort binnen zwei Werktagen</span></div>
<h2 id="t5">Ihr Projekt ist das <em>nächste</em> Kapitel.</h2>
<div class="aufruf"><a href="#t5">Erstgespräch vereinbaren →</a><span class="mono leise">Atelier Kranich · Venloer Straße · Köln</span></div>
</div></div>
</section>
</main>
<div class="fuss">
<p class="werte mono" aria-live="off">
<span>Variante <b data-w="name">Perspektive</b></span>
<span data-w="formel"></span>
<span class="lang">linear, an Scroll gekoppelt · Strecke 100svh</span>
<span data-fortschritt>Übergang 01 → 02 · 0,00</span>
</p>
<div class="modi mono" role="group" aria-label="Variante des Übergangs">
<button type="button" data-variante="perspektive" aria-pressed="true">Perspektive</button>
<button type="button" data-variante="abdunkeln" aria-pressed="false">Abdunkeln</button>
<button type="button" data-variante="kante" aria-pressed="false">Runde Kante</button>
</div>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const schmal = matchMedia('(max-width: 760px)');
const kapitel = [...document.querySelectorAll('.kapitel')];
const zaehler = document.querySelector('[data-zaehler]');
const anzeige = document.querySelector('[data-fortschritt]');
const zahl = n => n.toFixed(2).replace('.', ',');
const zwei = n => String(n).padStart(2, '0');
// Varianten: Desktop-Werte und abgeschwächte Telefon-Werte (keine Rotation, Scale nur 0,9)
const VARIANTEN = {
perspektive: { name: 'Perspektive', skala: .2, skalaEin: .2, rot: 5, rotEin: 5, dunkel: .5, radius: 0,
tel: { skala: .1, skalaEin: .1, rot: 0, rotEin: 0 } },
abdunkeln: { name: 'Abdunkeln', skala: .05, skalaEin: 0, rot: 0, rotEin: 0, dunkel: .7, radius: 0,
tel: {} },
kante: { name: 'Runde Kante', skala: 0, skalaEin: 0, rot: 0, rotEin: 0, dunkel: .45, radius: 22,
tel: { radius: 12 } }
};
let variante = 'perspektive';
function werte() {
const v = VARIANTEN[variante];
return Object.assign({}, v, schmal.matches ? v.tel : {}, reduziert() ? { skala: 0, skalaEin: 0, rot: 0, rotEin: 0, radius: 0 } : {});
}
function setzen(neu) {
variante = neu;
const w = werte();
const s = html.style;
s.setProperty('--skala', w.skala); s.setProperty('--skala-ein', w.skalaEin);
s.setProperty('--rot', w.rot + 'deg'); s.setProperty('--rot-ein', w.rotEin + 'deg');
s.setProperty('--dunkel', w.dunkel); s.setProperty('--radius', w.radius + 'vh');
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
// Werte-Anzeige: genau die Werte, die gerade wirken
const teile = [];
if (w.skala) teile.push(`alt: scale 1 → <b>${zahl(1 - w.skala).replace(/0$/, '')}</b>`);
if (w.rot) teile.push(`rotate 0 → <b>−${w.rot}°</b>`);
if (w.skalaEin) teile.push(`neu: scale <b>${zahl(1 - w.skalaEin).replace(/0$/, '')}</b> → 1`);
if (w.rotEin) teile.push(`rotate <b>+${w.rotEin}°</b> → 0`);
if (w.radius) teile.push(`Oberkante <b>50% / ${w.radius}vh</b> → 0`);
teile.push(`Abdunkeln 0 → <b>${zahl(w.dunkel).replace(/0$/, '')}</b>`);
document.querySelector('[data-w="name"]').textContent = w.name;
document.querySelector('[data-w="formel"]').innerHTML = teile.join(' · ');
messen();
}
// Fortschritt je Section aus der Lage der Oberkante: 0 = Oberkante am unteren Rand, 1 = oben angekommen
function messen() {
const h = innerHeight;
const ein = kapitel.map((k, i) => i === 0 ? 1 : Math.min(1, Math.max(0, 1 - k.getBoundingClientRect().top / h)));
let aktiv = 0;
kapitel.forEach((k, i) => {
const aus = i < kapitel.length - 1 ? ein[i + 1] : 0;
k.style.setProperty('--ein', ein[i].toFixed(4));
k.style.setProperty('--aus', aus.toFixed(4));
const verdeckt = aus >= 0.999;
k.classList.toggle('verdeckt', verdeckt);
k.inert = verdeckt;
if (ein[i] >= 0.5) aktiv = i;
});
zaehler.textContent = `Kapitel ${zwei(aktiv + 1)} / ${zwei(kapitel.length)}`;
let n = 1; while (n < kapitel.length - 1 && ein[n] >= 1) n++;
anzeige.textContent = `Übergang ${zwei(n)} → ${zwei(n + 1)} · ${zahl(ein[n])}`;
}
let wartet = false;
const anstossen = () => { if (!wartet) { wartet = true; requestAnimationFrame(() => { wartet = false; messen(); }); } };
addEventListener('scroll', anstossen, { passive: true });
addEventListener('resize', () => setzen(variante));
schmal.addEventListener('change', () => setzen(variante));
new MutationObserver(() => setzen(variante)).observe(html, { attributes: true, attributeFilter: ['class'] });
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { pause(); setzen(b.dataset.variante); }));
setzen(variante);
// Vorführung: die Seite scrollt Kapitel für Kapitel von selbst, jede Runde in der nächsten Variante
const FOLGE = ['perspektive', 'kante', 'abdunkeln'];
const START = 1000, FAHRT = 2200, HALT = 600;
let uhr = null, t0 = 0, runde = 0;
const weich = x => 0.5 - 0.5 * Math.cos(Math.PI * x); // nur die gedachte Scroll-Hand; der Effekt selbst ist linear
function position(t) {
const h = innerHeight, n = kapitel.length - 1;
if (t < START) return 0;
t -= START;
for (let i = 0; i < n; i++) {
if (t < FAHRT) return (i + weich(t / FAHRT)) * h;
t -= FAHRT;
if (t < HALT) return (i + 1) * h;
t -= HALT;
}
return null; // Runde vorbei
}
function vorfuehren() {
pause(); runde = 0; html.classList.add('vorfuehrung');
setzen(FOLGE[0]); scrollTo(0, 0);
t0 = performance.now();
const schritt = t => {
const y = position(t - t0);
if (y === null) { runde++; setzen(FOLGE[runde % FOLGE.length]); scrollTo(0, 0); t0 = t; }
else scrollTo(0, y);
messen();
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; html.classList.remove('vorfuehrung'); }
function neustart() { pause(); scrollTo(0, 0); setzen(variante); }
function weiter() { if (!uhr) vorfuehren(); }
// Wer selbst scrollt, übernimmt (außer in der Galerie-Vorschau)
['wheel', 'touchstart', 'keydown'].forEach(ev => addEventListener(ev, () => { if (uhr && !html.classList.contains('vorschau')) pause(); }, { passive: true }));
})();
</script>
</body>
</html>