Elastisches Raster (Spalten mit Scroll-Verzögerung)
Ein Bildarchiv in Spalten, die beim Scrollen verschieden lange nachziehen: außen träger, innen schneller. Beim Stoppen holen sie weich auf.
Ein mehrspaltiges Bildraster (Projekte, Fotos) wird in Spalten-Wrapper gruppiert. Beim Scrollen folgt jede Spalte mit eigener Verzögerung (Lag): die mittleren Spalten reagieren schneller, die äußeren hängen nach. Hört der Scroll auf, holen die Spalten weich auf. So wirkt das Raster elastisch und schwer. Variante: Die Spalten laufen gegenläufig, abwechselnd hoch und runter.
Eine einfache Galerie bekommt Gewicht und wirkt hochwertig, weil die Spalten sich wie Körper mit Masse verhalten. Das Layout selbst bleibt unverändert, der Effekt liegt nur in der Bewegung.
Wann einsetzen
Projektraster, Fotogalerien und Archivseiten mit Smooth Scrolling.
Wann nicht
Ohne Smooth-Scroll-Setup, auf Seiten mit viel Text und wenn die Leistung knapp ist.
Varianten
- Lag nach Abstand zur Mitte
- Gegenläufige Spalten
- Lag nur beim Einscrollen der Sektion
Umsetzung
GSAP ScrollSmoother mit smooth: 1, effects: true und normalizeScroll: true. Die Spaltenzahl aus getComputedStyle(grid).gridTemplateColumns lesen und die Kacheln in .grid__column-Wrapper verteilen, damit weniger Elemente animiert werden. Pro Spalte smoother.effects(col, { speed: 1, lag }) mit lag = baseLag + |i − mid| × lagScale und mid = (n −
- /
- Bei Resize neu aufbauen. Ohne ScrollSmoother: Lenis plus eigene Lerp pro Spalte, wie in dieser Demo.
Robust umsetzen
Telefon. Lag stark verkürzen oder abschalten, sonst fühlt sich der Touch-Scroll schwammig an.
Ohne Bewegung. Kein Lag, normales Scrollen.
Barrierefrei. Durch die Verteilung in Spalten liest ein Screenreader spaltenweise statt zeilenweise. Ist die Reihenfolge wichtig, die Originalreihenfolge im DOM behalten und nur visuell verteilen.
Code
<!DOCTYPE html>
<!--
Elastisches Raster (Spalten mit Scroll-Verzögerung)
Fundus · Hero & Section-Layouts · https://cecodesign.de/lab/fundus/hero-layouts/elastisches-raster-lag/
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>Elastisches Raster (Spalten mit Scroll-Verzögerung) · 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 Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; 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; }
@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: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
/* Rezept „Bild zuerst“: neutrale Fläche, ein Textton, Mono für Zähler und Bildunterschrift */
--grund: #f4f4f2; --text: #141414; --platzhalter: #e6e6e6; --leise: #6e6e6e; --linie: rgba(20, 20, 20, .12);
--kurve: cubic-bezier(0.455, 0.03, 0.515, 0.955); /* easeInOutQuad, Bildwechsel */
--rand: clamp(16px, 3.4vw, 48px);
--luecke: clamp(12px, 2vw, 28px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 15px/1.5 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
body { overflow-x: clip; }
.mono { font: 400 12px/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; color: var(--leise); }
a { color: inherit; text-decoration: none; }
a:focus-visible, button:focus-visible { outline: 1.5px solid var(--text); outline-offset: 3px; }
/* Kopf: Name links, Navigation rechts, alles klein */
.kopf { position: relative; z-index: 2; display: flex; justify-content: space-between; align-items: baseline; gap: 20px; padding: calc(var(--rand) * .6) var(--rand) 0; }
.marke { font: 400 1.5rem/1.1 'Instrument Serif', serif; letter-spacing: -.01em; }
.nav { display: flex; gap: clamp(14px, 2vw, 28px); font-size: 14px; }
.nav a { color: var(--leise); transition: color .2s var(--kurve); }
.nav a[aria-current], .nav a:hover { color: var(--text); }
.einleitung { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--luecke); padding: clamp(28px, 6vh, 72px) var(--rand) clamp(22px, 4vh, 44px); align-items: end; }
.einleitung h1 { grid-column: 1 / 3; font: 400 clamp(1.5rem, 3vw, 2.75rem)/1.1 'Instrument Serif', serif; letter-spacing: -.01em; }
.einleitung h1 em { color: var(--leise); }
.einleitung p { grid-column: 3; max-width: 34ch; font-size: 14px; color: #3a3a3a; }
.filter { grid-column: 4; display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; }
.filter span[aria-current] { color: var(--text); }
/* Das Raster. Ohne JS: normales 4-Spalten-Grid. Mit JS: Kacheln wandern in Spalten-Wrapper. */
.raster { --spalten: 4; display: grid; grid-template-columns: repeat(var(--spalten), minmax(0, 1fr)); gap: var(--luecke); padding: 0 var(--rand) clamp(80px, 16vh, 200px); }
.spalte { display: flex; flex-direction: column; gap: calc(var(--luecke) * 1.6); will-change: transform; }
.spalte:nth-child(even) { padding-top: clamp(40px, 9vh, 110px); }
.kachel figure { display: grid; gap: 8px; }
.kachel .bild { position: relative; aspect-ratio: var(--f, 4 / 5); background: var(--platzhalter); overflow: hidden; }
.kachel canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .6s var(--kurve); }
.kachel canvas.da { opacity: 1; }
.kachel figcaption { display: flex; justify-content: space-between; gap: 10px; }
.kachel figcaption b { font-weight: 400; color: var(--text); }
.kachel .titel { font: 400 1.125rem/1.15 'Instrument Serif', serif; opacity: 0; transition: opacity .35s var(--kurve); position: absolute; left: 10px; bottom: 9px; color: #fff; text-shadow: 0 1px 12px rgba(0,0,0,.35); pointer-events: none; }
@media (hover: hover) { .kachel:hover .titel { opacity: 1; } }
/* Zähler und Bildunterschrift der Mitte, fest unten rechts */
.zaehler { position: fixed; right: var(--rand); bottom: calc(var(--rand) * .5); z-index: 4; display: flex; gap: 14px; align-items: baseline; padding: 7px 10px; background: rgba(244, 244, 242, .92); pointer-events: none; }
.zaehler .mono { color: var(--leise); }
.zaehler [data-nr] { color: var(--text); }
.zaehler [data-ort] { transition: opacity .35s var(--kurve); }
.fuss { position: relative; z-index: 2; display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--luecke); padding: 40px var(--rand) 56px; border-top: 1px solid var(--linie); font-size: 14px; }
.fuss .marke { font-size: 1.25rem; }
.fuss p { color: #3a3a3a; }
/* Werte-Pult der Demo */
.pult { position: fixed; left: var(--rand); bottom: calc(var(--rand) * .5); z-index: 5; display: grid; gap: 5px; max-width: min(470px, calc(100vw - 2 * var(--rand))); padding: 8px 10px; font-size: 11px; background: rgba(244, 244, 242, .9); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border: 1px solid var(--linie); }
.pult__modi { display: flex; flex-wrap: wrap; gap: 4px; }
.pult button { font: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); height: 26px; padding: 0 9px; cursor: pointer; transition: color .2s var(--kurve), background-color .2s var(--kurve); }
.pult button[aria-pressed='true'] { color: var(--grund); background: var(--text); border-color: var(--text); }
.pult p { color: #555; }
.pult [data-live] { color: var(--text); }
.vorschau body { overflow: hidden; }
.vorschau .einleitung { padding-top: 22px; padding-bottom: 20px; }
.vorschau .einleitung p { display: none; }
@media (max-width: 1100px) {
.raster { --spalten: 3; }
.einleitung { grid-template-columns: repeat(3, 1fr); }
.einleitung h1 { grid-column: 1 / 3; }
.einleitung p { grid-column: 1 / 3; grid-row: 2; }
.filter { grid-column: 3; grid-row: 1 / 3; align-self: end; }
.fuss { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
.raster { --spalten: 2; }
.einleitung { grid-template-columns: 1fr; gap: 14px; }
.einleitung h1, .einleitung p, .filter { grid-column: 1; grid-row: auto; }
.filter { justify-content: flex-start; }
.nav { gap: 12px; font-size: 13px; }
.nav a:not([aria-current]):not(:last-child) { display: none; }
.fuss { grid-template-columns: 1fr; gap: 14px; }
.zaehler { right: auto; left: var(--rand); }
.zaehler [data-ort] { display: none; }
.kachel figcaption { font-size: 11px; }
.kachel figcaption span:last-child { display: none; }
}
/* Touch: kein Hover-Titel, der Projekttitel steht in der Bildunterschrift */
@media (hover: none) {
.kachel .titel { display: none; }
}
:root.reduziert .spalte { transform: none !important; will-change: auto; }
:root.reduziert .kachel canvas { transition: opacity .2s linear; }
@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="#">Noemi Thommen</a>
<nav class="nav" aria-label="Hauptnavigation">
<a href="#">Auswahl</a><a href="#" aria-current="page">Archiv</a><a href="#">Info</a><a href="#">Kontakt</a>
</nav>
</header>
<main>
<section class="einleitung" aria-labelledby="titel">
<h1 id="titel">Archiv <em>2019–2026</em></h1>
<p>Reportage, Architektur und Auftragsarbeiten für Verlage und Kulturhäuser. Fast alles am Rhein, fast alles vor sieben Uhr morgens.</p>
<div class="filter mono" aria-label="Filter">
<span aria-current="true">Alle 24</span><span>Rhein 9</span><span>Stadt 8</span><span>Auftrag 7</span>
</div>
</section>
<div class="raster" data-raster role="list" aria-label="Serien im Archiv"></div>
</main>
<footer class="fuss">
<p class="marke">Noemi Thommen</p>
<p>Fotografie<br>Reportage, Architektur, Porträt</p>
<p>Klybeckstrasse 141<br>4057 Basel</p>
<p><a href="#">atelier@noemithommen.ch</a><br>Impressum · Datenschutz</p>
</footer>
<div class="zaehler" aria-hidden="true">
<span class="mono" data-ort>Dreirosenbrücke, 05:48</span>
<span class="mono" data-nr>01 / 24</span>
</div>
<div class="pult mono">
<div class="pult__modi" role="group" aria-label="Variante">
<button type="button" data-variante="mitte" aria-pressed="true">Lag zur Mitte</button>
<button type="button" data-variante="gegen" aria-pressed="false">Gegenläufig</button>
<button type="button" data-variante="eintritt" aria-pressed="false">Nur beim Einscrollen</button>
</div>
<p data-werte>lag = 0,2 s + |i − Mitte| × 0,3 s</p>
<p>Versatz <span data-live>0 · 0 · 0 · 0 px</span></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.min.js"></script>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert') || new URLSearchParams(location.search).has('reduziert');
const touch = matchMedia('(hover: none)').matches;
/* ---------- Inhalte: 24 Serien, Bilder werden als „Fotos“ auf Canvas gemalt ---------- */
const SERIEN = [
['nebel', '4 / 5', 'Dreirosenbrücke, 05:48', 'Flussnebel', 2025],
['hafen', '3 / 2', 'Hafen Kleinhüningen', 'Silos am Abend', 2024],
['fassade', '4 / 5', 'St. Johann, Block 7', 'Wohnhaus Elsässerstrasse', 2023],
['schwimmer', '1 / 1', 'Rhein, Kleinbasler Ufer', 'Wickelfische', 2025],
['nacht', '2 / 3', 'Barfüsserplatz, 23:10', 'Letzte Acht', 2022],
['stein', '4 / 5', 'Münsterhügel', 'Roter Sandstein', 2021],
['wald', '3 / 4', 'Lange Erlen', 'Februar', 2026],
['innen', '4 / 5', 'Gundeli, Atelier', 'Morgenlicht', 2023],
['hafen', '4 / 5', 'Dreiländereck', 'Kräne, Westwind', 2022],
['nebel', '3 / 2', 'Wettsteinbrücke, 06:20', 'Pfeiler', 2024],
['schwimmer', '4 / 5', 'Rhein bei der Pfalz', 'August, 31 Grad', 2023],
['fassade', '1 / 1', 'Erlenmatt', 'Neubau, Rohbau', 2020],
['stein', '3 / 4', 'Kreuzgang', 'Schatten um zwölf', 2019],
['nacht', '4 / 5', 'Messeplatz', 'Regen, Tram', 2025],
['innen', '3 / 2', 'Druckerei Kleinbasel', 'Auftrag für einen Verlag', 2024],
['wald', '4 / 5', 'Birsufer', 'Pappeln', 2021],
['nebel', '2 / 3', 'St. Alban-Fähre', 'Fährseil', 2026],
['fassade', '3 / 4', 'Gellert', 'Hochhaus, Nordseite', 2022],
['hafen', '1 / 1', 'Rheinhafen, Becken 2', 'Containerkran', 2025],
['schwimmer', '3 / 2', 'Rheinbord', 'Sonntag', 2024],
['innen', '2 / 3', 'Kaserne', 'Probebühne', 2020],
['stein', '4 / 5', 'Spalentor', 'Nordwand', 2023],
['nacht', '3 / 2', 'Mittlere Brücke, 00:40', 'Spiegelung', 2021],
['wald', '1 / 1', 'Allschwiler Wald', 'Nebelgrenze', 2026],
];
/* ---------- Maler: weiche, filmische Szenen, deterministisch per Seed ---------- */
function zufall(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); }
const lin = (c, y0, y1, stops) => { const g = c.createLinearGradient(0, y0, 0, y1); stops.forEach(([o, f]) => g.addColorStop(o, f)); return g; };
function weich(c, px, fn) { c.save(); c.filter = 'blur(' + px.toFixed(1) + 'px)'; fn(); c.restore(); }
function streifen(c, w, y0, y1, r, n, farbe, a) {
for (let i = 0; i < n; i++) { const y = y0 + r() * (y1 - y0), l = w * (.05 + r() * .4), x = r() * w; c.globalAlpha = a * (.3 + r() * .7); c.fillStyle = farbe; c.fillRect(x, y, l, Math.max(1, w * .0025)); }
c.globalAlpha = 1;
}
const MALER = {
nebel(c, w, h, r) {
const hz = h * (.5 + r() * .08);
c.fillStyle = lin(c, 0, hz, [[0, '#bfc6c8'], [1, '#e7e5df']]); c.fillRect(0, 0, w, hz);
weich(c, w * .012, () => { c.fillStyle = 'rgba(92,100,102,.45)'; for (let x = -20; x < w; x += w * (.03 + r() * .05)) { const bh = h * (.015 + r() * .05); c.fillRect(x, hz - bh, w * (.03 + r() * .06), bh + 4); } });
// Brücke mit Bögen
weich(c, w * .004, () => {
c.strokeStyle = 'rgba(64,70,72,.55)'; c.fillStyle = 'rgba(64,70,72,.5)'; c.lineWidth = w * .012;
const y = hz - h * (.08 + r() * .05); c.fillRect(-10, y, w + 20, w * .016);
const bw = w * (.22 + r() * .1);
for (let x = (r() - 1) * bw; x < w; x += bw) { c.beginPath(); c.ellipse(x + bw / 2, hz, bw * .46, hz - y - w * .01, 0, Math.PI, 0); c.stroke(); c.fillRect(x + bw - w * .012, y, w * .024, hz - y); }
});
c.fillStyle = lin(c, hz, h, [[0, '#d9dbd7'], [1, '#8f9a9b']]); c.fillRect(0, hz, w, h - hz);
streifen(c, w, hz, h, r, 140, '#f2f1ec', .28); streifen(c, w, hz + 6, h, r, 60, '#55605f', .12);
weich(c, w * .05, () => { c.fillStyle = 'rgba(240,239,234,.55)'; c.fillRect(-w * .1, hz - h * .08, w * 1.2, h * .14); });
if (r() > .4) { c.fillStyle = 'rgba(30,34,36,.75)'; const bx = w * (.2 + r() * .6), by = hz + (h - hz) * (.3 + r() * .3); c.beginPath(); c.ellipse(bx, by, w * .04, w * .008, 0, 0, Math.PI * 2); c.fill(); }
},
hafen(c, w, h, r) {
const hz = h * (.62 + r() * .1);
c.fillStyle = lin(c, 0, hz, [[0, '#2e3b52'], [.55, '#a9706c'], [1, '#eab37c']]); c.fillRect(0, 0, w, hz);
weich(c, w * .06, () => { c.fillStyle = 'rgba(255,214,150,.85)'; c.beginPath(); c.arc(w * (.25 + r() * .5), hz - h * .04, w * .07, 0, Math.PI * 2); c.fill(); });
c.fillStyle = '#1b1d22';
// Silos
const sx = w * (r() * .4); for (let i = 0; i < 4; i++) { const sw = w * .07; c.beginPath(); c.roundRect(sx + i * sw * 1.05, hz - h * .2, sw, h * .2 + 2, [sw / 2, sw / 2, 0, 0]); c.fill(); }
// Kräne
for (let k = 0; k < 2 + (r() * 2 | 0); k++) {
const x = w * (.15 + r() * .75), th = h * (.28 + r() * .2), jl = w * (.25 + r() * .25), d = r() > .5 ? 1 : -1;
c.fillRect(x, hz - th, w * .012, th); c.fillRect(Math.min(x, x + d * jl), hz - th, jl, w * .008);
c.fillRect(x - d * w * .06, hz - th - w * .006, w * .06, w * .02);
c.strokeStyle = '#1b1d22'; c.lineWidth = 1; c.beginPath(); c.moveTo(x + w * .006, hz - th - h * .05); c.lineTo(x + d * jl, hz - th); c.moveTo(x + d * jl * .7, hz - th); c.lineTo(x + d * jl * .7, hz - th + h * (.08 + r() * .1)); c.stroke();
c.fillRect(x + w * .006, hz - th - h * .05, 2, h * .05);
}
c.fillRect(0, hz - 3, w, 4);
c.fillStyle = lin(c, hz, h, [[0, '#4a3d44'], [1, '#14161b']]); c.fillRect(0, hz, w, h - hz);
streifen(c, w, hz + 2, h, r, 90, '#f2b678', .35);
},
fassade(c, w, h, r) {
c.fillStyle = lin(c, 0, h, [[0, '#c3c0b8'], [1, '#9f9c95']]); c.fillRect(0, 0, w, h);
for (let i = 0; i < 900; i++) { c.fillStyle = r() > .5 ? 'rgba(255,255,255,.05)' : 'rgba(0,0,0,.05)'; c.fillRect(r() * w, r() * h, 1 + r() * 2, 1 + r() * 2); }
const sp = 3 + (r() * 3 | 0), ze = 5 + (r() * 4 | 0), mx = w * .08, my = h * .05;
const fw = (w - 2 * mx) / sp, fh = (h - 2 * my) / ze;
for (let z = 0; z < ze; z++) {
c.fillStyle = 'rgba(255,255,255,.18)'; c.fillRect(0, my + z * fh - 3, w, 3);
for (let s = 0; s < sp; s++) {
const x = mx + s * fw + fw * .16, y = my + z * fh + fh * .18, ww = fw * .68, hh = fh * .6, an = r() > .93;
c.fillStyle = an ? lin(c, y, y + hh, [[0, '#f0cf96'], [1, '#c98f4f']]) : lin(c, y, y + hh, [[0, '#3b4146'], [1, '#22262a']]);
c.fillRect(x, y, ww, hh);
if (!an) { c.fillStyle = 'rgba(190,205,215,.16)'; c.beginPath(); c.moveTo(x, y); c.lineTo(x + ww * .55, y); c.lineTo(x, y + hh * .7); c.fill(); }
c.fillStyle = 'rgba(0,0,0,.25)'; c.fillRect(x, y + hh, ww, 2);
}
}
weich(c, w * .02, () => { c.fillStyle = 'rgba(25,20,18,.22)'; c.beginPath(); c.moveTo(w * (.3 + r() * .4), 0); c.lineTo(w, 0); c.lineTo(w, h); c.lineTo(w * (.6 + r() * .3), h); c.fill(); });
},
schwimmer(c, w, h, r) {
c.fillStyle = lin(c, 0, h, [[0, '#4b8c8c'], [1, '#2a5f62']]); c.fillRect(0, 0, w, h);
c.lineWidth = Math.max(1, w * .004); c.lineCap = 'round';
for (let i = 0; i < 260; i++) {
const x = r() * w, y = r() * h, l = w * (.02 + r() * .06);
c.strokeStyle = r() > .3 ? 'rgba(220,240,235,' + (.04 + r() * .1) + ')' : 'rgba(10,40,45,.16)';
c.beginPath(); c.moveTo(x, y); c.quadraticCurveTo(x + l / 2, y - l * .18, x + l, y + l * .05); c.stroke();
}
const farben = ['#e8583a', '#f2c230', '#2f6fd6', '#f08bb0', '#f2f0ea', '#e8583a'];
const n = 3 + (r() * 5 | 0);
for (let i = 0; i < n; i++) {
const x = w * (.1 + r() * .8), y = h * (.1 + r() * .8), s = w * (.035 + r() * .025), a = -.5 + r() * .4;
c.save(); c.translate(x, y); c.rotate(a);
weich(c, s * .25, () => { c.fillStyle = 'rgba(8,30,32,.35)'; c.beginPath(); c.ellipse(s * .25, s * .3, s * 1.1, s * .6, 0, 0, Math.PI * 2); c.fill(); });
c.fillStyle = farben[i % farben.length]; c.beginPath(); c.ellipse(0, 0, s * 1.05, s * .55, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = 'rgba(255,255,255,.12)'; c.beginPath(); c.ellipse(-s * .3, -s * .2, s * .55, s * .12, 0, 0, Math.PI * 2); c.fill();
c.fillStyle = '#c99574'; c.beginPath(); c.arc(-s * 1.35, s * .05, s * .28, 0, Math.PI * 2); c.fill();
c.strokeStyle = 'rgba(230,245,240,.45)'; c.lineWidth = Math.max(1, s * .08); c.beginPath(); c.arc(-s * 1.35, s * .05, s * .5, .4, 2.6); c.stroke();
c.restore();
}
},
nacht(c, w, h, r) {
c.fillStyle = lin(c, 0, h, [[0, '#0e1320'], [.6, '#1a2233'], [1, '#0b0e15']]); c.fillRect(0, 0, w, h);
const hz = h * (.55 + r() * .1);
// Tram mit erleuchteten Fenstern
const ty = hz - h * .14, tx = w * (r() * .3 - .1), tl = w * (.7 + r() * .4);
c.fillStyle = '#21402e'; c.beginPath(); c.roundRect(tx, ty, tl, h * .15, w * .02); c.fill();
for (let x = tx + w * .03; x < tx + tl - w * .06; x += w * .085) { c.fillStyle = lin(c, ty, ty + h * .06, [[0, '#f6e2b0'], [1, '#c9a46a']]); c.fillRect(x, ty + h * .025, w * .065, h * .06); }
weich(c, w * .03, () => { c.fillStyle = 'rgba(246,226,176,.25)'; c.fillRect(tx, ty, tl, h * .1); });
const farben = ['95,208,138', '240,167,74', '245,240,230', '230,80,70'];
for (let i = 0; i < 26; i++) {
const x = r() * w, y = h * (.1 + r() * .5), rad = w * (.015 + r() * .06), f = farben[r() * farben.length | 0];
const g = c.createRadialGradient(x, y, 0, x, y, rad); g.addColorStop(0, 'rgba(' + f + ',.55)'); g.addColorStop(.75, 'rgba(' + f + ',.32)'); g.addColorStop(1, 'rgba(' + f + ',0)');
c.fillStyle = g; c.beginPath(); c.arc(x, y, rad, 0, Math.PI * 2); c.fill();
}
c.fillStyle = lin(c, hz, h, [[0, '#141a26'], [1, '#07090d']]); c.fillRect(0, hz, w, h - hz);
weich(c, w * .012, () => { for (let i = 0; i < 18; i++) { const x = r() * w, f = farben[r() * farben.length | 0]; c.fillStyle = 'rgba(' + f + ',' + (.12 + r() * .25) + ')'; c.fillRect(x, hz + 4, w * (.008 + r() * .02), (h - hz) * (.4 + r() * .6)); } });
c.fillStyle = 'rgba(200,210,220,.22)'; c.fillRect(0, hz + (h - hz) * .35, w, 1); c.fillRect(0, hz + (h - hz) * .42, w, 1);
},
stein(c, w, h, r) {
c.fillStyle = lin(c, 0, h, [[0, '#b0604c'], [1, '#86453a']]); c.fillRect(0, 0, w, h);
const lh = h * (.05 + r() * .03);
for (let y = 0, z = 0; y < h; y += lh, z++) {
c.fillStyle = 'rgba(60,20,15,.28)'; c.fillRect(0, y, w, Math.max(1, h * .003));
const bw = w * (.18 + r() * .1); for (let x = (z % 2) * bw / 2; x < w; x += bw) c.fillRect(x, y, Math.max(1, w * .003), lh);
c.fillStyle = 'rgba(255,200,180,' + (r() * .08) + ')'; c.fillRect(0, y + 2, w, lh - 3);
}
for (let i = 0; i < 1400; i++) { c.fillStyle = r() > .5 ? 'rgba(255,220,200,.08)' : 'rgba(50,15,10,.1)'; c.fillRect(r() * w, r() * h, 1 + r() * 2, 1 + r() * 2); }
if (r() > .35) { // gotisches Fenster
const x = w * (.3 + r() * .25), ww = w * .22, y = h * (.25 + r() * .2), hh = h * .45;
c.fillStyle = '#2a1512'; c.beginPath(); c.moveTo(x, y + hh); c.lineTo(x, y + ww * .6); c.quadraticCurveTo(x, y, x + ww / 2, y - ww * .2); c.quadraticCurveTo(x + ww, y, x + ww, y + ww * .6); c.lineTo(x + ww, y + hh); c.fill();
}
c.fillStyle = 'rgba(35,10,8,.42)'; c.beginPath(); const a = r(); c.moveTo(0, h * a * .6); c.lineTo(w * (.5 + r() * .5), h); c.lineTo(0, h); c.fill();
},
wald(c, w, h, r) {
c.fillStyle = lin(c, 0, h, [[0, '#e3e5de'], [.7, '#d2d6cb'], [1, '#b5b9a6']]); c.fillRect(0, 0, w, h);
for (let schicht = 0; schicht < 3; schicht++) {
const a = [.18, .38, .85][schicht], b = [w * .012, w * .005, 0][schicht], n = [22, 14, 6][schicht];
weich(c, b, () => { for (let i = 0; i < n; i++) { const x = r() * w, tw = w * (.006 + r() * .012) * (schicht + 1); c.fillStyle = 'rgba(48,52,44,' + a + ')'; c.fillRect(x, -10, tw, h * (.78 + schicht * .08 + r() * .1)); } });
weich(c, w * .04, () => { c.fillStyle = 'rgba(236,238,232,.5)'; c.fillRect(0, h * .55, w, h * .25); });
}
c.fillStyle = lin(c, h * .82, h, [[0, 'rgba(160,160,138,.6)'], [1, '#8f8f78']]); c.fillRect(0, h * .84, w, h * .16);
},
innen(c, w, h, r) {
const bo = h * (.6 + r() * .1);
c.fillStyle = lin(c, 0, bo, [[0, '#cfc8bb'], [1, '#ddd6c9']]); c.fillRect(0, 0, w, bo);
c.fillStyle = lin(c, bo, h, [[0, '#a99a84'], [1, '#8a7c68']]); c.fillRect(0, bo, w, h - bo);
const x0 = w * (.08 + r() * .3);
weich(c, w * .006, () => {
c.fillStyle = 'rgba(255,248,230,.7)'; c.beginPath(); c.moveTo(x0, h * .12); c.lineTo(x0 + w * .3, h * .12); c.lineTo(x0 + w * .3, bo); c.lineTo(x0, bo); c.fill();
c.fillStyle = 'rgba(255,244,222,.55)'; c.beginPath(); c.moveTo(x0, bo); c.lineTo(x0 + w * .3, bo); c.lineTo(x0 + w * .62, h); c.lineTo(x0 + w * .12, h); c.fill();
c.fillStyle = 'rgba(150,135,112,.6)'; c.fillRect(x0 + w * .145, h * .12, w * .012, bo - h * .12); c.fillRect(x0, h * .38, w * .3, w * .01);
});
// Stuhl
c.fillStyle = '#2b2622'; const sx = w * (.55 + r() * .2), sy = bo + (h - bo) * .25;
c.fillRect(sx, sy - h * .22, w * .015, h * .3); c.fillRect(sx, sy, w * .16, w * .02); c.fillRect(sx + w * .15, sy, w * .012, h * .12); c.fillRect(sx + w * .01, sy, w * .012, h * .12);
weich(c, w * .02, () => { c.fillStyle = 'rgba(40,30,20,.25)'; c.beginPath(); c.ellipse(sx + w * .12, sy + h * .13, w * .14, h * .02, 0, 0, Math.PI * 2); c.fill(); });
},
};
function malen(cv, typ, seed) {
const w = cv.width, h = cv.height, c = cv.getContext('2d'), r = zufall(seed);
c.clearRect(0, 0, w, h);
MALER[typ](c, w, h, r);
const v = c.createRadialGradient(w / 2, h / 2, Math.min(w, h) * .35, w / 2, h / 2, Math.max(w, h) * .75);
v.addColorStop(0, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,.2)');
c.fillStyle = v; c.fillRect(0, 0, w, h);
cv.classList.add('da');
}
/* ---------- Kacheln anlegen ---------- */
const raster = document.querySelector('[data-raster]');
const kacheln = SERIEN.map(([typ, f, ort, titel, jahr], i) => {
const li = document.createElement('div'); li.className = 'kachel'; li.setAttribute('role', 'listitem');
li.innerHTML = '<figure><div class="bild" style="--f:' + f + '"><canvas role="img" aria-label="' + titel + ', ' + ort + '"></canvas><span class="titel">' + titel + '</span></div>'
+ '<figcaption class="mono"><span><b>' + String(i + 1).padStart(2, '0') + '</b> ' + ort + '</span><span>' + jahr + '</span></figcaption></figure>';
li.dataset.ort = ort; li.dataset.nr = i + 1;
li._typ = typ; li._seed = 7919 * (i + 3);
return li;
});
let spalten = [], n = 0;
function aufbauen() {
const neu = getComputedStyle(raster).gridTemplateColumns.split(' ').length;
if (neu === n && spalten.length) return;
n = neu;
raster.innerHTML = '';
spalten = Array.from({ length: n }, () => { const s = document.createElement('div'); s.className = 'spalte'; raster.appendChild(s); return { el: s, y: scrollY, versatz: 0 }; });
kacheln.forEach((k, i) => spalten[i % n].el.appendChild(k));
setzeVariante(variante);
zeichnen();
}
function zeichnen() {
const dpr = Math.min(devicePixelRatio || 1, 2);
kacheln.forEach(k => {
const cv = k.querySelector('canvas'), b = cv.getBoundingClientRect();
const w = Math.round(b.width * dpr), h = Math.round(b.height * dpr);
if (w < 2 || (Math.abs(cv.width - w) < 8 && cv.classList.contains('da'))) return;
cv.width = w; cv.height = h; malen(cv, k._typ, k._seed);
});
}
/* ---------- Varianten und Werte ---------- */
const BASIS = .2, SKALA = .3, GEGEN_K = .1, GEGEN_LAG = .25, MAX = 260;
const komma = x => x.toFixed(2).replace(/0$/, '').replace('.', ',');
let variante = 'mitte', lags = [];
function setzeVariante(v) {
variante = v;
const mitte = (n - 1) / 2, faktor = touch ? .35 : 1;
lags = spalten.map((_, i) => v === 'gegen' ? GEGEN_LAG * faktor : (BASIS + Math.abs(i - mitte) * SKALA) * faktor);
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
const liste = lags.map(komma).join(' · ') + ' s';
document.querySelector('[data-werte]').textContent = reduziert() ? 'Reduzierte Bewegung: kein Lag, normales Scrollen'
: v === 'mitte' ? 'lag = 0,2 s + |i − Mitte| × 0,3 s → ' + liste + (touch ? ' (Touch × 0,35)' : '')
: v === 'gegen' ? 'Spalten ±10 % Gegenlauf zur Mitte der Sektion, lag ' + liste
: 'Lag zur Mitte nur, solange die Sektion einläuft → ' + liste;
}
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { pause(); setzeVariante(b.dataset.variante); }));
/* ---------- Lenis und die Spalten-Physik ---------- */
let lenis = null;
if (!reduziert() && window.Lenis) {
lenis = new Lenis({ lerp: .1, smoothWheel: true, syncTouch: false, autoRaf: false });
}
const liveEl = document.querySelector('[data-live]'), ortEl = document.querySelector('[data-ort]'), nrEl = document.querySelector('[data-nr]');
let vorher = performance.now(), zaehlerUhr = 0, letzteNr = 0;
function bild(t) {
const dt = Math.min(64, t - vorher); vorher = t;
if (lenis) lenis.raf(t);
const s = scrollY, vh = innerHeight;
if (!reduziert()) {
const rb = raster.getBoundingClientRect();
// Variante Einscrollen: Gewicht 1, solange die Rasteroberkante im unteren Viewport-Teil liegt, dann 0
const gewicht = variante === 'eintritt' ? Math.max(0, Math.min(1, (rb.top - vh * .05) / (vh * .55))) : 1;
spalten.forEach((sp, i) => {
const lag = lags[i] || 0;
sp.y = lag > 0 ? sp.y + (s - sp.y) * (1 - Math.exp(-dt / (lag * 1000 / 4))) : s;
let v = Math.max(-MAX, Math.min(MAX, s - sp.y)) * gewicht;
if (variante === 'gegen') { const rel = (vh / 2 - (rb.top + rb.height / 2)); v += rel * GEGEN_K * (i % 2 ? -1 : 1); }
sp.versatz = v;
sp.el.style.transform = 'translate3d(0,' + v.toFixed(2) + 'px,0)';
});
}
if (t - zaehlerUhr > 120) {
zaehlerUhr = t;
liveEl.textContent = spalten.map(sp => Math.round(sp.versatz)).join(' · ') + ' px';
// Kachel, die der Viewport-Mitte am nächsten ist
let best = null, d = 1e9;
kacheln.forEach(k => { const b = k.getBoundingClientRect(), m = Math.abs(b.top + b.height / 2 - vh / 2) + Math.abs(b.left + b.width / 2 - innerWidth / 2) * .3; if (m < d) { d = m; best = k; } });
if (best && +best.dataset.nr !== letzteNr) {
letzteNr = +best.dataset.nr;
nrEl.textContent = String(letzteNr).padStart(2, '0') + ' / 24'; ortEl.textContent = best.dataset.ort;
}
}
requestAnimationFrame(bild);
}
/* ---------- Vorführung: Scroll in Stößen, damit man das Nachziehen sieht ---------- */
const inOut = p => p < .5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; // easeInOutQuad
const setzeScroll = y => { if (lenis) lenis.scrollTo(y, { immediate: true, force: true }); else scrollTo(0, y); };
let fahrt = null, warte = null, runde = 0;
function fahre(ziel, dauer, fertig) {
const start = scrollY, t0 = performance.now();
const schritt = t => {
const p = Math.min(1, (t - t0) / dauer);
setzeScroll(start + (ziel - start) * (reduziert() ? (p < 1 ? 0 : 1) : inOut(p)));
if (p < 1) fahrt = requestAnimationFrame(schritt); else fertig && fertig();
};
fahrt = requestAnimationFrame(schritt);
}
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
if (runde > 0) setzeVariante(['mitte', 'gegen', 'mitte', 'eintritt'][runde % 4]);
runde++;
setzeScroll(0);
const max = document.documentElement.scrollHeight - innerHeight, stoss = Math.min(innerHeight * .55, 420);
let k = 0;
const naechster = () => {
if (k >= 5 || scrollY >= max - 4) { warte = setTimeout(() => fahre(0, 1400, () => { warte = setTimeout(vorfuehren, 700); }), 900); return; }
k++;
fahre(Math.min(max, scrollY + stoss), 700, () => { warte = setTimeout(naechster, 950); });
};
warte = setTimeout(naechster, 250);
}
function pause() { cancelAnimationFrame(fahrt); clearTimeout(warte); html.classList.remove('vorfuehrung'); }
function neustart() { pause(); setzeVariante(variante); setzeScroll(0); spalten.forEach(sp => { sp.y = 0; }); }
function weiter() { if (html.classList.contains('vorfuehrung') || html.classList.contains('vorschau')) vorfuehren(); }
['wheel', 'touchstart', 'keydown'].forEach(t => addEventListener(t, () => { if (fahrt || warte) pause(); }, { passive: true }));
aufbauen();
requestAnimationFrame(bild);
let ru = null;
addEventListener('resize', () => { clearTimeout(ru); ru = setTimeout(() => { aufbauen(); zeichnen(); }, 150); });
if (document.fonts) document.fonts.ready.then(zeichnen);
})();
</script>
</body>
</html>