Native CSS-Scroll-Reveals mit view()
Inhalte gleiten an die Scrollposition gekoppelt herein, rein in CSS über animation-timeline: view(), ohne Library und ohne JS-Init.
Ein- und Ausblendeffekte hängen direkt an der Position des Elements im Viewport, ohne JavaScript. animation-timeline: view() erzeugt eine anonyme View-Timeline. animation-range legt fest, in welchem Abschnitt die Keyframes laufen: entry, exit, cover, contain, entry-crossing oder exit-crossing. Typisch tritt ein Element von entry 0% bis entry 100% ein (Deckkraft, Verschiebung, Skalierung, Clip), bleibt in contain ruhig und tritt in exit wieder aus. Mit view-timeline-name und timeline-scope steuert ein Element die Animation eines anderen.
Die Bewegung folgt dem Finger bzw. dem Mausrad, nicht einer Uhr. Sie läuft auf dem Compositor und bleibt ruckelfrei, auch wenn der Main-Thread voll ist. Keine Library, kein Layout-Sprung durch JS-Initialisierung. Rückwärts scrollen spielt den Effekt rückwärts.
Wann einsetzen
Für Standard-Reveals, Parallaxe, Fortschrittsbalken, schrumpfende Header und Karussell-Effekte. Überall, wo keine komplexe Abfolge mit Callbacks nötig ist.
Wann nicht
Für mehrstufige Sequenzen mit Callbacks, Physik oder Canvas-Steuerung (dafür GSAP). Nicht als einzige Darstellung: In Browsern ohne Support müssen Inhalte ohne Animation sichtbar sein. Nicht für Inhalte über dem Falz, sonst sind sie beim Laden halb ausgeblendet.
Varianten
- Anonyme view()-Timeline pro Element
- Benannte Timeline (view-timeline-name) mit timeline-scope, damit ein Element ein anderes steuert
- scroll(root) für seitenweite Effekte wie eine Fortschrittsleiste
- Symmetrisch: rein in entry, raus in exit (zwei Animationen, die zweite mit fill forwards)
- Per JS: new ViewTimeline({ subject }) und element.animate(…, { timeline, rangeStart, rangeEnd })
Umsetzung
@media (prefers-reduced-motion: no-preference) { @supports (animation-timeline: view()) { .in { animation: in linear both; animation-timeline: view(); animation-range: entry 25% cover 50%; } } } @keyframes in { from { opacity: 0; translate: -15px 0; } }. animation-timeline immer NACH dem animation-Shorthand setzen, der Shorthand setzt es auf auto zurück. Achtung Spezifität: eine stärkere Regel mit dem Shorthand löscht die Timeline ebenfalls. Dauer auf auto lassen. view() nimmt immer den nächsten Scroll-Container; ein Elternelement mit overflow: hidden wird zum falschen Scroller, darum overflow: clip verwenden. Stagger entsteht über versetzte Ranges. Browser: Chromium ab 115 und Safari 26; Firefox ist laut MDN eingeschränkt verfügbar, deshalb Fallback einplanen.
Robust umsetzen
Telefon. Ideal für Mobilgeräte, weil compositor-getrieben. Keine Sonderbehandlung nötig. Große seitliche Wege (-4rem) werden am Rand abgeschnitten, also body { overflow-x: clip } setzen.
Ohne Bewegung. Die komplette Regel in @media (prefers-reduced-motion: no-preference) kapseln. Dann bleibt alles im Endzustand sichtbar.
Barrierefrei. Den Startzustand (opacity 0) nur innerhalb von @supports setzen, damit Inhalte ohne Support sichtbar bleiben. Inhalte über dem Falz nicht verstecken. Rein dekorativ, keine Information darf nur in der Animation stecken.
Code
<!DOCTYPE html>
<!--
Native CSS-Scroll-Reveals mit view()
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/css-view-reveal-nativ/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de" data-variante="sanft">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Native CSS-Scroll-Reveals mit view() · 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 {
--papier: #eeeae2; --tinte: #191816; --leise: rgba(25, 24, 22, .66); --linie: rgba(25, 24, 22, .14);
--akzent: #d4522a;
--rand: clamp(20px, 4vw, 56px);
}
/* Varianten: Werte aus den Produktionsbelegen */
[data-variante='sanft'] { --dx: -15px; --dy: 0px; --s: 1; --kurve: linear; --a-n: entry; --a: 25%; --b-n: cover; --b: 50%; } /* nerdy.dev */
[data-variante='weit'] { --dx: -4rem; --dy: 0px; --s: 1; --kurve: cubic-bezier(0.42, 0, 1, 1); --a-n: entry; --a: 0%; --b-n: cover; --b: 35%; } /* una.im, ease-in */
[data-variante='symmetrisch'] { --dx: 0px; --dy: 2rem; --s: .96; --kurve: linear; --a-n: entry; --a: 0%; --b-n: entry; --b: 100%; } /* rein und wieder raus */
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
img { display: block; max-width: 100%; }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
.mono { font: 500 11px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* Kopfleiste */
.leiste { position: fixed; inset: 0 0 auto; z-index: 5; display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px var(--rand); background: rgba(238, 234, 226, .82); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid var(--linie); }
.marke { color: var(--tinte); font-weight: 600; }
.modi { display: flex; align-items: center; gap: 4px; }
.modi > span { margin-right: 8px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: background-color 150ms, color 150ms, border-color 150ms; }
.modi button:hover { color: var(--tinte); border-color: var(--leise); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
.fortschritt { position: fixed; z-index: 6; inset: 0 0 auto; height: 2px; background: var(--akzent); transform-origin: 0 50%; scale: 0 1; display: none; }
/* Auftakt */
.auftakt { min-height: 64svh; display: grid; align-content: end; gap: clamp(20px, 3vw, 32px); padding: 110px var(--rand) clamp(36px, 6vh, 64px); }
.titel { font-size: clamp(46px, 8.4vw, 132px); font-weight: 600; letter-spacing: -.045em; line-height: .9; max-width: 11ch; }
.titel em { letter-spacing: -.02em; }
.auftakt__fuss { display: flex; justify-content: space-between; align-items: end; gap: 24px; flex-wrap: wrap; }
.auftakt__fuss p:first-child { max-width: 44ch; font-size: clamp(16px, 1.3vw, 19px); line-height: 1.5; color: var(--leise); }
/* Haltung mit wachsender Unterstreichung */
.haltung { padding: clamp(56px, 12vh, 140px) var(--rand); border-top: 1px solid var(--linie); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 3fr); gap: 24px; }
.haltung p:last-child { font-size: clamp(26px, 3.6vw, 52px); line-height: 1.12; letter-spacing: -.025em; max-width: 24ch; }
.strich { background: linear-gradient(var(--akzent), var(--akzent)) 0 94% / 100% .16em no-repeat; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
/* Arbeiten: Index links, Liste rechts */
.arbeiten { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 3fr); gap: 24px; padding: 0 var(--rand) clamp(60px, 14vh, 160px); timeline-scope: --a1, --a2, --a3, --a4; }
.index { position: sticky; top: 92px; align-self: start; display: grid; gap: 18px; padding-top: 8px; }
.index ol { list-style: none; padding: 0; display: grid; gap: 10px; }
.index li { display: flex; align-items: center; gap: 12px; color: var(--tinte); font-size: 15px; white-space: nowrap; }
.index li::before { content: ''; flex: none; width: 28px; height: 1px; background: currentColor; transform-origin: 0 50%; }
.index li span { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; color: var(--leise); }
.liste { display: grid; gap: clamp(72px, 14vh, 150px); }
.arbeit { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: clamp(20px, 3vw, 40px); row-gap: 22px; }
.arbeit:nth-child(1) { view-timeline-name: --a1; }
.arbeit:nth-child(2) { view-timeline-name: --a2; }
.arbeit:nth-child(3) { view-timeline-name: --a3; }
.arbeit:nth-child(4) { view-timeline-name: --a4; }
/* overflow: clip statt hidden: kein eigener Scroll-Container, das Bild darin behält den Seiten-Scroller als view()-Quelle */
.bild { grid-column: 1 / -1; aspect-ratio: 16 / 9; overflow: clip; border-radius: 4px; background: var(--linie); }
.bild img { width: 100%; height: 100%; object-fit: cover; scale: 1.14; }
.arbeit h2 { font-size: clamp(30px, 3.4vw, 50px); font-weight: 600; letter-spacing: -.035em; line-height: 1; margin-top: 10px; }
.arbeit .text { font-size: clamp(15px, 1.15vw, 17px); line-height: 1.55; color: var(--leise); max-width: 40ch; padding-top: 4px; }
.arbeit .text + .mono { grid-column: 2; }
/* Schluss */
.schluss { padding: clamp(60px, 12vh, 140px) var(--rand) 22vh; border-top: 1px solid var(--linie); display: grid; gap: 28px; }
.schluss p.gross { font-size: clamp(34px, 5.6vw, 88px); font-weight: 600; letter-spacing: -.04em; line-height: .98; max-width: 16ch; }
.schluss a { color: inherit; text-decoration: none; }
/* Werte-Anzeige */
.werte { position: fixed; z-index: 5; left: var(--rand); bottom: 16px; padding: 11px 14px; background: rgba(25, 24, 22, .9); color: rgba(238, 234, 226, .6); border-radius: 6px; font-size: 10px; pointer-events: none; }
.werte__inhalt { display: grid; grid-template-columns: auto auto; gap: 3px 16px; }
.werte b { color: var(--papier); font-weight: 500; }
.werte__knopf { display: none; }
/* ── Effekt: nur mit Support und ohne Bewegungswunsch-Reduktion ── */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
:root:not(.reduziert) .fortschritt { display: block; animation: wachsen linear both; animation-timeline: scroll(root); }
/* A) Anonyme view()-Timeline pro Element; Stagger über versetzte Ranges (--v) */
:root:not(.reduziert) .r {
animation: rein linear both;
animation-timeline: view(); /* NACH dem Shorthand */
animation-timing-function: var(--kurve);
animation-range: var(--a-n) calc(var(--a) + var(--v, 0%)) var(--b-n) calc(var(--b) + var(--v, 0%));
}
:root[data-variante='symmetrisch']:not(.reduziert) .r {
animation: rein linear both, raus linear forwards;
animation-timeline: view(), view();
animation-range: entry calc(0% + var(--v, 0%)) entry calc(100% + var(--v, 0%)), exit 0% exit 100%;
}
/* Medien: Einblenden (entry 10% → entry 80%, ease-out) und Parallaxe des Bilds im Rahmen */
:root:not(.reduziert) .bild { animation: hervor ease-out both; animation-timeline: view(); animation-range: entry 10% entry 80%; }
:root[data-variante='symmetrisch']:not(.reduziert) .bild { animation: hervor ease-out both, raus linear forwards; animation-timeline: view(), view(); animation-range: entry 10% entry 80%, exit 0% exit 100%; }
:root:not(.reduziert) .bild img { animation: treiben linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; }
/* Unterstreichung wächst in der Range 50 % → 75 % (cover) */
:root:not(.reduziert) .strich { animation: strich linear both; animation-timeline: view(); animation-range: 50% 75%; }
/* B) Benannte Timelines + timeline-scope: jede Arbeit steuert ihren Eintrag im Index */
:root:not(.reduziert) .index li, :root:not(.reduziert) .index li::before { animation: aktiv linear both; animation-range: cover 25% cover 75%; }
:root:not(.reduziert) .index li::before { animation-name: aktiv-strich; }
:root:not(.reduziert) .index li:nth-child(1), :root:not(.reduziert) .index li:nth-child(1)::before { animation-timeline: --a1; }
:root:not(.reduziert) .index li:nth-child(2), :root:not(.reduziert) .index li:nth-child(2)::before { animation-timeline: --a2; }
:root:not(.reduziert) .index li:nth-child(3), :root:not(.reduziert) .index li:nth-child(3)::before { animation-timeline: --a3; }
:root:not(.reduziert) .index li:nth-child(4), :root:not(.reduziert) .index li:nth-child(4)::before { animation-timeline: --a4; }
}
}
@keyframes rein { from { opacity: 0; translate: var(--dx) var(--dy); scale: var(--s); } }
@keyframes raus { from { opacity: 1; translate: 0 0; scale: 1; } to { opacity: 0; translate: 0 calc(var(--dy) * -1); scale: var(--s); } }
@keyframes hervor { from { opacity: 0; scale: .92; } }
@keyframes treiben { from { translate: 0 -6%; } to { translate: 0 6%; } }
@keyframes strich { from { background-size: 0% .16em; } }
@keyframes aktiv { 0%, 100% { color: rgba(25, 24, 22, .62); } 12%, 88% { color: var(--tinte); } }
@keyframes aktiv-strich { 0%, 100% { scale: .35 1; } 12%, 88% { scale: 1 1; } }
@keyframes wachsen { to { scale: 1 1; } }
/* Ohne Support: Inhalte bleiben sichtbar, JS blendet einmalig per IntersectionObserver ein */
@supports not (animation-timeline: view()) {
:root.js:not(.reduziert) .r { opacity: 0; translate: var(--dx) var(--dy); transition: opacity .9s cubic-bezier(0.16, 1, 0.3, 1), translate .9s cubic-bezier(0.16, 1, 0.3, 1); }
:root.js:not(.reduziert) .r.da { opacity: 1; translate: 0 0; }
}
.vorschau body { overflow: hidden; }
.vorschau .modi > span { display: none; }
.vorschau .werte { display: none; } /* Nebensache: deckte in der Vorschau Abschnittslabels */
@media (max-width: 760px) {
.leiste { padding-block: 10px; }
.leiste .marke span { display: none; }
.modi > span { display: none; }
.modi button { padding: 0 9px; height: 26px; }
.auftakt { min-height: 62svh; padding-top: 96px; }
.haltung, .arbeiten { grid-template-columns: minmax(0, 1fr); }
.haltung { gap: 18px; }
.index { top: 51px; z-index: 4; margin-inline: calc(var(--rand) * -1); padding: 10px var(--rand); background: rgba(238, 234, 226, .92); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid var(--linie); gap: 0; }
.index > .mono { display: none; }
.index ol { grid-template-columns: repeat(4, auto); justify-content: space-between; gap: 6px; }
.index li { gap: 6px; font-size: 0; }
.index li::before { width: 14px; }
.index li span { font-size: 11px; margin: 0; color: inherit; }
.liste { gap: 72px; margin-top: 28px; }
.arbeit { grid-template-columns: minmax(0, 1fr); row-gap: 14px; }
.bild { aspect-ratio: 4 / 5; }
.arbeit .text + .mono { grid-column: 1; }
/* Telefon: Werte als einklappbarer Chip unten rechts, eingeklappt verdeckt er keinen Fließtext */
.werte { left: auto; right: 12px; bottom: 12px; max-width: calc(100% - 24px); padding: 0; font-size: 9.5px; pointer-events: auto; display: flex; flex-direction: column-reverse; align-items: flex-end; box-shadow: 0 6px 20px rgba(25, 24, 22, .18); }
.werte__knopf { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--papier); background: none; border: 0; cursor: pointer; }
.werte__knopf::after { content: '+'; font-size: 13px; line-height: 1; transition: rotate 150ms; }
.werte__knopf[aria-expanded='true']::after { rotate: 45deg; }
.werte__inhalt { display: none; padding: 10px 12px 0; }
.werte.offen .werte__inhalt { display: grid; }
.werte__inhalt > :nth-child(-n+2) { display: none; }
}
@media (max-width: 380px) { .modi button { padding: 0 7px; letter-spacing: .08em; } }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="fortschritt" aria-hidden="true"></div>
<header class="leiste mono">
<span class="marke">Büro Feldlinie<span> · Leipzig</span></span>
<div class="modi" role="group" aria-label="Variante">
<span>Variante</span>
<button type="button" data-v="sanft" aria-pressed="true">Sanft</button>
<button type="button" data-v="weit" aria-pressed="false">Weit</button>
<button type="button" data-v="symmetrisch" aria-pressed="false">Ein + aus</button>
</div>
</header>
<main>
<section class="auftakt">
<p class="mono">Erscheinungsbilder · Schilder · Drucksachen</p>
<h1 class="titel">Zeichen für Leute, die etwas <em>machen.</em></h1>
<div class="auftakt__fuss">
<p>Wir gestalten Logos, Schilder und Drucksachen für Cafés, Werkstätten und kleine Verlage. Seit 2014, zu zweit, aus einem Hinterhof in Plagwitz.</p>
<p class="mono">Weiter scrollen ↓</p>
</div>
</section>
<section class="haltung">
<p class="mono r">Haltung</p>
<p class="r" style="--v: 4%">Ein gutes Zeichen hält zwanzig Jahre. Es muss auf einen <span class="strich">Kaffeebecher</span>, an eine <span class="strich">Ladentür</span> und in ein <span class="strich">Stempelkissen</span> passen. Dafür zeichnen wir lieber dreimal neu als einmal schnell.</p>
</section>
<section class="arbeiten" aria-labelledby="arbeiten-titel">
<nav class="index" aria-label="Arbeiten">
<p class="mono" id="arbeiten-titel">Ausgewählte Arbeiten</p>
<ol>
<li><span>01</span>Krume</li>
<li><span>02</span>Eselsohr</li>
<li><span>03</span>Tischgespräch</li>
<li><span>04</span>Kobalt 12</li>
</ol>
</nav>
<div class="liste">
<article class="arbeit">
<figure class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/marke-krume-hoch.jpg"><img src="bilder/marke-krume.jpg" alt="Wortmarke krume: fette dunkelbraune Kleinbuchstaben unter einem Brotlaib mit drei Schnitten auf Senfgelb" width="1200" height="800"></picture></figure>
<div>
<p class="mono r">01 · Erscheinungsbild · 2025</p>
<h2 class="r" style="--v: 4%">Krume</h2>
</div>
<p class="text r" style="--v: 8%">Eine Backstube in der Südvorstadt. Der Brotlaib mit drei Schnitten steht heute auf Tüten, auf der Markise und auf dem Lastenrad.</p>
</article>
<article class="arbeit">
<figure class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/marke-eselsohr-hoch.jpg"><img src="bilder/marke-eselsohr.jpg" alt="Wortbildmarke Eselsohr: ein Blatt mit rosa umgeknickter Ecke neben kursivem Schriftzug auf Anthrazit" width="1200" height="800" loading="lazy"></picture></figure>
<div>
<p class="mono r">02 · Logo und Beschilderung · 2024</p>
<h2 class="r" style="--v: 4%">Eselsohr</h2>
</div>
<p class="text r" style="--v: 8%">Eine Buchhandlung mit Lesecafé in Connewitz. Das Zeichen ist eine einzelne Seite, deren Ecke umknickt wie bei einem Buch, das man gern liest.</p>
</article>
<article class="arbeit">
<figure class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/magazin-doppelseite-hoch.jpg"><img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin Tischgespräch: Doppelseite „Die Ruhe der Dinge“ mit Keramikfoto auf Terrakotta" width="1200" height="800" loading="lazy"></picture></figure>
<div>
<p class="mono r">03 · Editorial · 2024</p>
<h2 class="r" style="--v: 4%">Tischgespräch</h2>
</div>
<p class="text r" style="--v: 8%">Ein Hausmagazin über Handwerk. 64 Seiten, eine Antiqua und ein Raster mit zwölf Spalten, das jede Seite trägt.</p>
</article>
<article class="arbeit">
<figure class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/flakon-hoch.jpg"><img src="bilder/flakon.jpg" alt="Bernsteinfarbener Parfumflakon auf weißem Podest vor kräftigem Kobaltblau" width="1200" height="800" loading="lazy"></picture></figure>
<div>
<p class="mono r">04 · Packaging · 2023</p>
<h2 class="r" style="--v: 4%">Kobalt 12</h2>
</div>
<p class="text r" style="--v: 8%">Für eine kleine Duftmanufaktur. Bernstein im kantigen Glas auf Kobaltblau, weil ein Flakon auch laut sein darf.</p>
</article>
</div>
</section>
<section class="schluss">
<p class="mono r">Frühjahr 2027</p>
<p class="gross r" style="--v: 4%">Wir haben Platz für <span class="strich">zwei neue Projekte.</span></p>
<p class="mono r" style="--v: 8%"><a href="#">Gottschedstraße 12 · 04109 Leipzig →</a></p>
</section>
</main>
<aside class="werte mono" aria-label="Werte der Variante">
<button type="button" class="werte__knopf" aria-expanded="false" aria-controls="werte-inhalt">Werte</button>
<div class="werte__inhalt" id="werte-inhalt" aria-live="polite" data-werte></div>
</aside>
<script>
(() => {
const html = document.documentElement;
const kannKoppeln = CSS.supports('animation-timeline: view()');
const VARIANTEN = {
sanft: { range: 'entry 25% cover 50%', weg: 'translate −15px 0', kurve: 'linear', quelle: 'nerdy.dev' },
weit: { range: 'entry 0% cover 35%', weg: 'translate −4rem 0', kurve: 'ease-in', quelle: 'una.im' },
symmetrisch: { range: 'entry 0–100% · exit 0–100%', weg: 'translate 0 2rem · scale .96', kurve: 'linear', quelle: 'Steckbrief' },
};
const anzeige = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-v]')];
let variante = 'sanft';
function setzen(v) {
variante = v;
html.dataset.variante = v;
knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
const w = VARIANTEN[v];
anzeige.innerHTML = html.classList.contains('reduziert')
? `<span>modus</span><b>reduzierte Bewegung</b><span>effekt</span><b>aus · Inhalte sofort sichtbar</b>`
: kannKoppeln
? `<span>timeline</span><b>view()</b><span>range</span><b>${w.range}</b><span>bewegung</span><b>${w.weg} · ${w.kurve}</b>`
: `<span>hinweis</span><b>kein view()-Support · Fallback</b><span>bewegung</span><b>${w.weg}</b>`;
}
knoepfe.forEach(b => b.addEventListener('click', () => { pause(); setzen(b.dataset.v); }));
// Telefon: Werte-Chip auf- und zuklappen
const werteBox = document.querySelector('.werte'), werteKnopf = document.querySelector('.werte__knopf');
werteKnopf.addEventListener('click', () => {
const offen = werteBox.classList.toggle('offen');
werteKnopf.setAttribute('aria-expanded', String(offen));
});
setzen(variante);
addEventListener('load', () => setzen(variante)); // ?reduziert setzt die Klasse erst im Rahmen-Skript
// Fallback ohne Support: einmalig einblenden
if (!kannKoppeln && 'IntersectionObserver' in window) {
const io = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) { e.target.classList.add('da'); io.unobserve(e.target); } }), { threshold: 0.2 });
document.querySelectorAll('.r').forEach(el => io.observe(el));
}
// Vorführung: die Seite scrollt von selbst; in der Galerie-Vorschau wechselt jede Runde die Variante
let uhr = null, t0 = 0, vorher = 0, runde = 0;
const DAUER = 11000, folge = ['sanft', 'weit', 'symmetrisch'];
function vorfuehren() {
pause(); scrollTo(0, 0);
t0 = performance.now(); vorher = 0; runde = 0;
if (html.classList.contains('vorschau')) setzen(folge[0]);
const schritt = t => {
const p = ((t - t0) % DAUER) / DAUER;
if (p < vorher && html.classList.contains('vorschau')) setzen(folge[++runde % folge.length]);
vorher = p;
const max = html.scrollHeight - innerHeight;
const q = Math.min(1, Math.max(0, (p - 0.05) / 0.88));
scrollTo(0, max * 0.84 * (0.5 - 0.5 * Math.cos(q * Math.PI)));
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; }
function neustart() { pause(); scrollTo(0, 0); }
addEventListener('wheel', () => { if (uhr && !html.classList.contains('vorschau')) pause(); }, { passive: true });
addEventListener('touchstart', () => { if (uhr && !html.classList.contains('vorschau')) pause(); }, { passive: true });
})();
</script>
</body>
</html>