Endlose Scroll-Galerie mit Tiefenparallaxe
Archivseite einer Mannheimer Fotografin ohne Seitenende: vier Spalten laufen mit eigener Geschwindigkeit und eigener Trägheit, der Footer bleibt fest erreichbar.
Eine Bildfolge wird so oft kopiert, dass ihr Ende nahtlos an den Anfang anschließt. Eine virtuelle Scrollposition (eigene oder Lenis mit infinite: true) wächst unbegrenzt und wird per Modulo auf die Höhe eines Satzes abgebildet. Spalten oder Reihen laufen mit unterschiedlichem Faktor und eigener Trägheit; bei schnellem Scrollen ziehen sie elastisch auseinander. Ein Klick öffnet das Bild im Vollbild. Optional blenden Bilder beim Eintritt ein.
Es gibt kein Seitenende, nur einen ruhigen Fluss. Die Spalten mit eigener Geschwindigkeit geben Tiefe, ohne ein Bild zu verändern. Das eignet sich als meditativer, markenprägender Einstieg in ein Archiv.
Wann einsetzen
Archiv- und Indexseiten von Fotografie, Portfolios, Mode-Lookbooks, Kunst- und Kulturprojekten.
Wann nicht
Auf SEO-relevanten Inhaltsseiten und überall, wo Nutzer etwas gezielt finden müssen. Wenn Impressum und Kontakt über einen Footer erreichbar sein müssen, den Footer fest einblenden.
Varianten
- Vertikale Spalten mit unterschiedlichem Faktor und eigener Trägheit (elastisches Raster)
- Horizontales Endlosband, auch zweireihig mit verschiedenen Geschwindigkeiten
- Endlose Seite, die Inhalte nachlädt und Ebenen staffelt
- Scroll-Röhre oder Zylinder (siehe 3d-karussell-zylinder)
Umsetzung
Lenis mit infinite: true (auf Touch zusätzlich syncTouch: true) oder eine eigene virtuelle Scrollposition aus wheel-, Tasten- und Pointer-Ereignissen. Spaltenversatz: y = −((scroll × faktor) mod satzhoehe), Faktoren z. B. 0,8 / 1 / 1,2. Elastik: pro Spalte ein eigener Lerp (z. B. 0,06 / 0,08 / 0,1), auf die Bildrate umgerechnet. Nur die Spalten bekommen ein transform, nicht die Bilder. Fallstricke: Lenis kennt kein CSS-Scroll-Snap. Kopien für Screenreader verstecken. Satzhöhe nach Resize neu messen. Jede Serie braucht trotzdem eine eigene, indexierbare Seite; der Zurück-Knopf sollte die Position wiederherstellen.
Robust umsetzen
Telefon. Eigenes Wischen mit Schwung (oder Lenis syncTouch, auf iOS unter 16 instabil). Alternative: natives Scrollen ohne Endlosschleife mit nachgeladenen Bildern. Zwei Spalten.
Ohne Bewegung. Kein Smooth Scroll, keine Parallaxe: eine normale, endliche Liste mit nativem Scrollen.
Barrierefrei. Tastatur muss scrollen können (Pfeile, Bild ab, Leertaste). Sprunglink zu Kontakt und Impressum. Kopien aria-hidden, nur die Originale fokussierbar. Vollbild als Dialog mit Esc und Pfeiltasten.
Code
<!DOCTYPE html>
<!--
Endlose Scroll-Galerie mit Tiefenparallaxe
Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/endlos-scroll-galerie/
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>Endlose Scroll-Galerie mit Tiefenparallaxe · 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 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; }
@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 {
--grund: #f4f4f2; --tinte: #141414; --leise: #6e6e6e; --platz: #e6e6e6; --linie: rgba(20, 20, 20, .1);
--kurve: cubic-bezier(0.455, 0.03, 0.515, 0.955); /* easeInOutQuad, Bildwechsel */
--wechsel: 500ms;
--ui: 200ms;
--rand: clamp(14px, 2vw, 28px);
--luecke: clamp(12px, 1.6vw, 24px);
--kopf: 64px; --fuss: 44px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { height: 100svh; overflow: hidden; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.mono { font: 400 11.5px/1.35 'Fragment Mono', ui-monospace, monospace; letter-spacing: .02em; font-variant-numeric: tabular-nums; }
/* Ränder: Name oben links, Navigation oben rechts, Footer unten fest */
.kopf { position: fixed; inset: 0 0 auto; height: var(--kopf); z-index: 5; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 0 var(--rand); background: var(--grund); }
.name { display: flex; align-items: baseline; gap: 12px; }
.name b { font: 400 26px/1 'Instrument Serif', Georgia, serif; letter-spacing: -.01em; }
.name span { font-size: 14px; color: var(--leise); }
.archiv { color: var(--leise); }
.navi { display: flex; gap: 22px; font-size: 14px; }
.navi a { padding-block: 4px; color: var(--leise); transition: color var(--ui) var(--kurve); }
.navi a:hover, .navi a[aria-current] { color: var(--tinte); }
.fuss { position: fixed; inset: auto 0 0; height: var(--fuss); z-index: 5; display: flex; align-items: center; gap: 22px; padding: 0 var(--rand); background: var(--grund); font-size: 13px; color: var(--leise); }
.fuss a { padding-block: 4px; }
.fuss a:hover { color: var(--tinte); }
.fuss .kontakt { color: var(--tinte); }
.sprung { position: fixed; left: var(--rand); top: 8px; z-index: 30; padding: 8px 12px; background: var(--tinte); color: var(--grund); font-size: 14px; translate: 0 -200%; }
.sprung:focus { translate: none; }
/* Bühne: virtuelle Scrollposition, Spuren laufen per transform */
.buehne { position: fixed; inset: 0; overflow: hidden; touch-action: none; outline: none; }
/* Fokus der Bühne: Rahmen innerhalb von Kopf und Fuß (die Bühne selbst liegt darunter) */
.buehne:focus-visible::before { content: ''; position: fixed; inset: calc(var(--kopf) + 4px) 4px calc(var(--fuss) + 4px); border: 2px solid var(--tinte); z-index: 4; pointer-events: none; }
.spur { position: absolute; top: 0; will-change: transform; }
.spalte { width: var(--spaltenbreite); }
.reihe { display: flex; gap: var(--luecke); height: var(--reihenhoehe); left: 0; }
.bild { position: relative; display: block; margin-bottom: var(--luecke); cursor: zoom-in; }
.reihe .bild { margin: 0; height: 100%; display: flex; flex-direction: column; flex: none; }
.reihe .bild figcaption { width: 0; min-width: 100%; }
.rahmen { display: block; background: var(--platz); aspect-ratio: var(--ar); overflow: hidden; }
.reihe .rahmen { height: calc(100% - 26px); width: auto; }
.rahmen img { display: block; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 600ms var(--kurve); }
.rahmen img.da { opacity: 1; }
.bild figcaption { display: flex; justify-content: space-between; gap: 10px; padding-top: 7px; color: var(--leise); white-space: nowrap; }
.bild figcaption span:first-child { overflow: hidden; text-overflow: ellipsis; }
/* Spalten: lange Titel brechen um statt abgeschnitten (schmale Spalten, 200 % Zoom) */
.spalte .bild figcaption { white-space: normal; align-items: baseline; }
.spalte .bild figcaption span:first-child { overflow: visible; text-wrap: pretty; }
.spalte .bild figcaption span + span { white-space: nowrap; }
.bild:hover figcaption span:first-child { color: var(--tinte); }
.bild:focus-visible { outline: 2px solid var(--tinte); outline-offset: 4px; }
/* Vollbild: nur Opazität, nie Position oder Maßstab des Werks */
.vollbild { position: fixed; inset: 0; z-index: 20; background: rgba(244, 244, 242, .985); display: grid; grid-template-rows: var(--kopf) 1fr auto; opacity: 0; visibility: hidden; transition: opacity var(--wechsel) var(--kurve), visibility 0s linear var(--wechsel); }
.vollbild.offen { opacity: 1; visibility: visible; transition: opacity var(--wechsel) var(--kurve), visibility 0s; }
.vb-kopf { display: flex; justify-content: space-between; align-items: center; padding: 0 var(--rand); }
.vb-kopf button { font-size: 14px; color: var(--leise); }
.vb-kopf button:hover { color: var(--tinte); }
.vb-buehne { position: relative; margin: 0 clamp(56px, 8vw, 120px); }
.vb-buehne img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; opacity: 0; transition: opacity var(--wechsel) var(--kurve); }
.vb-buehne img.an { opacity: 1; }
.vb-pfeil { position: absolute; top: 50%; translate: 0 -50%; width: 44px; height: 44px; display: grid; place-items: center; color: var(--leise); transition: color var(--ui) var(--kurve); }
.vb-pfeil:hover { color: var(--tinte); }
.vb-pfeil svg { width: 22px; height: 22px; }
.vb-pfeil.zurueck { left: calc(clamp(56px, 8vw, 120px) * -1 + 6px); }
.vb-pfeil.vor { right: calc(clamp(56px, 8vw, 120px) * -1 + 6px); }
.vb-fuss { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; padding: 18px var(--rand) 22px; }
.vb-titel { font: 400 clamp(1.5rem, 3vw, 2.5rem)/1.1 'Instrument Serif', Georgia, serif; letter-spacing: -.01em; }
.vb-fuss .mono { color: var(--leise); }
/* Pult: Variante und Werte */
.pult { position: fixed; right: var(--rand); bottom: calc(var(--fuss) + 12px); z-index: 8; display: grid; gap: 8px; justify-items: end; padding: 10px 12px; background: rgba(244, 244, 242, .92); border: 1px solid var(--linie); border-radius: 10px; backdrop-filter: blur(10px); }
.pult .knoepfe { display: flex; gap: 4px; }
.pult button { font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; height: 28px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--linie); color: var(--leise); transition: color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.pult button:hover { color: var(--tinte); }
.pult button[aria-pressed=true] { background: var(--tinte); border-color: var(--tinte); color: var(--grund); }
.pult button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.werte { font: 500 10px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); text-align: right; max-width: 380px; }
.werte b { font-weight: 500; color: var(--tinte); }
.vorschau .fuss .weg { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
@media (max-width: 760px) {
:root { --kopf: 56px; }
.name span, .navi a:not([aria-current]):not(.k), .archiv { display: none; }
.name b { font-size: 23px; }
.fuss { gap: 16px; font-size: 12px; }
.fuss .weg { display: none; }
}
/* Reduzierte Bewegung: keine Parallaxe, kein weiches Scrollen, endliche Liste mit normalem Scrollen */
.reduziert body { overflow: auto; height: auto; }
.reduziert .buehne { position: static; touch-action: auto; display: grid; grid-template-columns: repeat(var(--spalten), 1fr); gap: 0 var(--luecke); padding: calc(var(--kopf) + 8px) var(--rand) calc(var(--fuss) + 24px); }
.reduziert .spur { position: static; transform: none !important; width: auto; }
.reduziert .reihe { display: block; height: auto; }
.reduziert .reihe .bild { height: auto; margin-bottom: var(--luecke); }
.reduziert .reihe .rahmen { height: auto; width: 100%; }
.reduziert .kopie { display: none; }
.reduziert .rahmen img, .reduziert .vollbild, .reduziert .vb-buehne img { transition-duration: 150ms; }
</style>
</head>
<body>
<a class="sprung" href="#kontakt">Direkt zu Kontakt und Impressum</a>
<header class="kopf">
<a class="name" href="#"><b>Tamara Dörr</b><span>Fotografie, Mannheim</span></a>
<p class="mono archiv" data-archiv>Archiv · 7 Serien · 21 Bilder · 2022–2026</p>
<nav class="navi" aria-label="Hauptnavigation"><a href="#">Serien</a><a href="#" aria-current="page">Archiv</a><a href="#">Auftrag</a><a class="k" href="#kontakt">Kontakt</a></nav>
</header>
<main class="buehne" data-buehne tabindex="0" aria-label="Archiv, endlos scrollbar. Pfeiltasten und Bild-ab bewegen, Enter öffnet ein Bild."></main>
<footer class="fuss" id="kontakt">
<a class="kontakt" href="#">Anfragen & Aufträge</a>
<a href="#">Impressum</a>
<a href="#">Datenschutz</a>
<span class="mono weg">© 2026 Tamara Dörr · Jungbusch, Mannheim</span>
</footer>
<div class="vollbild" data-vollbild role="dialog" aria-modal="true" aria-label="Bild im Vollbild" aria-hidden="true">
<div class="vb-kopf"><p class="mono" data-vb-zaehler>01 / 21</p><button type="button" data-vb-zu>Schließen (Esc)</button></div>
<div class="vb-buehne">
<img alt="" data-vb-a><img alt="" data-vb-b>
<button type="button" class="vb-pfeil zurueck" data-vb-schritt="-1" aria-label="Vorheriges Bild"><svg viewBox="0 0 22 22" aria-hidden="true"><path d="M14 4 7 11l7 7" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button>
<button type="button" class="vb-pfeil vor" data-vb-schritt="1" aria-label="Nächstes Bild"><svg viewBox="0 0 22 22" aria-hidden="true"><path d="m8 4 7 7-7 7" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button>
</div>
<div class="vb-fuss"><p class="vb-titel" data-vb-titel></p><p class="mono" data-vb-info></p></div>
</div>
<div class="pult">
<div class="knoepfe" role="group" aria-label="Variante">
<button type="button" data-modus="spalten" aria-pressed="true">Spalten</button>
<button type="button" data-modus="band" aria-pressed="false">Band</button>
</div>
<p class="werte" data-werte></p>
</div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const buehne = document.querySelector('[data-buehne]');
const werte = document.querySelector('[data-werte]');
/* ---------- Bilder: eigene Aufnahmen als Canvas-Szenen (Mannheim, 7 Serien) ---------- */
const SERIEN = [
{ titel: 'Rheinufer, Lindenhof', jahr: 2024, szene: 'horizont' },
{ titel: 'Quadrate, 23 Uhr', jahr: 2025, szene: 'nacht' },
{ titel: 'Luisenpark, Novembernebel', jahr: 2023, szene: 'nebel' },
{ titel: 'Neckarstadt-West, blaue Stunde', jahr: 2025, szene: 'fenster' },
{ titel: 'Mühlauhafen', jahr: 2024, szene: 'kran' },
{ titel: 'Fernmeldeturm im Dunst', jahr: 2022, szene: 'turm' },
{ titel: 'Herschelbad', jahr: 2026, szene: 'becken' },
];
const FORMATE = [4 / 5, 3 / 2, 2 / 3, 1, 3 / 4, 4 / 5, 3 / 2];
const ROEMISCH = ['I', 'II', 'III'];
const BILDER = [];
for (let k = 0; k < 3; k++) SERIEN.forEach((s, i) => BILDER.push({ ...s, nr: 0, teil: ROEMISCH[k], seed: i * 31 + k * 7 + 3, ar: FORMATE[(i * 3 + k * 2) % FORMATE.length] }));
BILDER.forEach((b, i) => { b.nr = i + 1; });
const zufall = seed => () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const verlauf = (c, x0, y0, x1, y1, stops) => { const g = c.createLinearGradient(x0, y0, x1, y1); stops.forEach(([o, f]) => g.addColorStop(o, f)); return g; };
const scheibe = (c, x, y, r, farbe, a = 1) => { const g = c.createRadialGradient(x, y, 0, x, y, r); g.addColorStop(0, farbe); g.addColorStop(1, farbe.replace(/[\d.]+\)$/, '0)')); c.globalAlpha = a; c.fillStyle = g; c.fillRect(x - r, y - r, r * 2, r * 2); c.globalAlpha = 1; };
const weich = (c, px) => { c.filter = px ? `blur(${px}px)` : 'none'; };
const SZENEN = {
horizont(c, w, h, r) {
const hy = h * (0.5 + r() * 0.12), paletten = [['#7f93a8', '#c7c3c2', '#ecd8c2'], ['#a9b6c1', '#d9dcdb', '#efe9df'], ['#55617a', '#a99fa3', '#e7b99a']];
const p = paletten[Math.floor(r() * 3)];
c.fillStyle = verlauf(c, 0, 0, 0, hy, [[0, p[0]], [0.7, p[1]], [1, p[2]]]); c.fillRect(0, 0, w, hy + 1);
const sx = w * (0.25 + r() * 0.5); scheibe(c, sx, hy - h * 0.04, w * 0.42, 'rgba(255,240,215,.55)'); scheibe(c, sx, hy - h * 0.04, w * 0.04, 'rgba(255,248,232,.95)');
// Ufer gegenüber: Industrie, Schlote, Dampf
weich(c, 0.8); c.fillStyle = 'rgba(70,76,88,.5)';
for (let x = 0; x < w;) { const bw = w * (0.02 + r() * 0.07), bh = h * (0.006 + r() * 0.03); c.fillRect(x, hy - bh, bw, bh + 1); x += bw; }
const schlote = [];
for (let i = 0; i < 4 + r() * 4; i++) { const x = r() * w, sh = h * (0.04 + r() * 0.06); c.fillRect(x, hy - sh, w * 0.006, sh); schlote.push([x, hy - sh]); }
weich(c, 7);
schlote.forEach(([x, y]) => { for (let j = 0; j < 6; j++) { c.fillStyle = `rgba(250,250,248,${0.32 - j * 0.04})`; c.beginPath(); c.ellipse(x + j * w * 0.018, y - j * h * 0.016, w * (0.012 + j * 0.008), h * (0.008 + j * 0.004), 0, 0, 7); c.fill(); } });
weich(c, 0);
c.fillStyle = verlauf(c, 0, hy, 0, h, [[0, p[1]], [0.25, p[0]], [1, '#3d4552']]); c.fillRect(0, hy, w, h - hy);
weich(c, 10); c.fillStyle = 'rgba(255,244,222,.35)'; c.fillRect(sx - w * 0.02, hy, w * 0.04, h * 0.35); weich(c, 0);
for (let i = 0; i < 40; i++) { c.fillStyle = `rgba(255,255,255,${0.02 + r() * 0.04})`; c.fillRect(0, hy + (h - hy) * r(), w, 1); }
if (r() > 0.35) { // Buhne: flacher Steindamm vom Ufer schräg ins Wasser
const wy = v => hy + (h - hy) * v, spitze = w * (0.42 + r() * 0.2), links = r() > 0.5;
const X = x => (links ? x : w - x);
weich(c, 2); c.fillStyle = 'rgba(30,34,40,.35)'; c.beginPath(); c.moveTo(X(0), wy(0.86)); c.lineTo(X(spitze), wy(0.4)); c.lineTo(X(spitze), wy(0.47)); c.lineTo(X(0), wy(1)); c.fill();
weich(c, 1.1); c.fillStyle = '#2a2e34'; c.beginPath(); c.moveTo(X(0), wy(0.62)); c.lineTo(X(spitze * 0.96), wy(0.34)); c.lineTo(X(spitze), wy(0.36)); c.lineTo(X(spitze * 0.98), wy(0.385)); c.lineTo(X(0), wy(0.8)); c.closePath(); c.fill();
c.fillStyle = 'rgba(255,255,255,.07)'; c.beginPath(); c.moveTo(X(0), wy(0.62)); c.lineTo(X(spitze * 0.96), wy(0.34)); c.lineTo(X(spitze * 0.96), wy(0.35)); c.lineTo(X(0), wy(0.65)); c.fill(); weich(c, 0);
}
},
nacht(c, w, h, r) {
c.fillStyle = verlauf(c, 0, 0, 0, h, [[0, '#070a12'], [0.55, '#151521'], [1, '#2b1d18']]); c.fillRect(0, 0, w, h);
c.globalCompositeOperation = 'lighter';
const farben = ['255,179,92', '255,243,220', '255,85,60', '120,255,190', '255,210,140', '140,180,255'];
for (let i = 0; i < 46; i++) {
const gr = r(), rad = w * (0.012 + gr * gr * 0.075), y = h * (0.28 + r() * 0.5), x = r() * w, f = farben[Math.floor(r() * farben.length)];
weich(c, 1 + gr * 5); c.fillStyle = `rgba(${f},${0.18 + r() * 0.35})`; c.beginPath(); c.arc(x, y, rad, 0, 7); c.fill();
}
weich(c, 0);
for (let i = 0; i < 9; i++) { const x = r() * w, y = h * (0.2 + r() * 0.5); scheibe(c, x, y, w * 0.05, 'rgba(255,214,150,.5)'); scheibe(c, x, y, w * 0.006, 'rgba(255,250,235,1)'); }
c.globalCompositeOperation = 'source-over';
scheibe(c, w * 0.5, h * 1.05, w * 0.9, 'rgba(255,150,80,.18)');
},
nebel(c, w, h, r) {
const dunst = 'rgba(214,218,212,.42)';
c.fillStyle = verlauf(c, 0, 0, 0, h, [[0, '#c2c8c3'], [0.6, '#d8dbd6'], [1, '#e0e2de']]); c.fillRect(0, 0, w, h);
const ebenen = 4;
for (let e = 0; e < ebenen; e++) {
const t = e / (ebenen - 1), boden = h * (0.56 + e * 0.1);
weich(c, 8 - t * 7); c.fillStyle = '#353d36';
for (let x = -w * 0.1; x < w * 1.1;) {
const bh = h * (0.16 + r() * 0.2) * (0.6 + t * 0.7), neig = (r() - 0.5) * bh * 0.08, sw = w * (0.003 + t * 0.005);
c.beginPath(); c.moveTo(x - sw, boden + 2); c.lineTo(x + sw, boden + 2); c.lineTo(x + neig + sw * 0.5, boden - bh * 0.62); c.lineTo(x + neig - sw * 0.5, boden - bh * 0.62); c.fill();
const kr = bh * (0.24 + r() * 0.12), kx = x + neig, ky = boden - bh * 0.72;
for (let k = 0; k < 16; k++) { const wi = r() * 6.3, ab = Math.sqrt(r()) * kr; c.beginPath(); c.ellipse(kx + Math.cos(wi) * ab, ky + Math.sin(wi) * ab * 0.7, kr * (0.22 + r() * 0.28), kr * (0.18 + r() * 0.2), r() * 3, 0, 7); c.fill(); }
x += w * (0.07 + r() * 0.16) / (0.7 + t * 0.6);
}
c.fillRect(0, boden, w, h - boden);
weich(c, 0);
if (e < ebenen - 1) { c.fillStyle = dunst; c.fillRect(0, 0, w, h); }
}
c.fillStyle = verlauf(c, 0, 0, 0, h, [[0.55, 'rgba(225,228,224,0)'], [0.85, 'rgba(225,228,224,.28)'], [1, 'rgba(225,228,224,.1)']]); c.fillRect(0, 0, w, h);
},
fenster(c, w, h, r) {
c.fillStyle = verlauf(c, 0, 0, w, h, [[0, '#1b2741'], [1, '#2a3858']]); c.fillRect(0, 0, w, h);
const sp = 4 + Math.floor(r() * 3), zl = 6 + Math.floor(r() * 3), fw = w / sp, fh = h / zl, ox = -fw * r() * 0.5, oy = -fh * r() * 0.5;
for (let z = 0; z <= zl; z++) {
c.fillStyle = 'rgba(160,180,215,.07)'; c.fillRect(0, oy + z * fh + fh * 0.86, w, fh * 0.05);
for (let s = 0; s <= sp; s++) {
const x = ox + s * fw + fw * 0.26, y = oy + z * fh + fh * 0.18, ww = fw * 0.48, wh = fh * 0.58, licht = r();
if (licht < 0.36) {
const warm = r() < 0.75;
c.fillStyle = verlauf(c, x, y, x, y + wh, warm ? [[0, '#ffd99a'], [1, '#e99a52']] : [[0, '#dbe6ff'], [1, '#93aee8']]); c.fillRect(x, y, ww, wh);
if (r() < 0.5) { c.fillStyle = 'rgba(255,240,220,.35)'; for (let v = 0; v < 7; v++) c.fillRect(x + ww * (v / 7), y, ww * 0.06, wh); }
scheibe(c, x + ww / 2, y + wh / 2, ww * 1.2, warm ? 'rgba(255,190,110,.12)' : 'rgba(150,180,255,.1)');
} else {
c.fillStyle = verlauf(c, x, y, x, y + wh, [[0, '#2f3d5f'], [1, '#121a2c']]); c.fillRect(x, y, ww, wh);
}
c.fillStyle = 'rgba(12,16,28,.8)'; c.fillRect(x + ww / 2 - 1.2, y, 2.4, wh); c.fillRect(x, y + wh * 0.32, ww, 2.4);
}
}
},
kran(c, w, h, r) {
const hy = h * (0.66 + r() * 0.08);
c.fillStyle = verlauf(c, 0, 0, 0, hy, [[0, '#30355f'], [0.55, '#b4646a'], [1, '#f2b27c']]); c.fillRect(0, 0, w, hy + 1);
scheibe(c, w * (0.3 + r() * 0.4), hy, w * 0.6, 'rgba(255,200,140,.35)');
const silhouette = (spiegel) => {
c.save(); if (spiegel) { c.translate(0, hy * 2); c.scale(1, -1); }
c.fillStyle = '#18161f'; c.strokeStyle = '#18161f';
for (let x = 0; x < w;) { const bw = w * (0.03 + r() * 0.05), bh = h * (0.01 + r() * 0.03); c.fillRect(x, hy - bh, bw - 1, bh); x += bw; }
const n = 2 + Math.floor(r() * 2);
for (let i = 0; i < n; i++) {
const x = w * (0.15 + i * (0.7 / n) + r() * 0.08), kh = h * (0.32 + r() * 0.14), b = w * 0.07, top = hy - kh;
c.lineWidth = Math.max(1.5, w * 0.005);
c.beginPath(); c.moveTo(x, hy); c.lineTo(x, top); c.moveTo(x + b, hy); c.lineTo(x + b, top); c.moveTo(x, hy - kh * 0.4); c.lineTo(x + b, top); c.moveTo(x + b, hy - kh * 0.4); c.lineTo(x, top); c.stroke();
c.fillRect(x - w * 0.14, top - h * 0.012, w * 0.38, h * 0.014);
c.beginPath(); c.moveTo(x + b / 2, top - h * 0.1); c.lineTo(x - w * 0.12, top); c.moveTo(x + b / 2, top - h * 0.1); c.lineTo(x + w * 0.22, top); c.lineWidth = 1; c.stroke();
c.fillRect(x + b * 0.2, top + h * 0.004, b * 0.6, h * 0.03);
if (!spiegel) scheibe(c, x + b / 2, top - h * 0.1, w * 0.012, 'rgba(255,60,50,.95)');
}
c.restore();
};
silhouette(false);
c.fillStyle = verlauf(c, 0, hy, 0, h, [[0, '#a45f63'], [1, '#22223a']]); c.fillRect(0, hy, w, h - hy);
c.globalAlpha = 0.45; weich(c, 2.5); silhouette(true); weich(c, 0); c.globalAlpha = 1;
for (let i = 0; i < 50; i++) { c.fillStyle = `rgba(255,210,170,${r() * 0.06})`; c.fillRect(0, hy + (h - hy) * r(), w, 1); }
},
turm(c, w, h, r) {
c.fillStyle = verlauf(c, 0, 0, 0, h, [[0, '#cfd5d9'], [0.7, '#e7e9e6'], [1, '#eeeeea']]); c.fillRect(0, 0, w, h);
const x = w * (0.32 + r() * 0.36), boden = h * 0.86, korb = h * (0.3 + r() * 0.06);
weich(c, 1.4); c.fillStyle = 'rgba(120,130,138,.75)';
c.beginPath(); c.moveTo(x - w * 0.022, boden); c.lineTo(x + w * 0.022, boden); c.lineTo(x + w * 0.011, korb); c.lineTo(x - w * 0.011, korb); c.closePath(); c.fill();
c.beginPath(); c.ellipse(x, korb, w * 0.075, h * 0.018, 0, 0, 7); c.fill();
c.fillRect(x - w * 0.06, korb - h * 0.035, w * 0.12, h * 0.035);
c.beginPath(); c.ellipse(x, korb - h * 0.035, w * 0.06, h * 0.01, 0, 0, 7); c.fill();
c.fillRect(x - w * 0.045, korb + h * 0.03, w * 0.09, h * 0.012);
c.fillRect(x - w * 0.004, korb - h * 0.2, w * 0.008, h * 0.17);
c.fillStyle = 'rgba(170,110,105,.7)'; for (let i = 0; i < 4; i++) c.fillRect(x - w * 0.004, korb - h * (0.2 - i * 0.04), w * 0.008, h * 0.018);
weich(c, 4); c.fillStyle = 'rgba(78,90,82,.75)';
for (let i = 0; i < 40; i++) { const bx = r() * w, br = w * (0.03 + r() * 0.06); c.beginPath(); c.ellipse(bx, boden + (r() - 0.3) * h * 0.04, br, br * 0.7, 0, 0, 7); c.fill(); }
c.fillRect(0, boden + h * 0.02, w, h);
weich(c, 0);
c.fillStyle = verlauf(c, 0, 0, 0, h, [[0.4, 'rgba(235,236,232,0)'], [0.9, 'rgba(235,236,232,.35)']]); c.fillRect(0, 0, w, h);
},
becken(c, w, h, r) {
c.fillStyle = verlauf(c, 0, 0, w, h, [[0, '#6cc6d2'], [1, '#2e8ba3']]); c.fillRect(0, 0, w, h);
c.strokeStyle = 'rgba(20,70,90,.12)'; c.lineWidth = 1; const kachel = w / 14;
for (let x = 0; x < w; x += kachel) { c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke(); }
for (let y = 0; y < h; y += kachel) { c.beginPath(); c.moveTo(0, y); c.lineTo(w, y); c.stroke(); }
const bahn = w * (0.3 + r() * 0.4);
weich(c, 2); c.fillStyle = 'rgba(18,58,104,.75)';
c.beginPath(); for (let y = 0; y <= h; y += 6) c.lineTo(bahn + Math.sin(y * 0.03 + r()) * w * 0.006, y); for (let y = h; y >= 0; y -= 6) c.lineTo(bahn + w * 0.05 + Math.sin(y * 0.03) * w * 0.006, y); c.fill();
c.fillRect(bahn - w * 0.08, h * 0.08, w * 0.21, w * 0.05);
c.globalCompositeOperation = 'lighter'; weich(c, 1.6);
for (let i = 0; i < 70; i++) {
const quer = i % 2, f = 0.02 + r() * 0.03, ph = r() * 7, a = 6 + r() * 14, o = r() * (quer ? w : h);
c.strokeStyle = `rgba(210,255,250,${0.1 + r() * 0.16})`; c.lineWidth = 1 + r() * 2.4; c.beginPath();
for (let t = -20; t <= (quer ? h : w) + 20; t += 8) { const v = o + Math.sin(t * f + ph) * a + Math.sin(t * f * 2.3) * a * 0.4; quer ? c.lineTo(v, t) : c.lineTo(t, v); }
c.stroke();
}
weich(c, 0); c.globalCompositeOperation = 'source-over';
scheibe(c, w * 0.2, h * 0.15, w * 0.6, 'rgba(255,255,255,.18)');
},
};
function korn(c, w, h, r, staerke) {
const d = c.getImageData(0, 0, w, h), p = d.data;
for (let i = 0; i < p.length; i += 4) { const n = (r() - 0.5) * staerke; p[i] += n; p[i + 1] += n; p[i + 2] += n; }
c.putImageData(d, 0, 0);
}
const quellen = new Map();
function malen(b) {
if (quellen.has(b.nr)) return quellen.get(b.nr);
const w = 640, h = Math.round(w / b.ar), cv = document.createElement('canvas'); cv.width = w; cv.height = h;
const c = cv.getContext('2d'), r = zufall(b.seed);
SZENEN[b.szene](c, w, h, r); weich(c, 0); c.globalAlpha = 1;
korn(c, w, h, r, 7); // Sensorrauschen gehört zum Bild, gegen Streifen im Verlauf
const url = cv.toDataURL('image/jpeg', 0.86);
quellen.set(b.nr, url);
return url;
}
/* ---------- Spuren: Spalten (vertikal) oder Band (horizontal) ---------- */
const MODI = {
spalten: { faktor: [0.8, 1, 1.2, 0.9], lerp: [0.06, 0.08, 0.1, 0.07], versatz: [0, 220, 90, 320] },
band: { faktor: [1, 0.75], lerp: [0.08, 0.06], versatz: [0, 260] },
};
let modus = 'spalten', spuren = [], ziel = 0, schwung = 0;
const pad = n => String(n).padStart(2, '0');
function figur(b, kopie) {
const f = document.createElement('figure');
f.className = 'bild' + (kopie ? ' kopie' : '');
f.style.setProperty('--ar', b.ar);
f.dataset.nr = b.nr;
if (kopie) f.setAttribute('aria-hidden', 'true'); else { f.tabIndex = 0; f.setAttribute('role', 'button'); f.setAttribute('aria-label', `${b.titel} ${b.teil}, ${b.jahr}. Im Vollbild öffnen`); }
f.innerHTML = `<span class="rahmen"><img alt="${kopie ? '' : b.titel + ' ' + b.teil}" draggable="false"></span><figcaption class="mono"><span>${pad(b.nr)} ${b.titel} ${b.teil}</span><span>${b.jahr}</span></figcaption>`;
return f;
}
function spaltenZahl() { return innerWidth < 560 ? 2 : innerWidth < 1100 ? 3 : 4; }
function aufbauen() {
buehne.innerHTML = ''; spuren = [];
const m = MODI[modus], kopf = parseFloat(getComputedStyle(html).getPropertyValue('--kopf')), fuss = 44;
const rand = Math.min(28, Math.max(14, innerWidth * 0.02)), luecke = Math.min(24, Math.max(12, innerWidth * 0.016));
if (modus === 'spalten' || reduziert()) {
const n = spaltenZahl(), breite = (innerWidth - rand * 2 - luecke * (n - 1)) / n;
html.style.setProperty('--spalten', n);
html.style.setProperty('--spaltenbreite', breite + 'px');
for (let i = 0; i < n; i++) {
const el = document.createElement('div'); el.className = 'spur spalte';
el.style.left = rand + i * (breite + luecke) + 'px';
const eigene = BILDER.filter((_, j) => j % n === i);
eigene.forEach(b => el.append(figur(b, false)));
buehne.append(el);
const satz = el.offsetHeight; // Höhe eines Satzes; Kopien bis Bildschirm + Satz gefüllt
const kopien = Math.ceil((innerHeight + satz) / satz);
for (let k = 0; k < kopien; k++) eigene.forEach(b => el.append(figur(b, true)));
el.prepend(...eigene.map(b => figur(b, true))); // ein Satz davor: Originale liegen in (s0 − Satz, s0]
spuren.push({ el, achse: 'y', satz, s0: kopf + luecke, faktor: m.faktor[i % 4], lerp: m.lerp[i % 4], versatz: m.versatz[i % 4] - kopf, ist: ziel * m.faktor[i % 4] });
}
} else {
const reihen = 2, hoehe = (innerHeight - kopf - fuss - luecke * 3) / reihen;
html.style.setProperty('--reihenhoehe', hoehe + 'px');
for (let i = 0; i < reihen; i++) {
const el = document.createElement('div'); el.className = 'spur reihe';
el.style.top = kopf + luecke + i * (hoehe + luecke) + 'px';
const eigene = BILDER.filter((_, j) => j % reihen === i);
eigene.forEach(b => el.append(figur(b, false)));
buehne.append(el);
const satz = el.scrollWidth + luecke;
const kopien = Math.ceil((innerWidth + satz) / satz);
for (let k = 0; k < kopien; k++) eigene.forEach(b => el.append(figur(b, true)));
el.prepend(...eigene.map(b => figur(b, true)));
spuren.push({ el, achse: 'x', satz, s0: rand, faktor: m.faktor[i], lerp: m.lerp[i], versatz: m.versatz[i], ist: ziel * m.faktor[i] });
}
}
bilderSetzen();
zeichnen(true);
}
// Bilder nach und nach malen, damit die Seite sofort steht (Platzhalter-Grau in Bildgröße)
let malAuftrag = 0;
function bilderSetzen() {
const auftrag = ++malAuftrag, liste = [...BILDER];
const naechstes = () => {
if (auftrag !== malAuftrag || !liste.length) return;
const b = liste.shift(), url = malen(b);
buehne.querySelectorAll(`.bild[data-nr="${b.nr}"] img`).forEach(img => {
if (img.src) return;
img.onload = () => img.classList.add('da');
img.src = url;
});
setTimeout(naechstes, 0);
};
naechstes();
}
const modulo = (v, n) => ((v % n) + n) % n;
function zeichnen(sofort) {
if (reduziert()) return;
spuren.forEach(s => {
const soll = ziel * s.faktor;
s.ist = sofort ? soll : s.ist;
const pos = s.s0 - s.satz - modulo(s.ist + s.versatz + s.s0, s.satz); // gleiches Bild wie −((ist + versatz) mod Satz)
s.el.style.transform = s.achse === 'y' ? `translate3d(0,${pos.toFixed(2)}px,0)` : `translate3d(${pos.toFixed(2)}px,0,0)`;
});
}
/* ---------- Virtuelle Scrollposition: Rad, Tasten, Touch mit Schwung ---------- */
let letzte = performance.now(), auto = 0;
function bild(t) {
const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
if (!reduziert() && !offen) {
ziel += auto * dt;
if (Math.abs(schwung) > 0.05) { ziel += schwung * dt; schwung *= Math.pow(0.95, dt); } else schwung = 0;
spuren.forEach(s => { const k = 1 - Math.pow(1 - s.lerp, dt); s.ist += (ziel * s.faktor - s.ist) * k; });
zeichnen(false);
}
requestAnimationFrame(bild);
}
requestAnimationFrame(bild);
const eingriff = () => { if (vorf) { stopp(); html.classList.remove('vorfuehrung'); } };
addEventListener('wheel', e => {
if (reduziert() || offen) return;
e.preventDefault(); eingriff();
const d = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
ziel += (Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX) * d;
}, { passive: false });
let tx = 0, ty = 0, tz = 0, tv = 0, gezogen = 0;
buehne.addEventListener('pointerdown', e => {
if (reduziert() || e.pointerType === 'mouse') return;
eingriff(); tx = e.clientX; ty = e.clientY; tz = performance.now(); tv = 0; schwung = 0; gezogen = 0;
buehne.setPointerCapture(e.pointerId);
});
buehne.addEventListener('pointermove', e => {
if (!buehne.hasPointerCapture(e.pointerId)) return;
const d = modus === 'band' ? tx - e.clientX : ty - e.clientY, jetzt = performance.now();
ziel += d; gezogen += Math.abs(d);
tv = d / Math.max(1, jetzt - tz) * 16.667; tx = e.clientX; ty = e.clientY; tz = jetzt;
});
buehne.addEventListener('pointerup', e => { if (buehne.hasPointerCapture(e.pointerId)) { buehne.releasePointerCapture(e.pointerId); schwung = tv * 1.6; } });
buehne.addEventListener('keydown', e => {
if (reduziert() || offen) return;
const schritt = { ArrowDown: 120, ArrowUp: -120, ArrowRight: 120, ArrowLeft: -120, PageDown: innerHeight * 0.8, PageUp: -innerHeight * 0.8, ' ': innerHeight * 0.8 }[e.key];
if (schritt !== undefined && !(e.target.closest('.bild') && e.key === ' ')) { e.preventDefault(); eingriff(); ziel += e.shiftKey && e.key === ' ' ? -schritt : schritt; }
});
// Tab auf ein Bild außerhalb des sichtbaren Bereichs: Scrollposition so setzen, dass es frei unter dem Kopf steht
buehne.addEventListener('focusin', e => {
const f = e.target.closest('.bild'); buehne.scrollTop = 0; buehne.scrollLeft = 0;
if (!f || reduziert()) return;
const s = spuren.find(sp => sp.el.contains(f)); if (!s) return;
const y = s.achse === 'y', r = f.getBoundingClientRect(), a = y ? r.top : r.left, l = y ? r.height : r.width;
const ende = y ? innerHeight - 44 - 12 : innerWidth - s.s0;
if (a >= s.s0 - 1 && a + l <= ende + 1) return;
const lage = (y ? f.offsetTop : f.offsetLeft) - s.satz; // Abstand vom Satzanfang
const jetzt = modulo(ziel * s.faktor + s.versatz + s.s0, s.satz);
let d = modulo(lage - jetzt, s.satz); if (d > s.satz / 2) d -= s.satz;
eingriff(); schwung = 0; ziel += d / s.faktor; zeichnen(true);
});
buehne.addEventListener('scroll', () => { buehne.scrollTop = 0; buehne.scrollLeft = 0; }); // Fokus darf die Bühne nicht selbst verschieben
/* ---------- Vollbild mit Crossfade ---------- */
const vb = document.querySelector('[data-vollbild]'), vbA = document.querySelector('[data-vb-a]'), vbB = document.querySelector('[data-vb-b]');
const vbZaehler = document.querySelector('[data-vb-zaehler]'), vbTitel = document.querySelector('[data-vb-titel]'), vbInfo = document.querySelector('[data-vb-info]');
let offen = false, vbNr = 1, vbOben = vbA, zuvor = null;
function zeige(nr) {
vbNr = ((nr - 1 + BILDER.length) % BILDER.length) + 1;
const b = BILDER[vbNr - 1], unten = vbOben === vbA ? vbB : vbA;
unten.src = malen(b); unten.alt = `${b.titel} ${b.teil}, ${b.jahr}`;
unten.classList.add('an'); vbOben.classList.remove('an'); vbOben = unten;
vbZaehler.textContent = `${pad(vbNr)} / ${BILDER.length}`;
vbTitel.textContent = `${b.titel} ${b.teil}`;
vbInfo.textContent = `Mannheim, ${b.jahr} · Pigmentdruck auf Baryt, 40 × ${Math.round(40 / b.ar)} cm`;
}
function oeffnen(nr) { zuvor = document.activeElement; offen = true; vbA.classList.remove('an'); vbB.classList.remove('an'); zeige(nr); vb.classList.add('offen'); vb.setAttribute('aria-hidden', 'false'); vb.querySelector('[data-vb-zu]').focus({ preventScroll: true }); }
function schliessen() { offen = false; vb.classList.remove('offen'); vb.setAttribute('aria-hidden', 'true'); if (zuvor && zuvor.focus) zuvor.focus({ preventScroll: true }); }
buehne.addEventListener('click', e => { const f = e.target.closest('.bild'); if (f && gezogen < 6) oeffnen(+f.dataset.nr); gezogen = 0; });
buehne.addEventListener('keydown', e => { const f = e.target.closest('.bild'); if (f && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); oeffnen(+f.dataset.nr); } });
vb.querySelector('[data-vb-zu]').addEventListener('click', schliessen);
vb.querySelectorAll('[data-vb-schritt]').forEach(k => k.addEventListener('click', () => zeige(vbNr + +k.dataset.vbSchritt)));
addEventListener('keydown', e => {
if (!offen) return;
if (e.key === 'Escape') schliessen();
if (e.key === 'ArrowRight') zeige(vbNr + 1);
if (e.key === 'ArrowLeft') zeige(vbNr - 1);
});
/* ---------- Varianten, Werte ---------- */
const WERTE = {
spalten: 'Faktoren <b>0,8 / 1 / 1,2 / 0,9</b> · Lerp <b>0,06 / 0,08 / 0,1 / 0,07</b> · y = −((Scroll × Faktor) mod Satzhöhe) · Bild <b>500 ms cubic-bezier(0.455, 0.03, 0.515, 0.955)</b>',
band: 'Zwei Reihen · Faktoren <b>1 / 0,75</b> · Lerp <b>0,08 / 0,06</b> · Rad und Wischen laufen waagerecht · x = −((Scroll × Faktor) mod Satzbreite)',
};
function setzeModus(m) {
modus = m;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
werte.innerHTML = WERTE[m];
aufbauen();
}
document.querySelectorAll('[data-modus]').forEach(b => b.addEventListener('click', () => setzeModus(b.dataset.modus)));
let breiteAlt = innerWidth, rt = null;
addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(() => { if (Math.abs(innerWidth - breiteAlt) > 40 || modus === 'band') { breiteAlt = innerWidth; aufbauen(); } }, 150); });
// Reduzierte Bewegung kann zur Laufzeit kommen (Schalter der Detailseite)
let warReduziert = reduziert();
new MutationObserver(() => { if (reduziert() !== warReduziert) { warReduziert = reduziert(); aufbauen(); } }).observe(html, { attributes: true, attributeFilter: ['class'] });
/* ---------- Vorführung: ruhiger Fluss, dazwischen ein schneller Schub ---------- */
let vorf = false, uhr = null;
function stopp() { vorf = false; auto = 0; clearTimeout(uhr); }
function takt() {
if (!vorf) return;
schwung = 26 + Math.random() * 10; // wie ein kräftiger Rad-Schub: Spalten laufen elastisch auseinander
uhr = setTimeout(takt, 3600);
}
function vorfuehren() {
stopp(); if (offen) schliessen();
html.classList.add('vorfuehrung');
vorf = true; auto = 1.1; // Grundfluss ca. 66 px/s
uhr = setTimeout(takt, 900);
}
function pause() { const war = vorf; stopp(); vorf = war ? 'pause' : false; }
function weiter() { if (vorf === 'pause') vorfuehren(); }
function neustart() { stopp(); html.classList.remove('vorfuehrung'); if (offen) schliessen(); ziel = 0; schwung = 0; zeichnen(true); }
setzeModus('spalten');
})();
</script>
</body>
</html>