Verzerrung nach Scrollgeschwindigkeit (Skew/Stretch)
Zwölf Hausplakate eines Leipziger Studios kippen beim schnellen Scrollen bis zu 4 Grad und federn beim Stopp zurück, die Bildunterschriften bleiben scharf.
Die aktuelle Scrollgeschwindigkeit wird gemessen. Bilder oder Blöcke werden proportional dazu geneigt (skewY) oder gestreckt (scaleY), in WebGL auch gekrümmt. Beim Stopp federn sie in die Ruheform zurück. 2026 eher dezent mit höchstens 2 bis 4 Grad oder nur in WebGL-Galerien.
Der Scroll bekommt physische Trägheit. Der Inhalt reagiert sichtbar auf die Kraft der Geste.
Wann einsetzen
Experimentelle Portfolios, Bildergalerien, Studio-Seiten mit verspieltem Ton, eigene Arbeiten statt Kundenprojekte.
Wann nicht
Bei Text, auf Corporate-Seiten, auf Kundenarbeiten, die verkauft werden sollen, und als Dauereffekt auf allen Elementen. Das wirkt nach 2019.
Varianten
- skewY auf Bildern (DOM)
- scaleY-Stretch
- WebGL-Krümmung der Bildflächen im Vertex-Shader
- RGB-Versatz im Fragment-Shader
Umsetzung
Mit ScrollTrigger die Geschwindigkeit lesen: onUpdate: self => self.getVelocity(). Daraus clamp(−4, 4, v / −300) berechnen. Nur einen stärkeren Ausschlag übernehmen und ihn per gsap.to(proxy, { skew: 0, duration: 0.8, ease: 'power3', overwrite: true }) zurückfedern lassen, gesetzt über gsap.quickSetter(el, 'skewY', 'deg'). Alternativ gsap.quickTo. transform-origin center, will-change: transform. Mit Lenis: lenis.on('scroll', ({ velocity }) => …). Fallstrick: Text wird unscharf, deshalb nur Bildflächen verzerren und Bildunterschriften außerhalb lassen.
Robust umsetzen
Telefon. Abschalten, Touch-Momentum erzeugt starke Spitzen.
Ohne Bewegung. Abschalten.
Barrierefrei. Rein dekorativ. Keine Information darf an der Verzerrung hängen.
Code
<!DOCTYPE html>
<!--
Verzerrung nach Scrollgeschwindigkeit (Skew/Stretch)
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/velocity-skew-verzerrung/
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>Verzerrung nach Scrollgeschwindigkeit (Skew/Stretch) · 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: 'TikTok Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/tiktok-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 300 900; font-stretch: 75% 150%; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }
:root {
/* Rezept kreativstudio-buehne: Schwarz, Weiß, eine Signalfarbe */
--schwarz: #0b0b0b; --hell: #f2f2f2; --weiss: #ffffff; --meta: #8a8a8a; --signal: #ff5a1f;
--kurve: cubic-bezier(0.25, 1, 0.5, 1); /* power3.out: Rückfedern */
--rand: clamp(20px, 3.5vw, 48px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--hell); color: var(--weiss); font-family: 'TikTok Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { font-size: 1.125rem; line-height: 1.4; overflow-x: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.mono { font: 400 .75rem/1.35 'DM Mono', ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; }
/* ---------- Kopf ---------- */
.kopf { position: fixed; z-index: 10; inset: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 18px var(--rand); mix-blend-mode: difference; color: #fff; }
.wortmarke { font-weight: 800; font-stretch: 75%; font-size: 1.5rem; letter-spacing: -.02em; line-height: 1; color: inherit; text-decoration: none; }
.kopf nav { display: flex; gap: 28px; }
.kopf nav a { color: inherit; text-decoration: none; }
.kopf nav a[aria-current] { text-decoration: underline; text-underline-offset: 4px; }
/* ---------- Einstieg ---------- */
.hero { background: var(--schwarz); min-height: 92svh; display: grid; align-content: end; gap: clamp(24px, 4vh, 48px); padding: 120px var(--rand) clamp(32px, 6vh, 64px); }
.hero__zeile { display: flex; justify-content: space-between; gap: 20px; color: var(--meta); }
.hero h1 { font-size: clamp(3.5rem, 10vw, 11rem); font-weight: 800; font-stretch: 75%; letter-spacing: -.03em; line-height: .9; text-transform: uppercase; }
.hero h1 span { color: var(--signal); }
.hero__unten { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 28rem); gap: 24px; align-items: end; }
.hero__unten p:last-child { font-size: 1.125rem; line-height: 1.4; color: #d9d9d9; }
/* ---------- Plakatwand ---------- */
.wand { background: var(--hell); color: var(--schwarz); padding: clamp(64px, 10vh, 120px) var(--rand) clamp(80px, 14vh, 160px); }
.wand__kopf { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 24px; margin-bottom: clamp(40px, 7vh, 80px); }
.wand__kopf h2 { font-size: clamp(2rem, 4.5vw, 4.5rem); font-weight: 600; letter-spacing: -.02em; line-height: .95; max-width: 14ch; }
.wand__kopf .mono { color: #6b6b6b; text-align: right; }
.raster { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(40px, 6vh, 72px) clamp(16px, 2.5vw, 36px); align-items: start; }
.plakat { display: grid; gap: 12px; }
.plakat:nth-child(3n+2) { margin-top: 18vh; }
.plakat:nth-child(3n+3) { margin-top: 7vh; }
/* nur die Fläche verzerrt sich, die Bildunterschrift bleibt scharf */
.plakat__flaeche { aspect-ratio: 3 / 4; background: #fff; box-shadow: 0 1px 0 rgba(0, 0, 0, .05), 0 22px 40px -28px rgba(0, 0, 0, .45); will-change: transform; transform-origin: 50% 50%; backface-visibility: hidden; }
.plakat__flaeche svg { display: block; width: 100%; height: 100%; }
.plakat figcaption { display: flex; justify-content: space-between; gap: 12px; color: #4a4a4a; }
.plakat figcaption b { font-weight: 500; color: var(--schwarz); }
/* ---------- Abschluss ---------- */
.schluss { background: var(--schwarz); padding: clamp(80px, 14vh, 160px) var(--rand) 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 32px; overflow: hidden; }
.schluss p.mono { color: var(--meta); }
.schluss h2 { font-size: clamp(2rem, 4.5vw, 4.5rem); font-weight: 600; letter-spacing: -.02em; line-height: .98; max-width: 18ch; }
.schluss a.knopf { justify-self: start; display: inline-flex; align-items: center; gap: 14px; height: 56px; padding: 0 26px; border-radius: 999px; background: var(--signal); color: var(--schwarz); font-weight: 600; text-decoration: none; transition: transform .3s var(--kurve); }
.schluss a.knopf:hover { transform: translateY(-2px); }
.schluss a.knopf:focus-visible { outline: 2px solid var(--weiss); outline-offset: 4px; }
.riesig span { display: inline-block; }
.riesig { font-weight: 800; font-stretch: 75%; font-size: 23.6vw; letter-spacing: -.04em; line-height: .78; margin: clamp(40px, 8vh, 96px) calc(var(--rand) * -.3) -2.2vw; white-space: nowrap; }
/* ---------- Demo-Pult ---------- */
.pult { position: fixed; z-index: 30; left: 16px; bottom: 16px; display: grid; gap: 6px; justify-items: start; font: 500 10.5px/1.4 'DM Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; }
.pult__knoepfe { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: rgba(11, 11, 11, .88); border: 1px solid rgba(255, 255, 255, .14); }
.pult button { height: 26px; padding: 0 12px; border-radius: 999px; color: rgba(255, 255, 255, .7); letter-spacing: inherit; text-transform: inherit; transition: background-color .15s, color .15s; }
.pult button:hover { color: #fff; }
.pult button[aria-pressed='true'] { background: var(--signal); color: var(--schwarz); }
.pult__werte { padding: 5px 12px; border-radius: 999px; background: rgba(255, 255, 255, .94); color: var(--schwarz); text-transform: none; letter-spacing: .02em; font-variant-numeric: tabular-nums; min-width: 18.5rem; box-shadow: 0 0 0 1px rgba(0, 0, 0, .08); }
.pult__werte b { color: var(--signal); font-weight: 500; }
.vorschau body { overflow: hidden; }
.vorschau .hero { min-height: 70svh; }
@media (max-width: 1000px) {
.raster { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.plakat:nth-child(n) { margin-top: 0; }
.plakat:nth-child(2n) { margin-top: 12vh; }
}
@media (max-width: 640px) {
.kopf nav { display: none; }
.hero { min-height: 80svh; }
.hero__unten { grid-template-columns: 1fr; }
.hero__zeile span:last-child { display: none; }
.wand__kopf { grid-template-columns: 1fr; }
.wand__kopf .mono { text-align: left; }
.raster { gap: 36px 12px; }
.plakat figcaption { display: grid; gap: 2px; font-size: .6875rem; }
.pult { left: 12px; bottom: 12px; }
}
/* Touch und reduzierte Bewegung: keine Verzerrung (Touch-Momentum erzeugt Spitzen) */
.reduziert .plakat__flaeche { transform: none !important; will-change: auto; }
.reduziert .schluss a.knopf { transition: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="wortmarke" href="#">KRANBAHN</a>
<nav class="mono" aria-label="Hauptnavigation"><a href="#">Arbeiten</a><a href="#">Studio</a><a href="#wand" aria-current="page">Hausplakate</a><a href="#schluss">Kontakt</a></nav>
</header>
<section class="hero">
<p class="hero__zeile mono"><span>Hausplakate · eigene Arbeiten</span><span>Leipzig-Plagwitz · 51,33° N</span></p>
<h1>Ein Plakat<br>pro Monat.<br><span>Seit 2019.</span></h1>
<div class="hero__unten">
<p class="mono" data-hinweis>↓ Schnell scrollen</p>
<p>Für unsere Montagsrunde am Karl-Heine-Kanal drucken wir jeden Monat ein Plakat im Siebdruck. Keine Kunden, kein Briefing, nur wir und zwei Farben.</p>
</div>
</section>
<section class="wand" id="wand" aria-labelledby="wand-titel">
<div class="wand__kopf">
<h2 id="wand-titel">Die Wand im Treppenhaus.</h2>
<p class="mono">12 von 82 Plakaten<br>Siebdruck, A1</p>
</div>
<div class="raster" data-raster></div>
</section>
<section class="schluss" id="schluss">
<p class="mono">Nächste Montagsrunde · Mo, 2. November, 19 Uhr</p>
<h2>Wir drucken live. Bring ein T-Shirt mit.</h2>
<a class="knopf" href="#">Platz sichern <span aria-hidden="true">↗</span></a>
<p class="riesig" aria-hidden="true"><span>KRANBAHN</span></p>
</section>
<div class="pult">
<div class="pult__knoepfe" role="group" aria-label="Variante">
<button type="button" data-modus="skew" aria-pressed="true">skewY</button>
<button type="button" data-modus="stretch" aria-pressed="false">scaleY</button>
<button type="button" data-modus="aus" aria-pressed="false">Aus</button>
</div>
<p class="pult__werte" data-werte>Tempo 0 px/s → skewY <b>0,0°</b></p>
</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/ScrollTrigger.min.js"></script>
<script>
(() => {
/* ---------- Plakate als eigene SVG-Grafiken ---------- */
const S = '#0b0b0b', W = '#ffffff', O = '#ff5a1f';
const T = (x, y, gr, farbe, inhalt, extra = '') => `<text x="${x}" y="${y}" font-family="TikTok Sans" font-weight="800" font-stretch="75%" style="font-stretch:75%" font-size="${gr}" letter-spacing="-1" fill="${farbe}" ${extra}>${inhalt}</text>`;
const M = (x, y, farbe, inhalt, anker = 'start') => `<text x="${x}" y="${y}" font-family="DM Mono" font-size="9" letter-spacing=".5" fill="${farbe}" text-anchor="${anker}">${inhalt}</text>`;
const kreise = (n, f) => Array.from({ length: n }, (_, i) => f(i)).join('');
const PLAKATE = [
{ nr: 82, titel: 'Montagsrunde November', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${O}"/><circle cx="230" cy="320" r="150" fill="${S}"/>${T(18, 84, 58, S, 'MONTAGS')}${T(18, 138, 58, S, 'RUNDE')}${M(18, 380, S, '02.11.2026 · 19 UHR')}${M(282, 30, W, 'NO. 82', 'end')}` },
{ nr: 81, titel: 'Kranbahn, Oktober', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${S}"/><g stroke="${W}" stroke-width="7" fill="none"><path d="M40 360 V120 M110 360 V120 M24 120 H276 M40 160 L110 120 M110 160 L40 120"/><path d="M210 120 V210"/></g><rect x="196" y="210" width="28" height="22" fill="${O}"/>${T(18, 70, 56, W, 'KRAN BAHN')}${M(282, 380, W, 'NO. 81', 'end')}` },
{ nr: 80, titel: 'Kanal, September', technik: 'Siebdruck, 3 Farben', svg: `<rect width="300" height="400" fill="${W}"/><circle cx="210" cy="130" r="64" fill="${O}"/>${kreise(9, i => `<rect x="0" y="${210 + i * 20}" width="300" height="${2 + i * 1.6}" fill="${S}"/>`)}${T(18, 64, 64, S, 'KANAL')}${M(18, 392, S, 'NO. 80 · KARL-HEINE-KANAL')}` },
{ nr: 79, titel: 'Achtundvierzig', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${S}"/>${kreise(70, i => { const x = 30 + (i % 7) * 40, y = 140 + Math.floor(i / 7) * 24; return `<circle cx="${x}" cy="${y}" r="5" fill="${i === 38 ? O : W}"/>`; })}${T(18, 100, 96, W, '48')}${M(282, 30, W, 'NO. 79', 'end')}` },
{ nr: 78, titel: 'K wie Kranbahn', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${O}"/>${T(-20, 470, 560, W, 'K')}<rect x="0" y="300" width="300" height="34" fill="${S}"/>${M(14, 322, W, 'HAUSPLAKAT NO. 78')}` },
{ nr: 77, titel: 'Druck, Juni', technik: 'Risographie, 1 Farbe', svg: `<rect width="300" height="400" fill="${W}"/>${kreise(108, i => { const c = i % 9, r = Math.floor(i / 9); return `<circle cx="${22 + c * 32}" cy="${120 + r * 22}" r="${1 + r * 0.95}" fill="${S}"/>`; })}${T(18, 78, 76, S, 'DRUCK')}${M(282, 392, S, 'NO. 77', 'end')}` },
{ nr: 76, titel: 'Laut, Mai', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${S}"/>${kreise(8, i => `<circle cx="150" cy="250" r="${20 + i * 18}" fill="none" stroke="${i === 3 ? O : W}" stroke-width="4"/>`)}${T(18, 70, 72, W, 'LAUT')}${M(282, 392, W, 'NO. 76', 'end')}` },
{ nr: 75, titel: 'West, April', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${W}"/><path d="M-20 300 L300 120 L300 210 L-20 390Z" fill="${S}"/><path d="M-20 250 L300 70 L300 110 L-20 290Z" fill="${O}"/>${T(18, 380, 96, S, 'WEST')}${M(282, 30, S, 'NO. 75', 'end')}` },
{ nr: 74, titel: 'Ton ab, März', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${S}"/>${kreise(12, i => { const h = [60, 140, 90, 200, 120, 240, 170, 80, 210, 130, 100, 60][i]; return `<rect x="${18 + i * 23.5}" y="${360 - h}" width="15" height="${h}" fill="${O}"/>`; })}${T(18, 76, 72, W, 'TON AB')}${M(18, 390, W, 'NO. 74 · MÄRZ')}` },
{ nr: 73, titel: 'Sechsundzwanzig', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${W}"/>${T(-10, 330, 300, S, '26')}<rect x="18" y="356" width="150" height="10" fill="${O}"/>${M(282, 30, S, 'NO. 73 · JANUAR', 'end')}` },
{ nr: 72, titel: 'Plagwitz, Dezember', technik: 'Siebdruck, 2 Farben', svg: `<rect width="300" height="400" fill="${O}"/>${kreise(10, i => `<path d="M0 ${150 + i * 22} q 37.5 -16 75 0 t 75 0 t 75 0 t 75 0" fill="none" stroke="${S}" stroke-width="5"/>`)}${T(18, 92, 66, S, 'PLAGWITZ')}${M(282, 392, S, 'NO. 72', 'end')}` },
{ nr: 71, titel: 'Alles ist Entwurf', technik: 'Siebdruck, 1 Farbe', svg: `<rect width="300" height="400" fill="${S}"/>${T(18, 120, 104, W, 'ALLES')}${T(18, 220, 104, W, 'IST')}${T(18, 320, 104, O, 'ENTWURF')}${M(18, 386, W, 'NO. 71 · NOVEMBER')}` },
];
document.querySelector('[data-raster]').innerHTML = PLAKATE.map(p => `
<figure class="plakat">
<div class="plakat__flaeche"><svg viewBox="0 0 300 400" role="img" aria-label="Plakat Nummer ${p.nr}: ${p.titel}">${p.svg}</svg></div>
<figcaption class="mono"><span><b>No. ${p.nr}</b> · ${p.titel}</span><span>${p.technik}</span></figcaption>
</figure>`).join('');
/* ---------- Riesen-Wortmarke exakt auf Breite einpassen ---------- */
const riesig = document.querySelector('.riesig');
function passeWortmarke() {
riesig.style.fontSize = '';
const f = parseFloat(getComputedStyle(riesig).fontSize), b = riesig.firstElementChild.getBoundingClientRect().width;
if (b > 0) riesig.style.fontSize = (f * riesig.clientWidth / b) + 'px';
}
passeWortmarke();
if (document.fonts) document.fonts.ready.then(passeWortmarke);
addEventListener('resize', passeWortmarke);
/* Zugang: auch neu einpassen, wenn sich die Breite des Worts selbst ändert (eigene Textabstände, 1.4.12) */
let passeGleich = false;
if ('ResizeObserver' in window) new ResizeObserver(() => { if (passeGleich) return; passeGleich = true; requestAnimationFrame(() => { passeGleich = false; passeWortmarke(); }); }).observe(riesig.firstElementChild);
/* ---------- Verzerrung nach Scrollgeschwindigkeit ---------- */
gsap.registerPlugin(ScrollTrigger);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const touch = matchMedia('(hover: none)');
const flaechen = gsap.utils.toArray('.plakat__flaeche');
const werte = document.querySelector('[data-werte]');
const TEILER = 300, MAX_SKEW = 4, MAX_STRECK = 0.08, DAUER = 0.8; // Messwerte: Teiler ~300, ±2–4°, Rückfedern 600–900 ms power3
const begrenzeSkew = gsap.utils.clamp(-MAX_SKEW, MAX_SKEW);
const begrenzeStreck = gsap.utils.clamp(0, MAX_STRECK);
const setzeSkew = gsap.quickSetter(flaechen, 'skewY', 'deg');
const setzeStreck = gsap.quickSetter(flaechen, 'scaleY');
const proxy = { wert: 0 };
let modus = 'skew', tempo = 0, uhr = null;
const zahl = (v, s = 1) => v.toFixed(s).replace('.', ',').replace('-', '−');
function zeigeWerte() {
if (reduziert()) { werte.innerHTML = 'Reduzierte Bewegung: keine Verzerrung'; return; }
if (modus === 'aus') { werte.innerHTML = touch.matches ? 'Touch: aus, Momentum erzeugt Spitzen' : `Tempo ${Math.round(tempo)} px/s → <b>keine Verzerrung</b>`; return; }
werte.innerHTML = modus === 'skew'
? `Tempo ${Math.round(tempo)} px/s → skewY <b>${zahl(proxy.wert)}°</b>`
: `Tempo ${Math.round(tempo)} px/s → scaleY <b>${zahl(1 + proxy.wert, 3)}</b>`;
}
function anwenden(v) {
if (modus === 'skew') setzeSkew(v); else if (modus === 'stretch') setzeStreck(1 + v);
zeigeWerte();
}
ScrollTrigger.create({
onUpdate: self => {
tempo = self.getVelocity();
if (modus === 'aus' || reduziert()) { zeigeWerte(); return; }
const ziel = modus === 'skew' ? begrenzeSkew(tempo / -TEILER) : begrenzeStreck(Math.abs(tempo) / (TEILER * 50));
// nur ausschlagen, wenn der neue Wert stärker ist; danach federt die Fläche mit power3.out zurück
if (Math.abs(ziel) > Math.abs(proxy.wert)) {
proxy.wert = ziel;
gsap.to(proxy, { wert: 0, duration: DAUER, ease: 'power3.out', overwrite: true, onUpdate: () => anwenden(proxy.wert), onComplete: () => { tempo = 0; zeigeWerte(); } });
}
}
});
function setzeModus(m) {
modus = m;
gsap.killTweensOf(proxy); proxy.wert = 0;
gsap.set(flaechen, { skewY: 0, scaleY: 1 });
gsap.set(flaechen, { transformOrigin: m === 'stretch' ? '50% 0%' : '50% 50%' });
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
zeigeWerte();
}
document.querySelectorAll('[data-modus]').forEach(b => b.addEventListener('click', () => setzeModus(b.dataset.modus)));
/* ---------- Vorführung: kurze, schnelle Scroll-Stöße mit Pausen ---------- */
const STOESSE = [[0.22, 0.5], [0.42, 0.45], [0.62, 0.5], [0.8, 0.45], [0.5, 0.55], [0.05, 0.9]];
function vorfuehren() {
pause();
scrollTo(0, 0);
if (modus === 'aus' && !reduziert()) setzeModus('skew');
const y = { v: 0 };
uhr = gsap.timeline({ repeat: -1, delay: 0.6, onUpdate: () => scrollTo(0, y.v) });
STOESSE.forEach(([ziel, dauer]) => {
uhr.to(y, { v: () => ziel * (document.documentElement.scrollHeight - innerHeight), duration: dauer, ease: 'power2.inOut' })
.to({}, { duration: 0.75 });
});
}
function pause() { if (uhr) { uhr.kill(); uhr = null; } }
function weiter() { if (html.classList.contains('vorschau') && !uhr) vorfuehren(); }
function neustart() { pause(); scrollTo(0, 0); setzeModus(modus); }
['wheel', 'touchstart', 'keydown'].forEach(t => addEventListener(t, () => { if (uhr && !html.classList.contains('vorschau')) pause(); }, { passive: true }));
document.addEventListener('DOMContentLoaded', () => {
if (reduziert()) { document.querySelectorAll('[data-modus]').forEach(b => { b.disabled = true; }); setzeModus('aus'); }
else setzeModus(touch.matches ? 'aus' : 'skew');
if (touch.matches || reduziert()) document.querySelector('[data-hinweis]').textContent = '↓ Scrollen';
});
})();
</script>
</body>
</html>