Stapelkarten beim Scrollen (Stacking Cards)
Projektkarten docken beim Scrollen nacheinander an und legen sich übereinander; die verdeckten werden kleiner und dunkler, kippen weg oder bleiben als Register stehen.
Mehrere große Karten (Projekte, Leistungen) sind position: sticky. Beim Scrollen schiebt sich jede Karte über die vorige. Die verdeckten Karten werden dabei leicht kleiner und oft dunkler, so entsteht ein Stapel mit Tiefe. Ein kleiner Versatz im top-Wert pro Karte lässt die Kanten der unteren Karten sichtbar.
Die physische Metapher ist sofort klar: Karten werden abgelegt. Jede Karte bekommt kurz die ganze Bühne, der wachsende Stapel zeigt den Fortschritt. Die Mechanik ist einfach, die Wirkung groß.
Wann einsetzen
Für 3 bis 6 gleichrangige Blöcke: Leistungen, ausgewählte Projekte, Werte, Prozessschritte.
Wann nicht
Bei Karten mit viel oder wechselnd langem Text, denn Inhalt wird verdeckt. Bei sehr vielen Einträgen und bei Karten, die höher als der Viewport sind. Nicht als Allzweck-Effekt auf jeder Seite: 2026 ist er in Templates sehr verbreitet und wirkt schnell generisch.
Varianten
- Herunterskalieren der verdeckten Karten (Standard), z. B. 1 − 0,05 je Ebene
- Abdunkeln der verdeckten Karten, z. B. Helligkeit 0,75 hoch Tiefe (Huge)
- Kippen nach hinten: verdeckte Karte geht auf scale 0,7 und rotateX 40°, dazu leichte Drehung ±5° (Pieter Koopt)
- Kippen mit Verblassen: scale 0,8 und rotateX/Y bis 15°, opacity sinkt mit (Sunmetalon)
- Flacher Stapel mit Versatz wie Karteikarten, begrenzt auf 3 sichtbare Ebenen (Pika)
- Kurzes Zusammendrücken mit elastischem Rückfedern beim Andocken (Huge)
- Bild in der ankommenden Karte zoomt von 2× auf 1× (Olivier Larose)
- Reines CSS mit view-timeline und exit-crossing-Ranges
- Horizontal stapelnde Karten
Umsetzung
Struktur: .stapel > .karte (position: sticky; top: calc(Basis + var(--i) × 25px); feste Höhe) > .flaeche. Animiert wird nur das innere Element, damit die Layouthöhe gleich bleibt. Kein overflow: hidden auf Eltern, sonst greift sticky nicht. transform-origin: top center wirkt natürlich.
JS: Pro Karte a = clamp(1 − (Abstand zur Sticky-Position ÷ Kartenhöhe), 0, 1). Die Tiefe einer Karte ist die Summe von a aller folgenden Karten. Daraus scale = 1 − 0,05 × Tiefe und Abdunkeln 1 − 0,75^Tiefe. Berechnung im passiven scroll-Listener mit requestAnimationFrame. Alternativ GSAP ScrollTrigger mit scrub.
Nur CSS: #cards { view-timeline-name: --cards } und .card__inner { animation: scale linear forwards; animation-timeline: --cards; animation-range: exit-crossing calc(var(--i0) / var(--n) × 100%) exit-crossing calc(var(--i) / var(--n) × 100%) }. Schrittweite aus der Kartenanzahl ableiten, feste 0,25-Schritte brechen ab 5 Karten.
Robust umsetzen
Telefon. Funktioniert mit sticky auch auf Touch. Karten nicht höher als etwa 80svh, Versatz kleiner, Skalierung schwächer. Bild über den Text stellen und Hochformat-Bilder nutzen.
Ohne Bewegung. Nur die sticky-Überlagerung behalten, ohne Skalieren, Kippen, Abdunkeln und Rückfedern. Alternativ als normale Liste untereinander.
Barrierefrei. Verdeckte Karten bleiben im DOM und fokussierbar. Bekommt ein Element in einer verdeckten Karte den Fokus, muss die Seite zu dieser Karte scrollen (scroll-margin-top setzen oder per focusin zur Andock-Position springen). Kartenüberschriften als echte Überschriften auszeichnen.
Code
<!DOCTYPE html>
<!--
Stapelkarten beim Scrollen (Stacking Cards)
Fundus · Scroll & Section-Animationen · https://cecodesign.de/lab/fundus/scroll/stapelkarten/
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>Stapelkarten beim Scrollen · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
--grund: #121110; --text: #f1eee7; --leise: rgba(241, 238, 231, .56); --linie: rgba(241, 238, 231, .14);
--rand: clamp(16px, 3.4vw, 44px);
--kopf: 60px; /* feste Kopfleiste */
--fuss: 44px; /* Werte-Zeile unten */
--luft: 24svh; /* Scrollweg, in dem eine Karte allein auf der Bühne liegt */
--staerke: 1; /* Skalierung schwächer auf Touch */
/* Variante „Ablegen“ (Standard) */
--basis: calc(var(--kopf) + 2svh); --versatz: 25px;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.serif { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
/* Kopfleiste */
.kopf { position: fixed; inset: 0 0 auto; height: var(--kopf); z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 var(--rand); background: linear-gradient(var(--grund) 55%, transparent); }
.kopf__marke { color: var(--text); display: flex; gap: 14px; align-items: baseline; white-space: nowrap; }
.kopf__marke span { color: var(--leise); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: rgba(18, 17, 16, .6); border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color 150ms, background 150ms, border-color 150ms; }
.modi button:hover { color: var(--text); border-color: rgba(241, 238, 231, .4); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible, .karte a:focus-visible { outline: 2px solid #e8a15c; outline-offset: 3px; }
/* Werte-Zeile */
.werte { position: fixed; inset: auto 0 0; height: var(--fuss); z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 var(--rand); color: var(--leise); background: linear-gradient(transparent, var(--grund) 45%); pointer-events: none; }
.werte b { color: var(--text); font-weight: 500; }
.werte__info { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.werte__zahl { white-space: nowrap; color: var(--text); }
/* Einleitung */
.intro { min-height: 64svh; display: grid; align-content: end; gap: 28px; padding: calc(var(--kopf) + 6svh) var(--rand) 8svh; }
.intro h1 { font-size: clamp(42px, 7.4vw, 116px); font-weight: 600; letter-spacing: -.045em; line-height: .94; max-width: 13ch; }
.intro__kopf { display: flex; justify-content: space-between; align-items: flex-end; gap: 32px; }
.index { list-style: none; padding: 0; display: grid; gap: 0; min-width: 250px; color: var(--leise); }
.index li { display: flex; gap: 18px; padding: 9px 0; border-top: 1px solid var(--linie); }
.index li:last-child { border-bottom: 1px solid var(--linie); }
.index span { color: var(--text); }
.intro h1 .serif { font-size: 1.08em; letter-spacing: -.02em; }
.intro__zeile { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; color: var(--leise); }
.intro__zeile p:first-child { max-width: 46ch; font: 400 clamp(15px, 1.25vw, 18px)/1.5 'Instrument Sans', sans-serif; letter-spacing: 0; text-transform: none; }
/* Der Stapel */
.stapel { padding: 0 var(--rand); }
.karte {
--top: calc(var(--basis) + var(--i) * var(--versatz));
--hoehe: min(660px, calc(100svh - var(--basis) - (var(--n) - 1) * var(--versatz) - var(--fuss) - 8px));
position: sticky; top: var(--top); height: var(--hoehe); margin: 0 auto var(--luft); max-width: 1240px;
scroll-margin-top: var(--top);
}
.karte:last-of-type { margin-bottom: 0; }
.halt { height: 34svh; } /* fertiger Stapel bleibt kurz liegen */
.flaeche {
position: relative; height: 100%; border-radius: 18px; overflow: hidden; background: var(--k-grund); color: var(--k-text);
display: grid; grid-template-rows: auto 1fr; transform-origin: 50% 0%; will-change: transform;
box-shadow: 0 -1px 0 rgba(255, 255, 255, .06) inset, 0 -18px 50px -20px rgba(0, 0, 0, .55);
}
.flaeche::after { content: ''; position: absolute; inset: 0; background: #000; opacity: var(--dim, 0); pointer-events: none; }
.reiter { height: 48px; display: grid; grid-template-columns: 56px 1fr auto; align-items: center; gap: 12px; padding: 0 22px; border-bottom: 1px solid var(--k-linie); }
.reiter span:nth-child(2) { color: var(--k-text); }
.reiter span { color: var(--k-leise); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.inhalt { display: grid; grid-template-columns: 1.45fr 1fr; gap: clamp(16px, 2.4vw, 36px); padding: clamp(14px, 1.8vw, 22px); min-height: 0; }
.bild { border-radius: 10px; overflow: hidden; min-height: 0; background: var(--k-bild); }
.bild picture { display: block; height: 100%; }
.bild img { display: block; width: 100%; height: 100%; object-fit: cover; transform-origin: 50% 50%; }
/* Karte 1: Hochformat-Motiv (4:5) in jeder Breite. Ausschnitt so, dass die Bildmitte bei 40 % der Bildhöhe ruht
(Hochformat ist 125cqw hoch, Motiv in der Bildmitte), begrenzt auf 0 bis volle Deckung. Zoom-Ursprung unten:
bei Bild 1,3 rückt das Motiv nach oben statt aus dem Rahmen und ist schon in der angeschnittenen Startansicht sichtbar. */
.k1 .bild { container-type: size; }
.k1 .bild img { object-position: 50% clamp(100cqh - 125cqw, 40cqh - 62.5cqw, 0px); transform-origin: 50% 100%; }
.text { display: flex; flex-direction: column; justify-content: space-between; gap: 18px; padding: 6px 4px 4px 0; min-height: 0; }
.text h2 { font-size: clamp(30px, 3.5vw, 54px); font-weight: 600; letter-spacing: -.035em; line-height: .98; }
.text h2 .serif { display: block; font-size: 1.04em; color: var(--k-akzent); }
.text p { font-size: clamp(15px, 1.15vw, 17px); line-height: 1.5; max-width: 34ch; color: var(--k-text); opacity: .82; }
.text ul { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
.text li { border: 1px solid var(--k-linie); border-radius: 999px; padding: 6px 10px; color: var(--k-leise); }
.text a { align-self: flex-start; color: var(--k-text); text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 2px; font-weight: 500; }
@media (hover: hover) { .text a:hover { color: var(--k-akzent); } }
.k1 { --k-grund: #efe9df; --k-text: #1c1915; --k-leise: rgba(28, 25, 21, .66); --k-linie: rgba(28, 25, 21, .14); --k-akzent: #c8642c; --k-bild: #ec8a52; }
.k2 { --k-grund: #26231f; --k-text: #f1ece3; --k-leise: rgba(241, 236, 227, .56); --k-linie: rgba(241, 236, 227, .14); --k-akzent: #e8a99b; --k-bild: #1e1e21; }
.k3 { --k-grund: #ecdcc9; --k-text: #2a1610; --k-leise: rgba(42, 22, 16, .68); --k-linie: rgba(42, 22, 16, .16); --k-akzent: #c2402a; --k-bild: #cf462d; }
.k4 { --k-grund: #2d2017; --k-text: #f5ead0; --k-leise: rgba(245, 234, 208, .58); --k-linie: rgba(245, 234, 208, .15); --k-akzent: #ecc76a; --k-bild: #ebc96c; }
.k5 { --k-grund: #17181e; --k-text: #eef0f7; --k-leise: rgba(238, 240, 247, .55); --k-linie: rgba(238, 240, 247, .14); --k-akzent: #e39a4e; --k-bild: #1f3fd1; }
/* Variante „Kippen“: alle Karten an derselben Stelle, verdeckte kippt nach hinten weg */
.v-kippen { --basis: calc(var(--kopf) + 4svh); --versatz: 0px; }
.v-kippen .flaeche { transform-origin: 50% 30%; }
/* Variante „Karteikasten“: Reiter bleiben als Register sichtbar */
.v-kartei { --basis: calc(var(--kopf) + 1svh); --versatz: 48px; }
/* Ausklang */
.ende { min-height: 62svh; display: grid; align-content: center; justify-items: start; gap: 22px; padding: 12svh var(--rand) calc(var(--fuss) + 10svh); max-width: 1240px; margin: 0 auto; }
.ende h2 { font-size: clamp(34px, 5vw, 76px); font-weight: 600; letter-spacing: -.04em; line-height: 1; max-width: 16ch; }
.ende p { color: var(--leise); }
/* Reduzierte Bewegung: nur die sticky-Überlagerung, ohne Skalieren, Kippen, Abdunkeln */
.reduziert .flaeche { transform: none !important; opacity: 1 !important; will-change: auto; }
.reduziert .flaeche::after { opacity: 0 !important; }
.reduziert .bild img { transform: none !important; }
.vorschau body { overflow: hidden; }
.vorschau .intro__zeile { display: none; }
.vorschau .werte { display: none; } /* Nebensache: lag in der Vorschau über Text und Schlagworten der Karte */
/* Touch: schwächere Skalierung (JS liest --staerke) */
@media (hover: none) { :root { --staerke: .6; } }
@media (max-width: 760px) {
:root { --kopf: 96px; --fuss: 40px; --luft: 18svh; --versatz: 14px; --basis: calc(var(--kopf) + 1svh); }
.v-kartei { --versatz: 40px; }
.index { display: none; }
.kopf { flex-direction: column; align-items: flex-start; justify-content: center; gap: 10px; }
.modi button { padding: 0 10px; height: 28px; }
.karte { --hoehe: min(80svh, calc(100svh - var(--basis) - (var(--n) - 1) * var(--versatz) - var(--fuss) - 6px)); }
.reiter { height: 40px; grid-template-columns: 32px 1fr auto; padding: 0 14px; gap: 8px; }
.reiter span:last-child { display: none; }
.inhalt { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; gap: 14px; padding: 10px; }
.text { padding: 0 6px 6px; gap: 10px; }
.text h2 { font-size: 28px; }
.text p { font-size: 14.5px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.text ul { display: none; }
.werte__info { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.flaeche { border-radius: 14px; }
}
/* 320 px: Beschreibung vollständig, das Bild gibt Höhe ab */
@media (max-width: 380px) { .text p { display: block; -webkit-line-clamp: unset; overflow: visible; } }
@media (max-height: 560px) { .text p { display: none; } }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<p class="mono kopf__marke">Büro Hellwig <span>Ausgewählte Arbeiten</span></p>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-variante="ablegen" aria-pressed="true">Ablegen</button>
<button type="button" data-variante="kippen" aria-pressed="false">Kippen</button>
<button type="button" data-variante="kartei" aria-pressed="false">Karteikasten</button>
</div>
</header>
<section class="intro">
<div class="intro__kopf">
<h1>Fünf Arbeiten, sauber <span class="serif">abgelegt.</span></h1>
<ol class="mono index" aria-label="Inhalt">
<li><span>01</span>Töpferei Lehmrot</li><li><span>02</span>Eselsohr</li><li><span>03</span>Röstkontor</li><li><span>04</span>Krume</li><li><span>05</span>Ocker 04</li>
</ol>
</div>
<div class="intro__zeile">
<p class="mono">Erscheinungsbilder für Werkstätten, Läden und Cafés. Jede Arbeit bekommt einen Moment lang die ganze Fläche, dann legt sich die nächste darauf.</p>
<p class="mono">Scrollen ↓</p>
</div>
</section>
<main class="stapel" style="--n: 5" aria-label="Arbeiten">
<article class="karte k1" style="--i: 0">
<div class="flaeche">
<div class="reiter mono"><span>01</span><span>Töpferei Lehmrot</span><span>Keramikwerkstatt</span></div>
<div class="inhalt">
<div class="bild"><picture><img src="bilder/keramik-stillleben-hoch.jpg" alt="Keramik-Stillleben: helle Flasche, schwarze Kugelvase, gelber Becher und Schalen auf Sockeln vor Terrakotta" width="800" height="1000"></picture></div>
<div class="text">
<h2>Ton und Glasur, <span class="serif">ganz in Ruhe.</span></h2>
<p>Ein Erscheinungsbild in Terrakotta und Kalkweiß. Die Fotos zeigen die Stücke wie Skulpturen, die Schrift tritt einen Schritt zurück.</p>
<ul class="mono"><li>Logo</li><li>Fotografie</li><li>Website</li></ul>
<a href="#ende" class="mono">Zum Projekt</a>
</div>
</div>
</div>
</article>
<article class="karte k2" style="--i: 1">
<div class="flaeche">
<div class="reiter mono"><span>02</span><span>Eselsohr</span><span>Buchhandlung & Lesecafé</span></div>
<div class="inhalt">
<div class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/marke-eselsohr-hoch.jpg"><img src="bilder/marke-eselsohr.jpg" alt="Wortbildmarke Eselsohr: Blatt mit rosa Eselsohr neben kursiver Schrift auf Anthrazit" width="1200" height="800"></picture></div>
<div class="text">
<h2>Eine Ecke, <span class="serif">umgeknickt.</span></h2>
<p>Ein Blatt mit Eselsohr und eine schmale Kursive. Rosa auf Anthrazit, damit das Zeichen auf Tüte, Lesezeichen und Schaufenster hält.</p>
<ul class="mono"><li>Wortbildmarke</li><li>Beschilderung</li><li>Lesezeichen</li></ul>
<a href="#ende" class="mono">Zum Projekt</a>
</div>
</div>
</div>
</article>
<article class="karte k3" style="--i: 2">
<div class="flaeche">
<div class="reiter mono"><span>03</span><span>Röstkontor</span><span>Kaffeerösterei</span></div>
<div class="inhalt">
<div class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/marke-roestkontor-hoch.jpg"><img src="bilder/marke-roestkontor.jpg" alt="Wortbildmarke Röstkontor: Kaffeebohne im Kreis über cremefarbenen Versalien auf Ziegelrot" width="1200" height="800"></picture></div>
<div class="text">
<h2>Eine Bohne, <span class="serif">klar geröstet.</span></h2>
<p>Die Bohne im Kreis, darunter breite Versalien in Creme auf Ziegelrot. Das Zeichen hält auf der Tüte genauso wie auf dem Röster.</p>
<ul class="mono"><li>Logo</li><li>Verpackung</li><li>Social Media</li></ul>
<a href="#ende" class="mono">Zum Projekt</a>
</div>
</div>
</div>
</article>
<article class="karte k4" style="--i: 3">
<div class="flaeche">
<div class="reiter mono"><span>04</span><span>Krume</span><span>Backstube</span></div>
<div class="inhalt">
<div class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/marke-krume-hoch.jpg"><img src="bilder/marke-krume.jpg" alt="Wortmarke krume: fette dunkelbraune Kleinbuchstaben und ein Brotlaib auf Sonnengelb" width="1200" height="800"></picture></div>
<div class="text">
<h2>Fett gesetzt, <span class="serif">warm serviert.</span></h2>
<p>Runde, schwere Kleinbuchstaben und ein Laib als Zeichen. Ein Auftritt zwischen Backstube und Café, von der Brottüte bis zur Tafel.</p>
<ul class="mono"><li>Wortmarke</li><li>Print</li><li>Tafeln</li></ul>
<a href="#ende" class="mono">Zum Projekt</a>
</div>
</div>
</div>
</article>
<article class="karte k5" style="--i: 4">
<div class="flaeche">
<div class="reiter mono"><span>05</span><span>Ocker 04</span><span>Duftlabor</span></div>
<div class="inhalt">
<div class="bild"><picture><source media="(max-width: 760px)" srcset="bilder/flakon-hoch.jpg"><img src="bilder/flakon.jpg" alt="Bernsteinfarbener Parfumflakon auf weißem Podest vor kräftigem Kobaltblau" width="1200" height="800"></picture></div>
<div class="text">
<h2>Bernstein <span class="serif">vor Blau.</span></h2>
<p>Ein Flakon auf hellem Podest, davor kräftiges Kobaltblau. Das Motiv trägt Etikett, Schachtel und Schaufenster.</p>
<ul class="mono"><li>Packaging</li><li>Key Visual</li><li>Leitfaden</li></ul>
<a href="#ende" class="mono">Zum Projekt</a>
</div>
</div>
</div>
</article>
<div class="halt" aria-hidden="true"></div>
</main>
<section class="ende" id="ende">
<p class="mono">Büro Hellwig · Grafikdesign</p>
<h2>Alle Arbeiten liegen im <span class="serif">Archiv.</span></h2>
<p class="mono">Anfragen: hallo@buero-hellwig.example</p>
</section>
<footer class="werte mono" aria-live="off">
<span class="werte__info" data-werte></span>
<span class="werte__zahl" data-zahl>00 / 05</span>
</footer>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const stapel = document.querySelector('.stapel');
const karten = [...document.querySelectorAll('.karte')];
const flaechen = karten.map(k => k.querySelector('.flaeche'));
const bilder = karten.map(k => k.querySelector('.bild img'));
const n = karten.length;
const klemme = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const WINKEL_Z = [-4, 3, -5, 2, 0]; // feste „zufällige“ rotationZ je Karte (±5°)
// elastic.out(1, 0.3) als CSS linear() für das Rückfedern nach dem Andocken
const elastisch = (() => {
const s = 0.3 / (2 * Math.PI) * Math.asin(1);
const f = p => p === 0 ? 0 : p === 1 ? 1 : Math.pow(2, -10 * p) * Math.sin((p - s) * 2 * Math.PI / 0.3) + 1;
const pkt = []; for (let k = 0; k <= 60; k++) pkt.push(f(k / 60).toFixed(4));
const lin = 'linear(' + pkt.join(', ') + ')';
return CSS.supports('animation-timing-function', lin) ? lin : 'cubic-bezier(0.16, 1, 0.3, 1)';
})();
const POWER1_OUT = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
const VARIANTEN = {
ablegen: '<b>Ablegen</b> · top +25px · scale 1 − 0,05·d · Helligkeit 0,75^d · Bild 1,3 → 1 · Andocken: Squash 100 ms, elastic.out(1, 0.3) 1000 ms',
kippen: '<b>Kippen</b> · power1.in · scale 0,7 · rotateX 40° · rotateZ ±5° · perspective 1200px · ausblenden bei d 1 → 1,2',
kartei: '<b>Karteikasten</b> · top +48px · scale 1 − 0,025·min(d, 3) · −3px je Ebene · ab Tiefe 3 ausblenden'
};
let variante = 'ablegen';
let maß = { tops: [], hoehe: 1, docks: [] };
let vorherA = new Array(n).fill(0);
function messen() {
// natürliche Lage der Karten (ohne sticky) messen, daraus die Andock-Scrollpositionen
karten.forEach(k => k.style.position = 'relative');
const natur = karten.map(k => k.getBoundingClientRect().top + scrollY);
karten.forEach(k => k.style.position = '');
maß.tops = karten.map(k => parseFloat(getComputedStyle(k).top) || 0);
maß.hoehe = karten[0].offsetHeight || 1;
maß.docks = natur.map((y, i) => y - maß.tops[i]);
}
// Tastatur: Fokus in einer Karte holt sie nach oben (angedockt, noch nicht von der nächsten verdeckt)
stapel.addEventListener('focusin', e => {
const i = karten.findIndex(kt => kt.contains(e.target));
if (i >= 0 && e.target.matches(':focus-visible') && maß.docks[i] != null) scrollTo({ top: maß.docks[i], behavior: 'instant' });
});
function staerke() { return parseFloat(getComputedStyle(html).getPropertyValue('--staerke')) || 1; }
let k = 1;
function zeichnen() {
// a[j]: wie weit Karte j schon über der vorigen liegt (0 = noch eine Kartenhöhe entfernt, 1 = angedockt)
const a = karten.map((kt, j) => klemme(1 - (kt.getBoundingClientRect().top - maß.tops[j]) / maß.hoehe));
let angedockt = 0;
a.forEach((w, j) => {
if (w > 0.999) angedockt = j + 1;
if (w > 0.999 && vorherA[j] <= 0.999 && variante === 'ablegen' && !reduziert() && j > 0) quetschen(j);
});
vorherA = a;
document.querySelector('[data-zahl]').textContent = String(angedockt).padStart(2, '0') + ' / ' + String(n).padStart(2, '0');
if (reduziert()) return;
for (let i = 0; i < n; i++) {
let d = 0; for (let j = i + 1; j < n; j++) d += a[j]; // Tiefe: so viele Karten liegen (anteilig) darüber
const f = flaechen[i], img = bilder[i];
if (variante === 'ablegen') {
f.style.transform = `scale(${(1 - 0.05 * k * d).toFixed(4)})`;
f.style.setProperty('--dim', (1 - Math.pow(0.75, d)).toFixed(3));
f.style.opacity = 1;
img.style.transform = `scale(${(1.3 - 0.3 * a[i]).toFixed(4)})`;
} else if (variante === 'kippen') {
const e = Math.pow(i + 1 < n ? a[i + 1] : 0, 2); // power1.in
f.style.transform = `perspective(1200px) rotateX(${(40 * e).toFixed(2)}deg) rotateZ(${(WINKEL_Z[i] * e).toFixed(2)}deg) scale(${(1 - 0.3 * k * e).toFixed(4)})`;
f.style.setProperty('--dim', (0.25 * e).toFixed(3));
f.style.opacity = (1 - klemme((d - 1) / 0.2)).toFixed(3);
img.style.transform = '';
} else {
const c = Math.min(d, 3);
f.style.transform = `translateY(${(-3 * c).toFixed(2)}px) scale(${(1 - 0.025 * k * c).toFixed(4)})`;
f.style.setProperty('--dim', (0.06 * c).toFixed(3));
f.style.opacity = d > 3 ? klemme(4 - d).toFixed(3) : 1;
img.style.transform = '';
}
}
}
// Huge: beim Andocken kurz breiter und flacher, dann elastisch zurück
function quetschen(j) {
const f = flaechen[j], w = f.offsetWidth, h = f.offsetHeight, em = 16;
f.animate([
{ scale: '1 1', easing: POWER1_OUT },
{ scale: `${((w + 1.5 * em) / w).toFixed(4)} ${((h - 0.5 * em) / h).toFixed(4)}`, offset: 100 / 1100, easing: elastisch },
{ scale: '1 1' }
], { duration: 1100 });
}
let geplant = false;
function anstossen() { if (!geplant) { geplant = true; requestAnimationFrame(() => { geplant = false; zeichnen(); }); } }
addEventListener('scroll', anstossen, { passive: true });
addEventListener('resize', () => { k = staerke(); messen(); zeichnen(); });
function setzen(neu) {
variante = neu;
['ablegen', 'kippen', 'kartei'].forEach(v => html.classList.toggle('v-' + v, v === variante));
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
document.querySelector('[data-werte]').innerHTML = reduziert() ? '<b>Reduziert</b> · nur sticky-Überlagerung, ohne Skalieren und Abdunkeln' : VARIANTEN[variante];
flaechen.forEach(f => { f.style.transform = ''; f.style.opacity = ''; f.style.removeProperty('--dim'); });
k = staerke(); messen(); vorherA = new Array(n).fill(1); zeichnen();
}
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => {
const vorher = scrollY; pause(); setzen(b.dataset.variante); scrollTo(0, vorher);
}));
// Fokus in einer verdeckten Karte: Seite zur Andock-Position dieser Karte scrollen
karten.forEach((kt, i) => kt.addEventListener('focusin', () => {
if (uhr) return;
scrollTo({ top: maß.docks[i], behavior: reduziert() ? 'auto' : 'smooth' });
}));
// Vorführung: von Andock-Punkt zu Andock-Punkt scrollen, kurz halten, wieder oben anfangen
let uhr = null, t0 = 0, runde = 0;
const FAHRT = 1300, HALT = 650;
const sanft = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
function vorfuehren() {
pause(); messen();
const vorschau = html.classList.contains('vorschau');
const punkte = [maß.docks[0] - innerHeight * 0.45, ...maß.docks, maß.docks[n - 1] + innerHeight * 0.12];
const takt = FAHRT + HALT, gesamt = (punkte.length - 1) * takt + 900;
scrollTo(0, punkte[0]); vorherA = new Array(n).fill(0); zeichnen();
t0 = performance.now(); let letzte = -1;
const schritt = t => {
const z = (t - t0) % gesamt, nr = Math.floor((t - t0) / gesamt);
if (nr !== letzte) {
if (letzte >= 0 && vorschau) setzen(['ablegen', 'kippen', 'kartei'][(++runde) % 3]);
letzte = nr; scrollTo(0, punkte[0]); vorherA = new Array(n).fill(0);
}
const seg = Math.min(punkte.length - 2, Math.floor(z / takt));
const lokal = klemme(((z - seg * takt)) / FAHRT);
const p = reduziert() ? (lokal > 0 ? 1 : 0) : sanft(lokal);
scrollTo(0, punkte[seg] + (punkte[seg + 1] - punkte[seg]) * p);
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; }
function neustart() { pause(); setzen(variante); scrollTo(0, 0); }
setzen(variante);
addEventListener('load', () => setzen(variante));
})();
</script>
</body>
</html>