CSS Scroll-Triggered Animations (zeitbasiert, ausgelöst durch Scroll)
Zeitbasierte CSS-Animationen, die der Scroll nur auslöst: Projektbilder und Texte eines Architekturbüros spielen ihre 350 oder 750 ms zu Ende, auch wenn der Scroll steht, und laufen beim Verlassen rückwärts.
Eine normale, zeitbasierte CSS-Animation (etwa 350 ms) startet nicht beim Laden, sondern durch einen Scroll-Trigger. timeline-trigger legt Name, Quelle (view() oder scroll()) und Aktivierungsbereich fest, nach einem Schrägstrich optional einen größeren Aktiv-Bereich. animation-trigger verbindet Animation und Trigger mit Aktionen, zum Beispiel play-forwards beim Eintritt und play-backwards beim Verlassen. Anders als bei scroll-driven Animationen läuft die Animation nach dem Auslösen in ihrer Dauer zu Ende, auch wenn der Scroll stoppt.
Sorgfältig getimte Reveals mit eigener Kurve und Dauer, aber nativ im Browser und ohne IntersectionObserver-JS.
Wann einsetzen
Einmalige oder umkehrbare Einblendungen von Karten, Überschriften und Bildern beim Scrollen, auch Akkordeon-artige Karten.
Wann nicht
Wenn die Animation exakt am Scroll kleben soll (dann scroll-driven). Und nicht als einzige Lösung, solange nur Chromium ab 145 es kann: ein Rückfall ist nötig.
Varianten
- play-forwards und play-backwards (umkehrbar)
- Getrennte Aktivierungs- und Aktiv-Bereiche (contain, cover)
- Trigger auf der Sektion, Animation auf den Kindern
- trigger-scope bei vielen gleichen Elementen
- interpolate-size: allow-keywords, um die Höhe auf auto zu animieren
Umsetzung
Beispiel aus dem Chrome-Blog: .card { timeline-trigger: --t view() contain 15% contain 85% / entry 100% exit 0%; trigger-scope: --t; animation: unclip .35s ease-in-out both; animation-trigger: --t play-forwards play-backwards } .card > * { interpolate-size: allow-keywords; animation: card-contents .35s ease-in-out both; animation-trigger: --t play-forwards play-backwards }. Ohne Bereich gilt cover. Trigger-Namen sind global, die letzte Deklaration gewinnt, deshalb trigger-scope setzen. Erkennung über @supports (timeline-trigger-name: --x) oder CSS.supports(); sonst ein IntersectionObserver, der eine Klasse setzt. Für bildhohe Elemente ist contain 15 % bis 85 % sehr schmal; dort einen cover-Bereich wählen. Weitere Aktionen stehen in der Spezifikation, der Artikel zeigt nur play-forwards und play-backwards.
Robust umsetzen
Telefon. Unproblematisch.
Ohne Bewegung. animation: none unter prefers-reduced-motion: reduce, oder nur Deckkraft.
Barrierefrei. Ohne Unterstützung oder ohne Auslösung muss der sichtbare Endzustand gelten. fill-mode und Startzustand prüfen: Die versteckten Startwerte gehören nur in die @supports-Regel.
Code
<!DOCTYPE html>
<!--
CSS Scroll-Triggered Animations (zeitbasiert, ausgelöst durch Scroll)
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/css-scroll-triggered/
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>CSS Scroll-Triggered Animations · 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: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; 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; }
:root {
/* Rezept architektur-ruhiges-raster */
--grund: #ffffff; --flaeche: #e1e3e3; --text: #231f20; --leise: #76767a; --dunkel: #1f2323; --fokus: #0022ff; --linie: rgba(35, 31, 32, .14);
/* Messwert des Musters (Chrome-Beispiel): 350 ms ease-in-out; Bilder nach Rezept 750 ms */
--kurve: cubic-bezier(0.42, 0, 0.58, 1);
--kurz: 350ms; --bild: 750ms; --versatz: 80ms;
--rand: clamp(18px, 3.6vw, 52px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 1.0625rem/1.45 'Hanken Grotesk', system-ui, sans-serif; letter-spacing: -.012em; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--fokus); outline-offset: 3px; }
em { font-style: italic; }
.meta { font-size: .8rem; line-height: 1.4; letter-spacing: .02em; color: var(--leise); }
.versal { text-transform: uppercase; letter-spacing: .06em; font-size: .8rem; font-weight: 500; }
.kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; padding: 20px var(--rand); }
.kopf nav { display: flex; gap: clamp(14px, 2vw, 30px); }
.kopf nav a { color: var(--leise); transition: color 250ms var(--kurve); }
.kopf nav a[aria-current], .kopf nav a:hover { color: var(--text); }
.raster { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: clamp(12px, 1.8vw, 28px); padding: 0 var(--rand); }
.intro { padding-top: clamp(48px, 12vh, 140px); padding-bottom: clamp(56px, 14vh, 160px); align-items: end; }
.intro h1 { grid-column: 1 / 8; font-size: clamp(1.953rem, 1.2rem + 2.4vw, 3.052rem); font-weight: 450; line-height: 1.12; letter-spacing: -.02em; text-wrap: balance; }
.intro p { grid-column: 9 / 13; max-width: 50ch; }
.intro dl { grid-column: 1 / 13; display: flex; flex-wrap: wrap; gap: 8px 40px; margin-top: clamp(36px, 7vh, 80px); padding-top: 14px; border-top: 1px solid var(--linie); }
.intro dt { color: var(--leise); }
.intro dd { color: var(--text); }
.intro dl div { display: flex; gap: 10px; }
.liste-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding: 0 var(--rand) 22px; }
.liste-kopf h2 { font-size: 1.25rem; font-weight: 450; }
.liste-kopf .filter { display: flex; gap: 18px; }
.liste-kopf .filter span[aria-current] { color: var(--text); }
.projekt { padding-bottom: clamp(80px, 16vh, 200px); align-items: end; }
.bild { position: relative; overflow: clip; background: var(--flaeche); }
.bild canvas, .bild svg { display: block; width: 100%; height: auto; }
.deckel { position: absolute; inset: 0; background: var(--flaeche); transform-origin: 50% 0; scale: 1 0; }
.text { display: grid; gap: 14px; max-width: 50ch; }
.text .nr { font-variant-numeric: tabular-nums; }
.text h3 { font-size: clamp(1.25rem, 1rem + .9vw, 1.953rem); font-weight: 450; letter-spacing: -.02em; line-height: 1.15; }
.text dl { display: grid; grid-template-columns: auto 1fr; gap: 3px 18px; padding-top: 12px; border-top: 1px solid var(--linie); }
.text dt { color: var(--leise); }
.p1 .bild { grid-column: 1 / 8; } .p1 .text { grid-column: 9 / 13; }
.p2 .bild { grid-column: 7 / 12; } .p2 .text { grid-column: 2 / 6; grid-row: 1; }
.p3 .bild { grid-column: 2 / 10; } .p3 .text { grid-column: 10 / 13; padding-left: 8px; }
.p4 .bild { grid-column: 1 / 6; } .p4 .text { grid-column: 7 / 11; }
.p5 .bild { grid-column: 3 / 13; background: #fff; box-shadow: inset 0 0 0 1px var(--linie); } .p5 .text { grid-column: 1 / 3; grid-row: 1; }
.fuss { background: var(--dunkel); color: #e1e3e3; padding: 56px var(--rand) 64px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; font-size: .9375rem; }
.fuss .meta { color: #9a9c9c; }
/* ---------- Der Effekt: native Trigger (Chrome 145+) ---------- */
@keyframes deckel-weg { from { scale: 1 1; } to { scale: 1 0; } }
@keyframes rein { from { opacity: 0; translate: 0 1.5rem; } }
@supports (timeline-trigger-name: --x) {
/* A) Trigger je Element: Bild und Text haben eigene Trigger, trigger-scope hält die Namen im Projekt */
.v-element .projekt { trigger-scope: --bild, --text; }
.v-element .bild { timeline-trigger: --bild view() cover 25% cover 75% / cover 0% cover 100%; }
.v-element .deckel { animation: deckel-weg var(--bild) var(--kurve) both; animation-trigger: --bild play-forwards play-backwards; }
.v-element .text { timeline-trigger: --text view() contain 15% contain 85% / entry 100% exit 0%; animation: rein var(--kurz) var(--kurve) both; animation-trigger: --text play-forwards play-backwards; }
/* B) Trigger auf der Sektion, Animation auf den Kindern (gestaffelt) */
.v-sektion .projekt { timeline-trigger: --p view() cover 25% cover 75% / cover 0% cover 100%; trigger-scope: --p; }
.v-sektion .deckel { animation: deckel-weg var(--bild) var(--kurve) both; animation-trigger: --p play-forwards play-backwards; }
.v-sektion .text > * { animation: rein var(--kurz) var(--kurve) both; animation-delay: calc(200ms + var(--i, 0) * var(--versatz)); animation-trigger: --p play-forwards play-backwards; }
/* C) Ohne eigenen Aktiv-Bereich: kehrt schon am Rand des Aktivierungsbereichs um */
.v-eng .projekt { trigger-scope: --bild, --text; }
.v-eng .bild { timeline-trigger: --bild view() cover 25% cover 75%; }
.v-eng .deckel { animation: deckel-weg var(--bild) var(--kurve) both; animation-trigger: --bild play-forwards play-backwards; }
.v-eng .text { timeline-trigger: --text view() contain 15% contain 85%; animation: rein var(--kurz) var(--kurve) both; animation-trigger: --text play-forwards play-backwards; }
}
/* Rückfall ohne Trigger-Unterstützung: IntersectionObserver setzt .an, Übergänge statt Animationen */
.rueckfall .deckel { scale: 1 1; transition: scale var(--bild) var(--kurve); }
.rueckfall .an .deckel { scale: 1 0; }
.rueckfall .text-teil { opacity: 0; translate: 0 1.5rem; transition: opacity var(--kurz) var(--kurve), translate var(--kurz) var(--kurve); }
.rueckfall .an .text-teil { opacity: 1; translate: 0 0; }
.rueckfall.v-sektion .an .text > * { transition-delay: calc(200ms + var(--i, 0) * var(--versatz)); }
/* Reduzierte Bewegung: sichtbarer Endzustand, nichts bewegt sich */
:root.reduziert .deckel { display: none; }
:root.reduziert .text, :root.reduziert .text > *, :root.reduziert .text-teil { animation: none !important; opacity: 1 !important; translate: none !important; transition: none !important; }
/* Pult */
.pult { position: fixed; right: 14px; bottom: 14px; z-index: 10; width: min(420px, calc(100vw - 28px)); display: grid; gap: 8px; padding: 12px 14px; background: rgba(255, 255, 255, .94); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: 0 0 0 1px var(--linie), 0 14px 40px -24px rgba(0, 0, 0, .35); font: 500 10.5px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .04em; color: var(--leise); }
.pult__modi { display: flex; flex-wrap: wrap; gap: 4px; }
.pult button { font: inherit; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); background: none; border: 1px solid var(--linie); height: 26px; padding: 0 9px; cursor: pointer; transition: color 250ms var(--kurve), background-color 250ms var(--kurve); }
.pult button[aria-pressed='true'] { color: #fff; background: var(--text); border-color: var(--text); }
.pult code { font: inherit; color: var(--text); word-break: break-word; }
.pult .status { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.pult .status i { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--flaeche); margin-right: 6px; vertical-align: 0; transition: background-color 125ms var(--kurve); }
.pult .status .an i { background: var(--fokus); }
.vorschau body { overflow: hidden; }
.vorschau .intro, .vorschau .liste-kopf { display: none; }
.vorschau .projekt { padding-top: 28px; padding-bottom: 72px; }
@media (max-width: 860px) {
.intro h1, .intro p, .intro dl { grid-column: 1 / -1; }
.intro p { margin-top: 22px; }
.projekt .bild, .projekt .text { grid-column: 1 / -1 !important; grid-row: auto !important; padding-left: 0 !important; }
.projekt .text { margin-top: 18px; }
.p2 .bild, .p4 .bild { grid-column: 1 / 11 !important; }
.fuss { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 560px) {
.kopf nav a:not([aria-current]):not(:last-child) { display: none; }
.liste-kopf .filter { display: none; }
.fuss { grid-template-columns: 1fr; }
.p2 .bild, .p4 .bild { grid-column: 1 / -1 !important; }
}
@media (hover: none) { .kopf nav a:hover { color: var(--leise); } }
</style>
</head>
<body>
<header class="kopf">
<a href="#" class="versal" style="color:var(--text)">Halbhöhe Architekten</a>
<nav class="versal" aria-label="Hauptnavigation"><a href="#">Büro</a><a href="#" aria-current="page">Projekte</a><a href="#">Team</a><a href="#">Aktuelles</a><a href="#">Kontakt</a></nav>
</header>
<main>
<section class="raster intro" aria-labelledby="intro-titel">
<h1 id="intro-titel">Wir bauen <em>am Hang</em>: Wohnhäuser, Schulen und Umbauten in Stuttgart und der Region.</h1>
<p>Die meisten unserer Grundstücke sind steil, schmal oder schon bebaut. Wir sehen darin keinen Nachteil. Ein Haus, das auf den Hang antwortet, braucht weniger Erdbewegung und bekommt bessere Aussicht.</p>
<dl class="meta">
<div><dt>Büro</dt><dd>Stuttgart-Süd</dd></div>
<div><dt>Seit</dt><dd>2009</dd></div>
<div><dt>Partner</dt><dd>Miriam Gerstl, Jonas Aichele</dd></div>
<div><dt>Leistungsphasen</dt><dd>1 bis 9</dd></div>
</dl>
</section>
<div class="liste-kopf">
<h2>Ausgewählte Projekte</h2>
<p class="filter meta"><span aria-current="true">Alle</span><span>Wohnen</span><span>Bildung</span><span>Umbau</span><span>Gemeinde</span></p>
</div>
<article class="raster projekt p1">
<figure class="bild"><canvas data-szene="hang" width="1200" height="800" role="img" aria-label="Weißes Wohnhaus aus zwei versetzten Kuben am Hang, Abendlicht"></canvas><span class="deckel" aria-hidden="true"></span></figure>
<div class="text">
<p class="meta nr text-teil" style="--i:0">01</p>
<h3 class="text-teil" style="--i:1">Wohnhaus am Bopser</h3>
<p class="text-teil" style="--i:2">Zwei Geschosse, gegeneinander verschoben, damit beide Wohnungen den Blick über den Talkessel haben. Die Garage liegt im Hang, nicht davor.</p>
<dl class="meta text-teil" style="--i:3"><dt>Typologie</dt><dd>Wohnen, 2 Einheiten</dd><dt>BGF</dt><dd>412 m²</dd><dt>Fertig</dt><dd>2024</dd></dl>
</div>
</article>
<article class="raster projekt p2">
<figure class="bild"><canvas data-szene="kita" width="960" height="1200" role="img" aria-label="Niedriger Holzbau einer Kita mit senkrechter Lattung auf einer Wiese"></canvas><span class="deckel" aria-hidden="true"></span></figure>
<div class="text">
<p class="meta nr text-teil" style="--i:0">02</p>
<h3 class="text-teil" style="--i:1">Kita Feuerbach</h3>
<p class="text-teil" style="--i:2">Sechs Gruppen unter einem Dach aus Brettsperrholz. Jeder Gruppenraum hat eine eigene Tür in den Garten, die Flure sind breit genug zum Spielen.</p>
<dl class="meta text-teil" style="--i:3"><dt>Typologie</dt><dd>Bildung</dd><dt>BGF</dt><dd>1.380 m²</dd><dt>Fertig</dt><dd>2023</dd></dl>
</div>
</article>
<article class="raster projekt p3">
<figure class="bild"><canvas data-szene="kelter" width="1600" height="1000" role="img" aria-label="Alte Weinkelter mit Ziegeldach und gläsernem Anbau vor Rebhängen"></canvas><span class="deckel" aria-hidden="true"></span></figure>
<div class="text">
<p class="meta nr text-teil" style="--i:0">03</p>
<h3 class="text-teil" style="--i:1">Umbau Weinkelter <em>Uhlbach</em></h3>
<p class="text-teil" style="--i:2">Die Kelter von 1907 bleibt, wie sie ist. Daneben steht ein Glasraum für Proben und Feste, der sie nicht berührt.</p>
<dl class="meta text-teil" style="--i:3"><dt>Typologie</dt><dd>Umbau, Kultur</dd><dt>BGF</dt><dd>640 m²</dd><dt>Fertig</dt><dd>2022</dd></dl>
</div>
</article>
<article class="raster projekt p4">
<figure class="bild"><canvas data-szene="gemeinde" width="960" height="1200" role="img" aria-label="Gemeindehaus aus Sichtbeton mit hohen schmalen Fenstern, Birken davor"></canvas><span class="deckel" aria-hidden="true"></span></figure>
<div class="text">
<p class="meta nr text-teil" style="--i:0">04</p>
<h3 class="text-teil" style="--i:1">Gemeindehaus Degerloch</h3>
<p class="text-teil" style="--i:2">Ein Saal für 180 Personen, ein Raum für die Jugend, eine Küche, die jeder benutzen darf. Die schmalen Fenster lassen Licht herein und halten den Lärm der Straße draußen.</p>
<dl class="meta text-teil" style="--i:3"><dt>Typologie</dt><dd>Gemeinde</dd><dt>BGF</dt><dd>960 m²</dd><dt>Fertig</dt><dd>2021</dd></dl>
</div>
</article>
<article class="raster projekt p5">
<figure class="bild" data-schnitt></figure>
<div class="text">
<p class="meta nr text-teil" style="--i:0">05</p>
<h3 class="text-teil" style="--i:1">Stäffelehaus Heslach</h3>
<p class="text-teil" style="--i:2">Vier Meter breit, an einer öffentlichen Treppe. Jedes Geschoss hat seinen eigenen Eingang von den Stufen aus.</p>
<dl class="meta text-teil" style="--i:3"><dt>Typologie</dt><dd>Wohnen</dd><dt>Plan</dt><dd>Schnitt A–A</dd><dt>Fertig</dt><dd>2026</dd></dl>
</div>
</article>
</main>
<footer class="fuss">
<p class="versal">Halbhöhe Architekten</p>
<p>Partnerschaft mbB<br>Miriam Gerstl, Jonas Aichele</p>
<p>Böblinger Straße 72<br>70199 Stuttgart</p>
<p><a href="#">buero@halbhoehe-architekten.de</a><br><span class="meta">Mitglied der Architektenkammer Baden-Württemberg</span></p>
</footer>
<div class="pult">
<div class="pult__modi" role="group" aria-label="Variante">
<button type="button" data-variante="element" aria-pressed="true">Trigger je Element</button>
<button type="button" data-variante="sektion" aria-pressed="false">Sektion → Kinder</button>
<button type="button" data-variante="eng" aria-pressed="false">Ohne Aktiv-Bereich</button>
</div>
<code data-code></code>
<div class="status"><span data-scroll><i></i>Scroll steht</span><span data-laeuft><i></i>0 Animationen laufen</span><span data-modus></span></div>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert') || new URLSearchParams(location.search).has('reduziert');
const $ = (s, r = document) => r.querySelector(s), $$ = (s, r = document) => [...r.querySelectorAll(s)];
const nativ = CSS.supports('timeline-trigger-name: --x');
/* ---------- Bilder: ruhige Architektur-Szenen auf Canvas ---------- */
const lin = (c, x0, y0, x1, y1, st) => { const g = c.createLinearGradient(x0, y0, x1, y1); st.forEach(([o, f]) => g.addColorStop(o, f)); return g; };
const weich = (c, px, fn) => { c.save(); c.filter = 'blur(' + px + 'px)'; fn(); c.restore(); };
function rng(s) { return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); }
function baeume(c, r, xs, y, gr, farben) {
xs.forEach(x => weich(c, gr * .08, () => { for (let k = 0; k < 7; k++) { c.fillStyle = farben[k % farben.length]; c.beginPath(); c.arc(x + (r() - .5) * gr, y - r() * gr * 1.1, gr * (.35 + r() * .3), 0, 7); c.fill(); } }));
}
function quader(c, x, y, w, h, tiefe, licht, schatten) {
c.fillStyle = licht; c.fillRect(x, y, w, h);
c.fillStyle = schatten; c.beginPath(); c.moveTo(x + w, y); c.lineTo(x + w + tiefe, y - tiefe * .35); c.lineTo(x + w + tiefe, y + h - tiefe * .35); c.lineTo(x + w, y + h); c.fill();
c.fillStyle = 'rgba(255,255,255,.6)'; c.fillRect(x, y, w, 2);
}
const SZENEN = {
hang(c, w, h, r) {
c.fillStyle = lin(c, 0, 0, 0, h * .7, [[0, '#b9c6d2'], [.7, '#e9e1d8'], [1, '#f1dfcb']]); c.fillRect(0, 0, w, h);
weich(c, 30, () => { c.fillStyle = 'rgba(132,140,150,.45)'; c.beginPath(); c.moveTo(0, h * .5); for (let x = 0; x <= w; x += w / 12) c.lineTo(x, h * (.46 + r() * .05)); c.lineTo(w, h); c.lineTo(0, h); c.fill(); });
baeume(c, r, [w * .08, w * .2, w * .82, w * .93], h * .62, w * .06, ['#5d6a4a', '#4c5a3d', '#6b7a55']);
c.fillStyle = lin(c, 0, h * .55, 0, h, [[0, '#7c8a5e'], [1, '#56633f']]); c.beginPath(); c.moveTo(0, h * .58); c.lineTo(w, h * .76); c.lineTo(w, h); c.lineTo(0, h); c.fill();
// Haus: zwei versetzte Kuben
quader(c, w * .3, h * .44, w * .34, h * .2, w * .07, '#f3f1ec', '#cfccc5');
quader(c, w * .38, h * .27, w * .32, h * .17, w * .07, '#f6f4ef', '#d3d0c9');
c.fillStyle = lin(c, 0, h * .31, 0, h * .41, [[0, '#f2c98f'], [1, '#c98e55']]); c.fillRect(w * .41, h * .31, w * .25, h * .085);
c.fillStyle = '#2e3236'; c.fillRect(w * .33, h * .5, w * .12, h * .1); c.fillRect(w * .5, h * .5, w * .1, h * .1);
c.fillStyle = 'rgba(255,255,255,.25)'; c.fillRect(w * .33, h * .5, w * .05, h * .1);
c.fillStyle = 'rgba(40,36,30,.5)'; c.fillRect(w * .41, h * .37, w * .25, 2);
weich(c, 14, () => { c.fillStyle = 'rgba(40,46,30,.45)'; c.beginPath(); c.moveTo(w * .3, h * .64); c.lineTo(w * .71, h * .64); c.lineTo(w * .82, h * .71); c.lineTo(w * .36, h * .7); c.fill(); });
},
kita(c, w, h, r) {
c.fillStyle = lin(c, 0, 0, 0, h * .6, [[0, '#9fbdd6'], [1, '#e5edf1']]); c.fillRect(0, 0, w, h);
weich(c, 40, () => { c.fillStyle = 'rgba(255,255,255,.7)'; c.beginPath(); c.ellipse(w * .7, h * .16, w * .3, h * .04, 0, 0, 7); c.fill(); });
baeume(c, r, [w * .1, w * .55, w * .9], h * .5, w * .1, ['#5c7048', '#6f8457', '#4e613c']);
c.fillStyle = lin(c, 0, h * .55, 0, h, [[0, '#93a86a'], [1, '#6f8550']]); c.fillRect(0, h * .56, w, h * .44);
const y = h * .43, bh = h * .14;
c.fillStyle = '#a97c52'; c.fillRect(-10, y, w + 20, bh);
for (let x = 0; x < w; x += w * .018) { c.fillStyle = r() > .5 ? 'rgba(70,45,25,.35)' : 'rgba(255,220,180,.12)'; c.fillRect(x, y, Math.max(1, w * .005), bh); }
c.fillStyle = '#f2f0ea'; c.fillRect(-10, y - h * .018, w + 20, h * .018);
c.fillStyle = 'rgba(0,0,0,.18)'; c.fillRect(-10, y, w + 20, h * .012);
for (let i = 0; i < 4; i++) { const x = w * (.06 + i * .25); c.fillStyle = lin(c, 0, y + bh * .2, 0, y + bh, [[0, '#33393d'], [1, '#22272a']]); c.fillRect(x, y + bh * .22, w * .13, bh * .78); c.fillStyle = 'rgba(190,210,225,.25)'; c.fillRect(x, y + bh * .22, w * .05, bh * .78); }
weich(c, 10, () => { c.fillStyle = 'rgba(45,60,30,.35)'; c.fillRect(-10, y + bh, w + 20, h * .03); });
c.fillStyle = '#c8b48a'; c.beginPath(); c.moveTo(w * .4, h * .57); c.lineTo(w * .52, h * .57); c.lineTo(w * .7, h); c.lineTo(w * .3, h); c.fill();
},
kelter(c, w, h, r) {
c.fillStyle = lin(c, 0, 0, 0, h * .5, [[0, '#cfd6da'], [1, '#eef0ef']]); c.fillRect(0, 0, w, h);
// Rebhang mit Zeilen
c.fillStyle = lin(c, 0, h * .15, 0, h * .6, [[0, '#8a9668'], [1, '#6f7b4f']]); c.beginPath(); c.moveTo(0, h * .2); c.quadraticCurveTo(w * .45, h * .06, w, h * .3); c.lineTo(w, h * .62); c.lineTo(0, h * .62); c.fill();
c.strokeStyle = 'rgba(60,70,40,.4)'; c.lineWidth = Math.max(1, w * .002);
for (let i = 0; i < 26; i++) { const y0 = h * (.2 + i * .016); c.beginPath(); c.moveTo(0, y0 + h * .04); c.quadraticCurveTo(w * .45, y0 - h * .1 + i * h * .008, w, y0 + h * .12); c.stroke(); }
c.fillStyle = '#d9d4c7'; c.fillRect(0, h * .6, w, h * .4);
// Kelter: Bruchstein, Ziegeldach
const kx = w * .14, ky = h * .36, kw = w * .42, kh = h * .26;
c.fillStyle = '#cdbfa4'; c.fillRect(kx, ky, kw, kh);
for (let i = 0; i < 260; i++) { c.fillStyle = r() > .5 ? 'rgba(120,100,70,.18)' : 'rgba(255,250,235,.18)'; c.fillRect(kx + r() * kw, ky + r() * kh, w * (.01 + r() * .015), h * .008); }
c.fillStyle = '#93473a'; c.beginPath(); c.moveTo(kx - w * .015, ky); c.lineTo(kx + kw / 2, ky - h * .17); c.lineTo(kx + kw + w * .015, ky); c.fill();
c.fillStyle = 'rgba(0,0,0,.12)'; for (let i = 1; i < 9; i++) c.fillRect(kx, ky - h * .17 * (1 - i / 9) + 0, kw, 1);
c.fillStyle = '#3b3530'; c.beginPath(); c.moveTo(kx + kw * .42, ky + kh); c.lineTo(kx + kw * .42, ky + kh * .45); c.arc(kx + kw * .5, ky + kh * .45, kw * .08, Math.PI, 0); c.lineTo(kx + kw * .58, ky + kh); c.fill();
// Glasraum daneben, mit Fuge
const gx = kx + kw + w * .04, gy = ky + kh * .28, gw = w * .3, gh = kh * .72;
c.fillStyle = lin(c, gx, gy, gx + gw, gy + gh, [[0, '#a8bcc2'], [.5, '#e2eaec'], [1, '#8fa3aa']]); c.fillRect(gx, gy, gw, gh);
c.fillStyle = 'rgba(40,50,55,.55)'; for (let x = gx; x <= gx + gw; x += gw / 6) c.fillRect(x, gy, Math.max(1, w * .002), gh);
c.fillStyle = '#f4f3ef'; c.fillRect(gx - w * .005, gy - h * .012, gw + w * .01, h * .012);
c.fillStyle = 'rgba(255,214,150,.35)'; c.fillRect(gx + gw * .1, gy + gh * .35, gw * .5, gh * .65);
weich(c, 12, () => { c.fillStyle = 'rgba(60,55,45,.3)'; c.fillRect(kx, ky + kh, w * .75, h * .03); });
},
gemeinde(c, w, h, r) {
c.fillStyle = lin(c, 0, 0, 0, h * .6, [[0, '#c5c9cc'], [1, '#e6e7e6']]); c.fillRect(0, 0, w, h);
const x = w * .12, y = h * .2, bw = w * .7, bh = h * .52;
quader(c, x, y, bw, bh, w * .12, '#bdbab3', '#9d9a94');
for (let i = 0; i < 1200; i++) { c.fillStyle = r() > .5 ? 'rgba(255,255,255,.07)' : 'rgba(0,0,0,.06)'; c.fillRect(x + r() * bw, y + r() * bh, 2, 2); }
c.fillStyle = 'rgba(0,0,0,.08)'; for (let k = 1; k < 6; k++) c.fillRect(x, y + bh * k / 6, bw, 1);
for (let k = 0; k < 5; k++) { const fx = x + bw * (.1 + k * .18); c.fillStyle = lin(c, 0, y, 0, y + bh, [[0, '#2f3437'], [1, '#1f2326']]); c.fillRect(fx, y + bh * .12, bw * .05, bh * .78); c.fillStyle = 'rgba(200,215,225,.2)'; c.fillRect(fx, y + bh * .12, bw * .02, bh * .78); }
c.fillStyle = '#cfccc4'; c.fillRect(0, y + bh, w, h - y - bh);
c.fillStyle = 'rgba(0,0,0,.06)'; for (let k = 0; k < 14; k++) c.fillRect(0, y + bh + k * h * .02, w, 1);
// Birken
for (let k = 0; k < 3; k++) {
const bx = w * (.18 + k * .3 + r() * .05), stamm = w * .012;
c.fillStyle = '#ece9e2'; c.fillRect(bx, h * .38, stamm, h * .46);
c.fillStyle = '#3a3a36'; for (let j = 0; j < 8; j++) c.fillRect(bx, h * (.4 + r() * .42), stamm, h * .006);
weich(c, 8, () => { c.fillStyle = 'rgba(150,165,90,.55)'; for (let j = 0; j < 6; j++) { c.beginPath(); c.arc(bx + (r() - .5) * w * .14, h * (.3 + r() * .14), w * (.04 + r() * .03), 0, 7); c.fill(); } });
}
},
};
$$('canvas[data-szene]').forEach((cv, i) => { const c = cv.getContext('2d'); SZENEN[cv.dataset.szene](c, cv.width, cv.height, rng(31 * (i + 7))); });
// Schnitt als Strichzeichnung (SVG): Stäffele, Haus mit vier Ebenen
$('[data-schnitt]').insertAdjacentHTML('afterbegin', `<svg viewBox="0 0 1200 640" role="img" aria-label="Schnitt A–A durch das Stäffelehaus: vier Geschosse an einer öffentlichen Treppe">
<g fill="none" stroke="#231f20" stroke-width="1.2">
<path d="M40 560 H220 V520 H270 V480 H320 V440 H370 V400 H420 V360 H470 V320 H520 V280 H570 V240 H1160" stroke-width="2"/>
<path d="M40 560 V600 H1160 V240" stroke="#76767a" stroke-dasharray="2 6"/>
<rect x="640" y="90" width="300" height="470" stroke-width="2"/>
<path d="M640 215 H940 M640 330 H940 M640 445 H940" stroke-width="3"/>
<path d="M640 90 L790 40 L940 90" stroke-width="2"/>
<path d="M940 140 H1000 M940 255 H1000 M940 370 H1000" />
<path d="M690 560 V480 M720 560 V470 M690 445 V375 M760 330 V250" stroke="#76767a"/>
<path d="M570 240 H640 M520 280 V330 H640 M420 360 V445 H640" stroke="#76767a" stroke-dasharray="4 4"/>
</g>
<g fill="#e1e3e3"><rect x="645" y="95" width="290" height="117"/><rect x="645" y="218" width="290" height="109" opacity=".55"/></g>
<g fill="none" stroke="#0022ff" stroke-width="1"><path d="M600 40 V600"/><path d="M590 40 H610 M590 600 H610"/></g>
<g font-family="Hanken Grotesk, sans-serif" font-size="15" fill="#76767a" letter-spacing=".3">
<text x="610" y="34" fill="#0022ff">A</text><text x="610" y="626" fill="#0022ff">A</text>
<text x="960" y="136">EG +0,00</text><text x="960" y="251">1. OG +3,10</text><text x="960" y="366">2. OG +6,20</text><text x="960" y="80">DG +9,30</text>
<text x="60" y="545">Stäffele, öffentlich</text>
<text x="40" y="40" fill="#231f20">Schnitt A–A · 1:200</text>
<path d="M40 620 H140" stroke="#231f20" stroke-width="2"/><text x="150" y="625">5 m</text>
</g></svg><span class="deckel" aria-hidden="true"></span>`);
/* ---------- Varianten ---------- */
const CODE = {
element: '.bild { timeline-trigger: --bild view() cover 25% cover 75% / cover 0% cover 100% } .text { timeline-trigger: --text view() contain 15% contain 85% / entry 100% exit 0% } animation-trigger: … play-forwards play-backwards',
sektion: '.projekt { timeline-trigger: --p view() cover 25% cover 75% / cover 0% cover 100%; trigger-scope: --p } .text > * { animation-delay: 200ms + i × 80ms }',
eng: '.bild { timeline-trigger: --bild view() cover 25% cover 75% } .text { … contain 15% contain 85% } ohne Aktiv-Bereich: kehrt schon am Rand um',
};
let variante = 'element';
function setzeVariante(v) {
variante = v;
['element', 'sektion', 'eng'].forEach(n => html.classList.toggle('v-' + n, n === v));
$$('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
$('[data-code]').textContent = reduziert() ? 'Reduzierte Bewegung: keine Animation, Endzustand sofort sichtbar' : CODE[v];
if (ioRein) $$('.projekt').forEach(p => beobachte(p));
}
$$('[data-variante]').forEach(b => b.addEventListener('click', () => { pause(); setzeVariante(b.dataset.variante); }));
$('[data-modus]').textContent = reduziert() ? 'reduziert' : nativ ? 'nativ, ohne JS' : 'Rückfall: IntersectionObserver';
/* ---------- Rückfall ohne timeline-trigger ---------- */
let ioRein = null, ioRaus = null;
function beobachte(p) {
p.classList.remove('an'); ioRein.unobserve(p); ioRaus.unobserve(p); ioRein.observe(p); ioRaus.observe(p);
}
if (!nativ && !reduziert()) {
html.classList.add('rueckfall');
// Aktivierung: Element im mittleren Bereich (15 % Rand oben und unten); Aktiv-Bereich: solange es sichtbar ist
ioRein = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) e.target.classList.add('an'); }), { rootMargin: '-15% 0px -15% 0px', threshold: .25 });
ioRaus = new IntersectionObserver(es => es.forEach(e => {
const eng = variante === 'eng';
if (!e.isIntersecting || (eng && e.intersectionRatio < .6)) e.target.classList.remove('an');
}), { threshold: [0, .6] });
}
/* ---------- Status im Pult: Scroll steht/bewegt, laufende Animationen ---------- */
const scrollEl = $('[data-scroll]'), laeuftEl = $('[data-laeuft]');
let letzterScroll = 0, statusUhr = null;
addEventListener('scroll', () => { letzterScroll = performance.now(); }, { passive: true });
function status() {
const bewegt = performance.now() - letzterScroll < 140;
scrollEl.classList.toggle('an', bewegt); scrollEl.lastChild.textContent = bewegt ? 'Scroll bewegt' : 'Scroll steht';
const n = document.getAnimations().filter(a => { if (a.playState !== 'running') return false; const p = a.effect && a.effect.getComputedTiming().progress; return p !== null && p > 0 && p < 1; }).length;
laeuftEl.classList.toggle('an', n > 0); laeuftEl.lastChild.textContent = n === 1 ? '1 Animation läuft' : n + ' Animationen laufen';
statusUhr = setTimeout(status, 90);
}
/* ---------- Vorführung: Scroll in Schritten, Pausen zeigen das Weiterlaufen ---------- */
function bezier(x1, y1, x2, y2) {
return t => { let u = t; for (let i = 0; i < 6; i++) { const x = 3 * (1 - u) * (1 - u) * u * x1 + 3 * (1 - u) * u * u * x2 + u * u * u - t, d = 3 * (1 - u) * (1 - u) * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u * u * (1 - x2); if (Math.abs(d) < 1e-6) break; u -= x / d; u = Math.min(1, Math.max(0, u)); } return 3 * (1 - u) * (1 - u) * u * y1 + 3 * (1 - u) * u * u * y2 + u * u * u; };
}
const fahrKurve = bezier(.1, .6, .4, 1); // Rezept: Default-Kurve
let fahrt = null, uhr = null;
function fahre(ziel, dauer, fertig) {
cancelAnimationFrame(fahrt);
const start = scrollY, t0 = performance.now();
const s = t => { const p = Math.min(1, (t - t0) / dauer); scrollTo(0, start + (ziel - start) * (reduziert() ? 1 : fahrKurve(p))); if (p < 1) fahrt = requestAnimationFrame(s); else fertig && fertig(); };
fahrt = requestAnimationFrame(s);
}
let runde = 0;
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
if (runde > 0) setzeVariante(['element', 'sektion', 'element', 'eng'][runde % 4]);
runde++;
scrollTo(0, 0);
const max = () => document.documentElement.scrollHeight - innerHeight;
const schritt = Math.round(innerHeight * .5);
const runter = () => {
if (scrollY >= max() - 4) { uhr = setTimeout(hoch, 1600); return; }
fahre(Math.min(max(), scrollY + schritt), 650, () => { uhr = setTimeout(runter, 1300); });
};
const hoch = () => {
if (scrollY <= 4) { uhr = setTimeout(vorfuehren, 900); return; }
fahre(Math.max(0, scrollY - schritt * 1.6), 800, () => { uhr = setTimeout(hoch, 1200); });
};
uhr = setTimeout(runter, 300);
}
function pause() { cancelAnimationFrame(fahrt); clearTimeout(uhr); html.classList.remove('vorfuehrung'); }
function neustart() { pause(); scrollTo(0, 0); setzeVariante(variante); }
function weiter() { vorfuehren(); }
['wheel', 'touchstart', 'keydown'].forEach(t => addEventListener(t, () => { if (html.classList.contains('vorfuehrung')) pause(); }, { passive: true }));
setzeVariante('element');
status();
})();
</script>
</body>
</html>