Gestaffelte Einblendung per sibling-index()
Nach dem Filterwechsel kommen die Projektkarten eines Zürcher Architekturbüros nacheinander, der Verzug kommt rein aus CSS über sibling-index().
Elemente einer Liste oder eines Rasters erscheinen nacheinander statt gleichzeitig. Das geht ohne JavaScript: animation-delay: calc(sibling-index() * var(--stagger)). sibling-index() liefert die Position unter den Geschwistern, sibling-count() ihre Anzahl. Beide sind seit 18.08.2026 Baseline (Chrome und Edge ab 138, Safari ab 26.2, Firefox ab 154). Ein Skript, das --i setzt, braucht es nur noch als Rückfall für ältere Browser.
Die Staffelung gibt Rhythmus und führt das Auge in Lesereihenfolge. Inhalte wirken choreografiert statt aufgeploppt.
Wann einsetzen
Menüeinträge, Karten nach einem Filterwechsel, Suchergebnisse, Dropdown-Einträge, Toasts.
Wann nicht
Bei langen Listen: höchstens 6 bis 8 Elemente staffeln, den Rest gleichzeitig zeigen. Bei UI-Elementen, die man oft öffnet: dort nervt die Wartezeit.
Varianten
- Linear nach Index: calc((sibling-index() - 1) * var(--stagger))
- Aus der Mitte: Abstand zur Mitte als Verzug, |sibling-index() - (sibling-count() + 1) / 2|, mit max() statt abs() geschrieben
- Umgekehrt beim Schließen: calc((sibling-count() - sibling-index()) * var(--stagger))
- Mit Obergrenze: min(sibling-index() - 1, 5), damit die Gesamtdauer begrenzt bleibt
Umsetzung
.liste > * { --stagger: 150ms; animation: einrutschen 700ms cubic-bezier(0.1, 0.9, 0.3, 1) both, einblenden 900ms cubic-bezier(0.1, 0.9, 0.3, 1) both; animation-delay: calc(var(--i, 0) * var(--stagger)); animation-delay: calc((sibling-index() - 1) * var(--stagger)); } @keyframes einrutschen { from { translate: 0 8px } } @keyframes einblenden { from { opacity: 0 } } @media (prefers-reduced-motion: reduce) { .liste > * { animation: einblenden 150ms linear both; animation-delay: 0s } }. Die erste animation-delay-Zeile ist der Fallback (JS setzt --i), Browser ohne sibling-index() verwerfen die zweite. Damit die Animation bei einem Filterwechsel neu startet und nur die Treffer zählen, die Elemente neu einsetzen statt sie mit hidden zu verstecken: sibling-index() zählt auch versteckte Geschwister. Browserstand (Web Features Explorer, 02.11.2026): sibling-index() und sibling-count() sind Baseline Newly available seit 18.08.2026. Die @supports-Weiche mit JS-Rückfall bleibt sinnvoll, bis die Funktionen Widely available sind (etwa Anfang 2029); wer nur aktuelle Browser bedient, kann den Rückfall weglassen, dann erscheinen ältere Browser ohne Staffel, aber vollständig.
Robust umsetzen
Telefon. Identisch. Bei zwei Spalten folgt der Verzug der Lesereihenfolge.
Ohne Bewegung. Kein Verzug und keine Bewegung, höchstens ein kurzes gemeinsames Einblenden (150 ms).
Barrierefrei. Die Inhalte stehen sofort im DOM und sind fokussierbar, sie sind nur optisch verzögert. Filterknöpfe mit aria-pressed, die neue Trefferzahl über eine aria-live-Region ansagen.
Code
<!DOCTYPE html>
<!--
Gestaffelte Einblendung per sibling-index()
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/gestaffelte-einblendung/
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>Gestaffelte Einblendung per sibling-index() · 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; --meta: #6e6e72; --tertiaer: #c5c7c7; --dunkel: #1f2323; --fokus: #0022ff;
--linie: rgba(35, 31, 32, .12);
--ein: cubic-bezier(0.1, 0.9, 0.3, 1); /* Ausklang: Öffnen, Einblenden */
--aus: cubic-bezier(0.1, 0.6, 0.4, 1); /* Default: Schließen, UI */
--ui: 250ms;
/* Staffelung (Apple Chapternav): 150 ms je Element, 700 ms Rutschen um 8 px, 900 ms Einblenden */
--stagger: 150ms;
--deckel: 5;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Hanken Grotesk', system-ui, sans-serif; font-weight: 450; letter-spacing: -.012em; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body { min-height: 100svh; padding: 0 clamp(16px, 3.4vw, 48px); display: flex; flex-direction: column; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; letter-spacing: inherit; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--fokus); outline-offset: 3px; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.weg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Kopf */
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: baseline; gap: 24px; padding: clamp(16px, 2.4vw, 28px) 0; font-size: 15px; }
.marke { font-weight: 500; letter-spacing: -.02em; }
.marke span { color: var(--meta); font-weight: 450; }
.nav { display: flex; gap: clamp(16px, 2.2vw, 32px); list-style: none; }
.nav a { color: var(--meta); transition: color var(--ui) var(--aus); }
.nav a:hover, .nav a[aria-current] { color: var(--text); }
.nav a[aria-current] { text-decoration: underline; text-underline-offset: .35ex; text-decoration-thickness: 8%; }
.sprache { justify-self: end; color: var(--meta); }
.sprache b { color: var(--text); font-weight: 450; }
.menue { display: none; justify-self: end; font-size: 15px; }
/* Werkverzeichnis */
.werk { flex: 1; display: flex; flex-direction: column; padding-top: clamp(20px, 4.5vh, 56px); }
.einstieg { display: grid; grid-template-columns: 1fr minmax(0, 50ch); align-items: end; gap: 24px 48px; }
h1 { font-size: clamp(1.953rem, 1.2rem + 2.4vw, 3.052rem); font-weight: 450; letter-spacing: -.02em; line-height: 1; }
h1 sup { font-size: .32em; vertical-align: top; position: relative; top: .2em; margin-left: .25em; color: var(--meta); letter-spacing: 0; font-variant-numeric: tabular-nums; }
.einstieg p { font-size: 1.0625rem; line-height: 1.45; color: var(--meta); text-wrap: pretty; }
.einstieg p em { color: var(--text); }
.filter { display: flex; flex-wrap: wrap; gap: 8px; margin: clamp(20px, 3.2vh, 36px) 0 clamp(16px, 2.6vh, 28px); }
.filter button {
height: 34px; padding: 0 15px; border-radius: 999px; font-size: 14px; display: inline-flex; align-items: center; gap: 7px;
box-shadow: inset 0 0 0 1px var(--tertiaer);
transition: background-color var(--ui) var(--aus), color var(--ui) var(--aus), box-shadow var(--ui) var(--aus);
}
.filter button:hover { box-shadow: inset 0 0 0 1px var(--text); }
.filter button small { font-size: 11px; color: var(--meta); font-variant-numeric: tabular-nums; transition: color var(--ui) var(--aus); }
.filter button[aria-pressed="true"] { background: var(--text); color: var(--grund); box-shadow: inset 0 0 0 1px var(--text); }
.filter button[aria-pressed="true"] small { color: var(--tertiaer); }
.filter button.drueck { background: var(--flaeche); }
/* Raster */
.raster { list-style: none; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: clamp(20px, 3vh, 32px) clamp(12px, 1.7vw, 24px); align-content: start; min-height: calc(2 * (var(--karte-h, 230px)) + 32px); }
.karte a { display: block; }
.modell { position: relative; aspect-ratio: 4 / 3; background: radial-gradient(120% 95% at 28% 18%, #f3f4f4 0%, #e6e8e8 55%, #d9dbdb 100%); overflow: hidden; }
.modell svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; transition: scale 500ms var(--aus); }
.karte a:hover .modell svg { scale: 1.03; }
.verzug { position: absolute; left: 8px; top: 8px; font: 500 10px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; color: var(--meta); font-variant-numeric: tabular-nums; }
.karte figcaption { padding-top: 10px; display: grid; gap: 3px; }
.karte .titel { font-size: 15px; line-height: 1.25; letter-spacing: -.012em; text-underline-offset: .35ex; text-decoration-thickness: 8%; }
.karte a:hover .titel, .karte a:focus-visible .titel { text-decoration: underline; }
.karte .info { font-size: .8rem; color: var(--meta); }
/* ===== Der Effekt =====
Jede Karte wird beim Filterwechsel neu eingesetzt, dadurch startet ihre Animation neu.
Der Verzug kommt aus der Position unter den Geschwistern: sibling-index() / sibling-count().
Zuerst die Fallback-Deklaration (JS setzt --i und --n), dann die native; Browser ohne
sibling-index() verwerfen die zweite. */
.raster > li {
--schritt: var(--i, 0);
animation: einrutschen 700ms var(--ein) both, einblenden 900ms var(--ein) both;
animation-delay: calc(var(--schritt) * var(--stagger));
}
.raster[data-staffel="mitte"] > li { --schritt: max(var(--i) - (var(--n) - 1) / 2, (var(--n) - 1) / 2 - var(--i)); }
.raster[data-staffel="deckel"] > li { --schritt: min(var(--i), var(--deckel)); }
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.raster[data-staffel="linear"] > li { --schritt: calc(sibling-index() - 1); }
.raster[data-staffel="mitte"] > li { --schritt: max(sibling-index() - (sibling-count() + 1) / 2, (sibling-count() + 1) / 2 - sibling-index()); }
.raster[data-staffel="deckel"] > li { --schritt: min(sibling-index() - 1, var(--deckel)); }
}
@keyframes einrutschen { from { translate: 0 8px; } }
@keyframes einblenden { from { opacity: 0; } }
/* Schließen: umgekehrte Reihenfolge, kurz, höchstens 5 Schritte */
.raster.geht > li {
animation: ausblenden 250ms var(--aus) both;
animation-delay: calc(min(var(--n) - 1 - var(--i), 5) * 30ms);
}
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.raster.geht > li { animation-delay: calc(min(sibling-count() - sibling-index(), 5) * 30ms); }
}
@keyframes ausblenden { to { opacity: 0; translate: 0 8px; } }
/* Demo-Steuerung */
.steuerung { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 32px; padding: 18px 0 20px; margin-top: clamp(20px, 3vh, 36px); border-top: 1px solid var(--linie); color: var(--meta); }
.steuerung .gruppe { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.varianten { display: flex; gap: 4px; flex-wrap: wrap; }
.varianten button { height: 26px; padding: 0 10px; border-radius: 999px; color: var(--meta); box-shadow: inset 0 0 0 1px var(--linie); transition: background-color var(--ui) var(--aus), color var(--ui) var(--aus); }
.varianten button:hover { color: var(--text); }
.varianten button[aria-pressed="true"] { background: var(--flaeche); color: var(--text); box-shadow: none; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 22px; }
.werte div { display: flex; gap: 8px; }
.werte dt { color: var(--meta); }
.werte dd { color: var(--text); text-transform: none; letter-spacing: .02em; }
/* Vorführung: gedachter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 18px; height: 18px; pointer-events: none; display: none; transition: transform 700ms var(--aus); }
.vorfuehrung .zeiger { display: block; }
.zeiger.klick path { transform: scale(.86); transform-origin: 3px 2px; }
.zeiger path { transition: transform 150ms var(--aus); }
/* Vorschau 960 × 720: nur Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .einstieg p, .vorschau .sprache, .vorschau .steuerung .varianten, .vorschau .werte div:nth-child(n+3) { display: none; }
.vorschau .einstieg { grid-template-columns: 1fr; }
.vorschau .steuerung { position: absolute; right: 0; top: calc(clamp(20px, 4.5vh, 56px) + 34px); margin: 0; padding: 0; border: 0; }
.vorschau .werte div:nth-child(n+2), .vorschau .steuerung .gruppe, .vorschau .stand { display: none; }
.vorschau .karte .titel { font-size: 14px; }
.vorschau .werk { position: relative; padding-top: 52px; }
.vorschau .raster { row-gap: 40px; }
.vorschau .steuerung { top: 86px; }
/* Telefon */
@media (max-width: 1100px) { .raster { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.vorschau .raster { grid-template-columns: repeat(5, minmax(0, 1fr)); }
@media (max-width: 760px) {
.kopf { grid-template-columns: 1fr auto; }
.kopf nav, .sprache { display: none; }
.menue { display: block; }
.einstieg { grid-template-columns: 1fr; }
.raster { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px 12px; }
.karte .titel { font-size: 14px; }
.karte .info { font-size: 12px; }
.karte .stand { display: none; }
.filter button { height: 32px; padding: 0 12px; font-size: 13px; }
}
@media (hover: none) {
.karte a:hover .modell svg { scale: 1; }
.karte a:active .modell svg { scale: .985; transition-duration: 150ms; }
.filter button:hover { box-shadow: inset 0 0 0 1px var(--tertiaer); }
.filter button[aria-pressed="true"]:hover { box-shadow: inset 0 0 0 1px var(--text); }
}
/* Reduzierte Bewegung: kein Verzug, kein Weg, nur kurzes Einblenden */
.reduziert .raster > li { animation: einblenden 150ms linear both; animation-delay: 0s; }
.reduziert .raster.geht > li { animation: none; }
.reduziert .modell svg, .reduziert .zeiger { transition: none; }
.reduziert .karte a:hover .modell svg { scale: 1; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<a class="marke" href="#">Hollenstein Marti <span>Architekten</span></a>
<nav aria-label="Hauptnavigation"><ul class="nav">
<li><a href="#">Büro</a></li>
<li><a href="#" aria-current="page">Projekte</a></li>
<li><a href="#">Team</a></li>
<li><a href="#">Aktuelles</a></li>
<li><a href="#">Kontakt</a></li>
</ul></nav>
<p class="sprache"><b>DE</b> / EN</p>
<button type="button" class="menue">Menü</button>
</header>
<main class="werk">
<div class="einstieg">
<h1>Projekte<sup data-anzahl>10</sup></h1>
<p>Wohnungsbau, Schulen und Umbauten im Bestand. Seit 2011 planen wir von der Hohlstrasse aus Häuser, die <em>lange</em> halten. Gezeigt sind Modellfotos aus Wettbewerb und Ausführung.</p>
</div>
<div class="filter" role="group" aria-label="Nach Typologie filtern" data-filter>
<button type="button" data-f="alle" aria-pressed="true">Alle <small>10</small></button>
<button type="button" data-f="wohnen" aria-pressed="false">Wohnen <small>3</small></button>
<button type="button" data-f="bildung" aria-pressed="false">Bildung <small>3</small></button>
<button type="button" data-f="kultur" aria-pressed="false">Kultur <small>2</small></button>
<button type="button" data-f="umbau" aria-pressed="false">Umbau <small>2</small></button>
</div>
<ul class="raster" data-raster data-staffel="linear" aria-label="Projekte"></ul>
<p class="weg" aria-live="polite" data-status></p>
<div class="steuerung mono">
<div class="gruppe">
<span>Staffelung</span>
<div class="varianten" role="group" aria-label="Variante der Staffelung">
<button type="button" data-v="linear" aria-pressed="true">Linear</button>
<button type="button" data-v="mitte" aria-pressed="false">Aus der Mitte</button>
<button type="button" data-v="deckel" aria-pressed="false">Gedeckelt</button>
</div>
</div>
<dl class="werte">
<div><dt>Verzug</dt><dd data-formel>(sibling-index() − 1) × 150 ms</dd></div>
<div><dt>Ein</dt><dd>8 px / 700 ms · Deckkraft 900 ms</dd></div>
<div><dt>Kurve</dt><dd>cubic-bezier(0.1, 0.9, 0.3, 1)</dd></div>
<div><dt>Aus</dt><dd>250 ms, rückwärts 30 ms</dd></div>
<div><dt>Quelle</dt><dd data-quelle>sibling-index() nativ</dd></div>
</dl>
</div>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#231f20" stroke="#ffffff" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const raster = document.querySelector('[data-raster]');
const filterKnoepfe = [...document.querySelectorAll('[data-f]')];
const variantenKnoepfe = [...document.querySelectorAll('[data-v]')];
const anzahl = document.querySelector('[data-anzahl]');
const status = document.querySelector('[data-status]');
const formel = document.querySelector('[data-formel]');
const zeiger = document.querySelector('[data-zeiger]');
const NATIV = CSS.supports('animation-delay', 'calc(sibling-index() * 1ms)');
document.querySelector('[data-quelle]').textContent = NATIV ? 'sibling-index() nativ' : 'JS-Fallback --i / --n';
/* ---------- Modellfotos: isometrische Kartonmodelle als SVG ---------- */
const MAT = {
karton: { o: '#f8f7f4', l: '#e7e5e0', r: '#c9c7c2', f: 'rgba(35,31,32,.20)' },
holz: { o: '#e6d3b3', l: '#d5bd97', r: '#b49b75', f: 'rgba(60,40,20,.26)' },
beton: { o: '#dcdddb', l: '#c6c8c6', r: '#a6a9a7', f: 'rgba(35,31,32,.24)' },
bestand: { o: '#a9acab', l: '#929594', r: '#777a79', f: 'rgba(20,20,20,.30)' },
platte: { o: '#efefed', l: '#d9dad8', r: '#c3c4c2' },
};
const C = Math.cos(Math.PI / 6), S = 0.5, SOCKEL = 0.8;
function bau(p) {
const pts = [], teile = [], schatten = [];
const P = (x, y, z) => { const q = [(x - y) * C, (x + y) * S - z]; pts.push(q); return q; };
const pfad = (arr, fill, extra = '') => `<path d="M${arr.map(([x, y, z]) => P(x, y, z).map(n => n.toFixed(2)).join(' ')).join('L')}Z" fill="${fill}"${extra}/>`;
const kante = ' stroke="rgba(35,31,32,.10)" stroke-width=".5" vector-effect="non-scaling-stroke" stroke-linejoin="round"';
// Fenster auf einer Fassade: Punkt-Funktion fp(u, v)
function fenster(fp, L, H, art, farbe) {
let s = '';
const geschosse = Math.floor(H / 3);
for (let g = 0; g < geschosse; g++) {
const v0 = g * 3 + 0.95, v1 = v0 + (art === 'band' ? 1.25 : 1.45);
if (v1 > H - .3) break;
if (art === 'band') s += pfad([fp(.5, v0), fp(L - .5, v0), fp(L - .5, v1), fp(.5, v1)], farbe);
else if (art === 'loch') for (let u = 1; u + 1 <= L - .6; u += 2.3) s += pfad([fp(u, v0), fp(u + 1, v0), fp(u + 1, v1), fp(u, v1)], farbe);
else if (art === 'schlitz') { if (g % 2) continue; for (let u = 1.2; u + .35 <= L - 1; u += 3.6) s += pfad([fp(u, v0), fp(u + .35, v0), fp(u + .35, v1 + 1.4), fp(u, v1 + 1.4)], farbe); }
}
return s;
}
function kiste(b) {
const m = MAT[b.m || p.m], z0 = (b.z || 0) + SOCKEL, z1 = z0 + b.h, { x, y, w, d } = b, r = b.dach || 0;
if (!b.z) schatten.push(b);
let s = '';
if (r && b.achse === 'y') {
s += pfad([[x, y, z1], [x, y + d, z1], [x + w / 2, y + d, z1 + r], [x + w / 2, y, z1 + r]], m.o, kante);
s += pfad([[x + w, y, z0], [x + w, y + d, z0], [x + w, y + d, z1], [x + w, y, z1]], m.r, kante);
s += pfad([[x, y + d, z0], [x + w, y + d, z0], [x + w, y + d, z1], [x + w / 2, y + d, z1 + r], [x, y + d, z1]], m.l, kante);
s += pfad([[x + w, y, z1], [x + w, y + d, z1], [x + w / 2, y + d, z1 + r], [x + w / 2, y, z1 + r]], mix(m.o, m.r), kante);
} else if (r) {
s += pfad([[x, y, z1], [x + w, y, z1], [x + w, y + d / 2, z1 + r], [x, y + d / 2, z1 + r]], mix(m.o, m.l), kante);
s += pfad([[x, y + d, z0], [x + w, y + d, z0], [x + w, y + d, z1], [x, y + d, z1]], m.l, kante);
s += pfad([[x + w, y, z0], [x + w, y + d, z0], [x + w, y + d, z1], [x + w, y + d / 2, z1 + r], [x + w, y, z1]], m.r, kante);
s += pfad([[x, y + d, z1], [x + w, y + d, z1], [x + w, y + d / 2, z1 + r], [x, y + d / 2, z1 + r]], m.o, kante);
} else {
s += pfad([[x, y, z1], [x + w, y, z1], [x + w, y + d, z1], [x, y + d, z1]], m.o, kante);
s += pfad([[x, y + d, z0], [x + w, y + d, z0], [x + w, y + d, z1], [x, y + d, z1]], m.l, kante);
s += pfad([[x + w, y, z0], [x + w, y + d, z0], [x + w, y + d, z1], [x + w, y, z1]], m.r, kante);
}
if (b.f) {
s += fenster((u, v) => [x + u, y + d, z0 + v], w, b.h, b.f, m.f);
s += fenster((u, v) => [x + w, y + d - u, z0 + v], d, b.h, b.f, m.f.replace(/[\d.]+\)$/, a => (parseFloat(a) * 1.25).toFixed(2) + ')'));
}
teile.push(s);
}
function baum([x, y, r]) {
const fuss = P(x, y, SOCKEL), krone = P(x, y, SOCKEL + 1.6 + r);
schatten.push({ baum: [x, y, r] });
teile.push(`<line x1="${fuss[0].toFixed(2)}" y1="${fuss[1].toFixed(2)}" x2="${krone[0].toFixed(2)}" y2="${krone[1].toFixed(2)}" stroke="#9a9d9b" stroke-width=".35"/>` +
`<circle cx="${krone[0].toFixed(2)}" cy="${krone[1].toFixed(2)}" r="${r.toFixed(2)}" fill="url(#k-${p.id})"/>`);
}
// Modellplatte
const [W, D] = p.platte;
const pl = MAT.platte;
const platte = pfad([[-2, -2, SOCKEL], [W + 2, -2, SOCKEL], [W + 2, D + 2, SOCKEL], [-2, D + 2, SOCKEL]], pl.o) +
pfad([[-2, D + 2, 0], [W + 2, D + 2, 0], [W + 2, D + 2, SOCKEL], [-2, D + 2, SOCKEL]], pl.l) +
pfad([[W + 2, -2, 0], [W + 2, D + 2, 0], [W + 2, D + 2, SOCKEL], [W + 2, -2, SOCKEL]], pl.r);
const unter = [P(-2, -2, 0), P(W + 2, -2, 0), P(W + 2, D + 2, 0), P(-2, D + 2, 0)];
p.teile.forEach(t => t.baum ? baum(t.baum) : kiste(t));
// Schatten: Grundriss und um die Höhe nach +x verschobener Grundriss, verbunden
const sch = schatten.map(b => {
if (b.baum) { const [x, y, r] = b.baum, q = P(x + 1.6, y - .2, SOCKEL); return `<ellipse cx="${q[0].toFixed(2)}" cy="${q[1].toFixed(2)}" rx="${(r * 1.5).toFixed(2)}" ry="${(r * .7).toFixed(2)}"/>`; }
const k = b.h * .55 + (b.dach || 0) * .4, { x, y, w, d } = b, z = SOCKEL;
const A = [x, y], B2 = [x + w, y], Cc = [x + w, y + d], Dd = [x, y + d];
const vs = q => [q[0] + k, q[1] - k * .18];
const f = (arr) => pfad(arr.map(q => [q[0], q[1], z]), '#000');
return f([A, B2, Cc, Dd]) + f([vs(A), vs(B2), vs(Cc), vs(Dd)]) + f([A, vs(A), vs(Dd), Dd]) + f([B2, vs(B2), vs(Cc), Cc]) + f([A, B2, vs(B2), vs(A)]) + f([Dd, Cc, vs(Cc), vs(Dd)]);
}).join('');
// Bildausschnitt 4:3 um alle Punkte
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
pts.forEach(([a, b]) => { x0 = Math.min(x0, a); x1 = Math.max(x1, a); y0 = Math.min(y0, b); y1 = Math.max(y1, b); });
let bw = x1 - x0, bh = y1 - y0;
const rand = 1.26;
let vw = Math.max(bw, bh * 4 / 3) * rand, vh = vw * 3 / 4;
const vx = x0 + bw / 2 - vw / 2, vy = y0 + bh / 2 - vh / 2 + vh * .03;
const ux = unter.map(q => q.map(n => n.toFixed(2)).join(' ')).join(' ');
return `<svg viewBox="${vx.toFixed(2)} ${vy.toFixed(2)} ${vw.toFixed(2)} ${vh.toFixed(2)}" aria-hidden="true" focusable="false">
<defs>
<filter id="w-${p.id}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="${(vw / 160).toFixed(2)}"/></filter>
<filter id="g-${p.id}" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="${(vw / 45).toFixed(2)}"/></filter>
<radialGradient id="k-${p.id}" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#e2e5e1"/><stop offset="1" stop-color="#b3b8b3"/></radialGradient>
</defs>
<polygon points="${ux}" fill="#6d706f" opacity=".32" filter="url(#g-${p.id})" transform="translate(${(vw * .02).toFixed(2)} ${(vw * .025).toFixed(2)})"/>
${platte}
<g fill="#3a3c3b" opacity=".2" filter="url(#w-${p.id})">${sch}</g>
${teile.join('')}
</svg>`;
}
function mix(a, b) {
const h = s => [1, 3, 5].map(i => parseInt(s.substr(i, 2), 16));
return '#' + h(a).map((v, i) => Math.round((v + h(b)[i]) / 2).toString(16).padStart(2, '0')).join('');
}
/* ---------- Werkverzeichnis ---------- */
const PROJEKTE = [
{ id: 'hardau', titel: 'Wohnsiedlung Hardau-Ost', ort: 'Zürich', jahr: 2025, stand: 'im Bau', typ: 'wohnen', m: 'karton', platte: [30, 22],
teile: [{ x: 1, y: 1, w: 24, d: 5, h: 15, f: 'band' }, { x: 1, y: 10, w: 9, d: 9, h: 9, f: 'loch' }, { baum: [12.5, 13, 1.5] }, { x: 15, y: 9, w: 12, d: 9, h: 12, f: 'loch' }, { baum: [5, 21.5, 1.3] }] },
{ id: 'leutschenbach', titel: 'Schulhaus Leutschenbach-Nord', ort: 'Zürich', jahr: 2024, stand: 'Wettbewerb, 1. Rang', typ: 'bildung', m: 'holz', platte: [28, 22],
teile: [{ x: 0, y: 0, w: 27, d: 6, h: 9, f: 'band' }, { x: 0, y: 6, w: 6, d: 14, h: 9, f: 'band' }, { baum: [9, 16, 1.4] }, { x: 13, y: 10, w: 14, d: 9, h: 6 }] },
{ id: 'rhein', titel: 'Kulturhaus am Rhein', ort: 'Schaffhausen', jahr: 2023, stand: 'fertiggestellt', typ: 'kultur', m: 'beton', platte: [24, 18],
teile: [{ x: 12, y: -1, w: 6, d: 4, h: 20, f: 'schlitz' }, { x: 2, y: 3, w: 17, d: 13, h: 9, f: 'schlitz' }, { baum: [22, 14, 1.4] }] },
{ id: 'spinnerei', titel: 'Umbau Spinnerei Wettingen', ort: 'Wettingen', jahr: 2024, stand: 'fertiggestellt', typ: 'umbau', m: 'karton', platte: [30, 18],
teile: [{ x: 0, y: 3, w: 28, d: 9, h: 9, f: 'loch', m: 'bestand' },
{ x: 1, y: 3, w: 4.6, d: 9, h: 0, z: 9, dach: 2.4, achse: 'y' }, { x: 6.4, y: 3, w: 4.6, d: 9, h: 0, z: 9, dach: 2.4, achse: 'y' }, { x: 11.8, y: 3, w: 4.6, d: 9, h: 0, z: 9, dach: 2.4, achse: 'y' }, { x: 17.2, y: 3, w: 4.6, d: 9, h: 0, z: 9, dach: 2.4, achse: 'y' }, { x: 22.6, y: 3, w: 4.6, d: 9, h: 0, z: 9, dach: 2.4, achse: 'y' },
{ x: 18, y: 13, w: 10, d: 4, h: 6, f: 'band' }] },
{ id: 'binz', titel: 'Wohnhof Binzmühle', ort: 'Zürich-Oerlikon', jahr: 2026, stand: 'Projekt', typ: 'wohnen', m: 'karton', platte: [26, 26],
teile: [{ x: 0, y: 0, w: 24, d: 5, h: 15, f: 'band' }, { x: 0, y: 5, w: 5, d: 14, h: 15, f: 'band' }, { baum: [12, 12, 1.6] }, { x: 19, y: 5, w: 5, d: 14, h: 12, f: 'band' }, { x: 0, y: 19, w: 24, d: 5, h: 12, f: 'band' }] },
{ id: 'rebhalde', titel: 'Kindergarten Rebhalde', ort: 'Winterthur', jahr: 2022, stand: 'fertiggestellt', typ: 'bildung', m: 'holz', platte: [24, 18],
teile: [{ x: 2, y: 2, w: 9, d: 6, h: 3.6, dach: 3, f: 'loch' }, { baum: [18, 3, 1.5] }, { x: 11, y: 9, w: 9, d: 6, h: 3.6, dach: 3, f: 'loch' }, { baum: [4, 13, 1.3] }, { baum: [21, 15, 1.2] }] },
{ id: 'zug', titel: 'Bibliothek im Park', ort: 'Zug', jahr: 2025, stand: 'Wettbewerb, 2. Rang', typ: 'kultur', m: 'beton', platte: [24, 20],
teile: [{ x: 1, y: 1, w: 21, d: 16, h: 5, f: 'band' }, { x: 4, y: 3.5, w: 15, d: 11, z: 5, h: 5, f: 'band' }, { x: 6.5, y: 5.5, w: 10, d: 7, z: 10, h: 4, f: 'band' }] },
{ id: 'gleis', titel: 'Aufstockung Lagerhaus Gleis 4', ort: 'Basel', jahr: 2023, stand: 'fertiggestellt', typ: 'umbau', m: 'karton', platte: [26, 16],
teile: [{ x: 1, y: 2, w: 22, d: 11, h: 12, f: 'loch', m: 'bestand' }, { x: 2, y: 3, w: 20, d: 9, z: 12, h: 4, f: 'band' }] },
{ id: 'seefeld', titel: 'Atelierhaus Seefeld', ort: 'Zürich', jahr: 2021, stand: 'fertiggestellt', typ: 'wohnen', m: 'karton', platte: [22, 14],
teile: [{ x: 3, y: 2, w: 8, d: 9, h: 15, dach: 4, f: 'loch' }, { x: 11, y: 2, w: 8, d: 9, h: 12, f: 'loch', m: 'bestand' }, { baum: [20, 12, 1.2] }] },
{ id: 'allmend', titel: 'Dreifachhalle Allmend', ort: 'Luzern', jahr: 2026, stand: 'im Bau', typ: 'bildung', m: 'holz', platte: [28, 18],
teile: [{ x: 0, y: 1, w: 26, d: 15, h: 8, f: 'band' },
{ x: 0, y: 1, w: 26, d: 3.75, z: 8, h: 0, dach: 1.6 }, { x: 0, y: 4.75, w: 26, d: 3.75, z: 8, h: 0, dach: 1.6 }, { x: 0, y: 8.5, w: 26, d: 3.75, z: 8, h: 0, dach: 1.6 }, { x: 0, y: 12.25, w: 26, d: 3.75, z: 8, h: 0, dach: 1.6 }] },
];
const BILD = Object.fromEntries(PROJEKTE.map(p => [p.id, bau(p)]));
const TYP = { wohnen: 'Wohnen', bildung: 'Bildung', kultur: 'Kultur', umbau: 'Umbau' };
let filter = 'alle', staffel = 'linear', wechsel = null, wartend = null;
const FORMEL = {
linear: '(sibling-index() − 1) × 150 ms',
mitte: '|sibling-index() − (sibling-count() + 1) / 2| × 150 ms',
deckel: 'min(sibling-index() − 1, 5) × 150 ms',
};
function karte(p, i, n) {
const li = document.createElement('li');
li.className = 'karte';
li.style.setProperty('--i', i); // Fallback für Browser ohne sibling-index()
li.style.setProperty('--n', n);
li.innerHTML = `<a href="#${p.id}" aria-label="${p.titel}, ${p.ort} ${p.jahr}, ${p.stand}"><figure><div class="modell">${BILD[p.id]}<span class="verzug" aria-hidden="true"></span></div>` +
`<figcaption><span class="titel">${p.titel}</span><span class="info">${p.ort} • ${p.jahr}<span class="stand"> • ${p.stand}</span></span></figcaption></figure></a>`;
return li;
}
function verzugZeigen() {
[...raster.children].forEach(li => {
const t = parseFloat(getComputedStyle(li).animationDelay) || 0;
li.querySelector('.verzug').textContent = '+' + Math.round(t * 1000) + ' ms';
});
}
function einsetzen() {
const liste = PROJEKTE.filter(p => filter === 'alle' || p.typ === filter);
raster.classList.remove('geht');
raster.replaceChildren(...liste.map((p, i) => karte(p, i, liste.length)));
anzahl.textContent = liste.length;
formel.textContent = reduziert() ? 'kein Verzug, Deckkraft 150 ms' : FORMEL[staffel];
status.textContent = `${liste.length} Projekte${filter === 'alle' ? '' : ' in ' + TYP[filter]}`;
verzugZeigen();
}
function zeigeFilter(f, sofort) {
filter = f;
filterKnoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.f === f));
clearTimeout(wechsel);
const n = raster.children.length;
if (!n || sofort || reduziert()) return einsetzen();
raster.classList.add('geht');
wechsel = setTimeout(einsetzen, 250 + Math.min(n - 1, 5) * 30 + 30);
}
function setzeStaffel(v) {
staffel = v;
raster.dataset.staffel = v;
variantenKnoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.v === v));
formel.textContent = reduziert() ? 'kein Verzug, Deckkraft 150 ms' : FORMEL[v];
}
filterKnoepfe.forEach(b => b.addEventListener('click', () => { if (b.dataset.f !== filter || !raster.classList.contains('geht')) zeigeFilter(b.dataset.f); }));
variantenKnoepfe.forEach(b => b.addEventListener('click', () => { setzeStaffel(b.dataset.v); zeigeFilter(filter, true); }));
raster.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });
/* ---------- Vorführung: gedachter Zeiger klickt Filter und Varianten ---------- */
const ABLAUF = [
['alle', 'linear'], ['wohnen', 'linear'], ['alle', 'mitte'], ['bildung', 'mitte'], ['alle', 'deckel'], ['umbau', 'deckel'],
];
let schritt = 0, uhr = [];
const spaeter = (fn, ms) => uhr.push(setTimeout(fn, ms));
function zeigerAuf(el) {
const r = el.getBoundingClientRect();
zeiger.style.transform = `translate(${r.left + r.width * .55}px, ${r.top + r.height * .6}px)`;
}
function tick() {
const [f, v] = ABLAUF[schritt % ABLAUF.length];
const knopf = filterKnoepfe.find(b => b.dataset.f === f);
zeigerAuf(knopf);
spaeter(() => {
zeiger.classList.add('klick'); knopf.classList.add('drueck');
if (v !== staffel) setzeStaffel(v);
zeigeFilter(f, f === filter);
spaeter(() => { zeiger.classList.remove('klick'); knopf.classList.remove('drueck'); }, 160);
}, 750);
schritt++;
spaeter(tick, f === 'alle' ? 3600 : 2800);
}
function pause() { uhr.forEach(clearTimeout); uhr = []; }
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
schritt = 0;
zeiger.style.transition = 'none';
zeiger.style.transform = `translate(${innerWidth * .62}px, ${innerHeight * .9}px)`;
zeiger.getBoundingClientRect();
zeiger.style.transition = '';
tick();
}
function neustart() {
pause();
html.classList.remove('vorfuehrung');
setzeStaffel(staffel);
zeigeFilter('alle', true);
}
setzeStaffel('linear');
zeigeFilter('alle', true);
})();
</script>
</body>
</html>