Karten-Stapel und Fächer zum Wegschnippen
Probelektion einer Würzburger Weinschule als Lernkarten: oberste Karte nach rechts wischen heißt gemerkt, sie landet federnd hinten im Stapel; als Fächer mit mittiger Karte.
Karten liegen absolut übereinander, leicht abwechselnd gedreht (bis ±4°), oder als Fächer: Nachbarn um 25 % bzw. 45 % versetzt, um 10° bzw. 15° gedreht und auf 0,9 bzw. 0,8 verkleinert. Nur die oberste oder mittlere Karte ist ziehbar. Beim Ziehen mischen alle Karten ihre Lage zwischen jetziger und nächster Position, proportional zum Zug. Unter 10 % der Breite federt die Karte zurück; darüber fliegt sie weg und wandert ans Ende des Stapels. Die nächste Karte springt mit elastischer Feder an ihren Platz. Knöpfe lösen dieselbe Bewegung aus.
Die Geste kennt jeder aus Apps. Sie ist körperlich und spielerisch. Die elastische Landung gibt Charakter, solange sie kurz bleibt.
Wann einsetzen
Lernkarten, Testimonials, Team, Produktvarianten, Moodboards und Feature-Karten auf verspielten Marken-, Kurs- und Agenturseiten.
Wann nicht
Wenn viele Elemente verglichen werden müssen. In seriösen B2B-Kontexten ohne verspielte Markenpersönlichkeit.
Varianten
- Gedrehter Stapel: oberste Karte wegwerfen, sie wandert nach hinten (rechts/links mit eigener Bedeutung, z. B. gemerkt/nochmal)
- Fächer mit mittiger aktiver Karte
- Fallender Kartenstapel beim Scrollen
- Radialer Fächer oder Bogen
Umsetzung
Eine Konfigurationstabelle je relativer Position statt Einzellogik: offset = (i − aktiv + n) % n, auf −n/2 bis n/2 gefaltet; Zwischenwerte linear mischen. Während des Ziehens gsap.set mit den gemischten Werten, nach dem Loslassen gsap.to mit elastic.out. Draggable auf einem unsichtbaren Proxy, Auslöser ist die Bühne. Bewegung unter 4 px gilt als Klick. Fallstricke: Kartenbreite bei Resize neu messen. Elastische Kurven nie auf opacity anwenden; Deckkraft in einem eigenen Tween. Mindestens 7 Karten für den Fächer.
Robust umsetzen
Telefon. Sehr gut für Touch. Karten fast vollbreit. Senkrechtes Scrollen nicht blockieren (type x, allowNativeTouchScrolling oder lockAxis).
Ohne Bewegung. Keine elastische Kurve, sondern 200 ms ease-out ohne Rotation. Die Stapelneigung bleibt statisch.
Barrierefrei. Knöpfe für Vor und Zurück zusätzlich zur Geste, Pfeiltasten auf der fokussierbaren Bühne. Nur die oberste Karte ist erreichbar, die darunter sind inert. Eine Live-Region meldet die neue Karte.
Code
<!DOCTYPE html>
<!--
Karten-Stapel und Fächer zum Wegschnippen
Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/flick-karten-stapel/
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>Karten-Stapel und Fächer zum Wegschnippen · 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: 'Bricolage Grotesque'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bricolage-grotesque:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Onest'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/onest:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Shantell Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/shantell-sans:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 300 800; 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: #f4f6fa; --karte: #ffffff; --tinte: #141414; --leise: #5d6170; --linie: rgba(20, 20, 20, .1);
--marke: #7491ff; --marke-tief: #4561d8; --erfolg: #10bf7a; --gelb: #f7c325;
--kurve: cubic-bezier(0.22, 1, 0.36, 1); /* easeOutQuint, Basis */
--feder: cubic-bezier(0.34, 1.56, 0.64, 1); /* nur für Erfolgsmomente */
--schatten-kurve: cubic-bezier(0.625, 0.05, 0, 1);
--rand: clamp(20px, 4vw, 56px);
--kb: clamp(248px, min(26vw, 45svh), 350px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Onest', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: hidden; display: grid; grid-template-rows: auto 1fr auto; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; cursor: pointer; }
.notiz { font-family: 'Shantell Sans', 'Onest', sans-serif; font-variation-settings: 'INFM' 45, 'BNCE' 0; font-weight: 500; }
.mono { font: 500 10.5px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* Kopf */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px var(--rand); }
.marke { display: flex; align-items: center; gap: 10px; font: 700 19px/1 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 24; letter-spacing: -.02em; }
.marke svg { width: 28px; height: 28px; }
.navi { display: flex; gap: 26px; font-size: 15px; color: var(--leise); }
.navi a[aria-current] { color: var(--tinte); font-weight: 560; }
.cta { display: inline-flex; align-items: center; height: 42px; padding: 0 18px; border-radius: 12px; background: var(--marke-tief); color: #fff; font-weight: 600; font-size: 15px; transition: background-color 150ms var(--kurve), scale 150ms var(--kurve); }
.cta:hover { background: #3a54c9; }
.cta:active { scale: .97; }
/* Inhalt: links Kurs, rechts Karten */
.lektion { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); align-items: center; gap: clamp(24px, 4vw, 64px); padding: clamp(12px, 2.6svh, 32px) var(--rand) clamp(16px, 3svh, 36px); }
.text { display: grid; gap: clamp(14px, 2.2svh, 22px); max-width: 34rem; }
.text .notiz { color: var(--marke-tief); font-size: 15px; }
.text h1 { font: 700 clamp(2.25rem, 1.4rem + 3vw, 4.25rem)/1.03 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 96; letter-spacing: -.025em; text-wrap: balance; }
.text h1 .mark { background: linear-gradient(transparent 62%, rgba(247, 195, 37, .6) 62% 92%, transparent 92%); padding: 0 .06em; }
.text > p { font-size: 1.0625rem; line-height: 1.6; color: var(--leise); max-width: 46ch; }
.fakten { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; }
.fakten li { font-size: 14px; padding: 7px 12px; border-radius: 999px; background: #fff; border: 1px solid var(--linie); }
.fakten li b { font-weight: 600; }
.fortschritt { display: grid; gap: 10px; padding: 16px 18px; border-radius: 16px; background: #fff; border: 1px solid var(--linie); max-width: 26rem; }
.fortschritt .zeile { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: 15px; }
.fortschritt .zahl { display: inline-block; font: 700 22px/1 'Bricolage Grotesque', sans-serif; font-variant-numeric: tabular-nums; }
.fortschritt .zahl.hops { animation: hops 500ms var(--feder); }
@keyframes hops { from { scale: 1.45; color: var(--erfolg); } to { scale: 1; } }
.balken { height: 8px; border-radius: 99px; background: #e8ebf2; overflow: hidden; }
.balken i { display: block; height: 100%; width: 0; border-radius: inherit; background: var(--erfolg); transition: width 300ms var(--kurve); }
.fortschritt .geschafft { color: var(--erfolg); font-size: 14px; }
/* Bühne mit Karten */
.buehne-wrap { display: grid; justify-items: center; gap: 18px; }
.buehne { position: relative; width: 100%; height: calc(var(--kb) * 1.27 + 70px); touch-action: pan-y; outline: none; user-select: none; -webkit-user-select: none; }
.buehne:focus-visible::after { content: ''; position: absolute; inset: -4px 10%; border: 2px solid var(--marke); border-radius: 26px; pointer-events: none; }
.karte {
position: absolute; left: 50%; top: 18px; width: var(--kb); height: calc(var(--kb) * 1.27); margin-left: calc(var(--kb) / -2);
display: grid; grid-template-rows: minmax(0, 1fr) auto auto auto; gap: 8px; padding: 14px 14px 20px; border-radius: 22px; background: var(--karte);
box-shadow: 0 1px 0 rgba(20, 20, 20, .04), 0 12px 28px -14px rgba(31, 41, 80, .28); transition: box-shadow 250ms var(--schatten-kurve);
will-change: transform; cursor: grab;
}
.karte.gepackt { cursor: grabbing; box-shadow: 0 1px 0 rgba(20, 20, 20, .04), 0 30px 50px -18px rgba(31, 41, 80, .38); }
.bildfeld { min-height: 0; border-radius: 14px; background: var(--ton, #e3e9ff); display: grid; place-items: center; overflow: hidden; }
.bildfeld svg { width: 82%; height: 86%; }
.karte .notiz { font-size: 13.5px; color: var(--marke-tief); padding: 6px 4px 0; }
.karte h3 { font: 700 clamp(1.5rem, 1rem + 1.2vw, 1.9rem)/1.05 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 48; letter-spacing: -.02em; padding: 0 4px; }
.karte .erkl { font-size: 15px; line-height: 1.5; min-height: 6em; color: var(--leise); padding: 0 4px; }
.stempel { position: absolute; top: 30px; padding: 6px 12px 7px; border-radius: 10px; border: 2.5px solid currentColor; font-size: 19px; font-weight: 700; opacity: 0; pointer-events: none; background: rgba(255, 255, 255, .92); }
.stempel.gemerkt { left: 26px; color: var(--erfolg); rotate: -9deg; }
.stempel.nochmal { right: 26px; color: #c99500; rotate: 8deg; }
.steuer { display: flex; align-items: center; gap: 10px; }
.knopf { height: 46px; padding: 0 18px; border-radius: 14px; border: 1px solid var(--linie); background: #fff; display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 560; transition: background-color 150ms var(--kurve), border-color 150ms var(--kurve), scale 150ms var(--kurve); }
.knopf:hover { border-color: rgba(20, 20, 20, .3); }
.knopf:active, .knopf.druck { scale: .96; }
.knopf svg { width: 16px; height: 16px; }
.knopf.vor { background: var(--tinte); color: #fff; border-color: var(--tinte); }
.knopf:focus-visible, .modi button:focus-visible, .cta:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
.hilfe { font-size: 13px; color: var(--leise); }
/* Fuß: Varianten und Werte */
.fuss { display: flex; justify-content: space-between; align-items: center; gap: 12px 28px; flex-wrap: wrap; padding: 14px var(--rand) 18px; border-top: 1px solid var(--linie); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; transition: color 150ms var(--kurve), background-color 150ms var(--kurve); }
.modi button:hover { color: var(--tinte); }
.modi button[aria-pressed=true] { background: var(--tinte); color: #fff; border-color: var(--tinte); }
.werte { max-width: 820px; text-align: right; }
.werte b { color: var(--tinte); font-weight: 500; }
/* Vorführung: gezeichneter Finger */
.finger { position: fixed; left: 0; top: 0; z-index: 30; width: 46px; height: 46px; margin: -23px 0 0 -23px; border-radius: 50%; background: rgba(20, 20, 20, .16); border: 2px solid rgba(20, 20, 20, .5); pointer-events: none; opacity: 0; scale: .8; transition: opacity 200ms var(--kurve), scale 200ms var(--kurve); }
.finger.an { opacity: 1; scale: 1; }
.vorschau body { overflow: hidden; }
.vorschau .fuss, .vorschau .navi, .vorschau .hilfe, .vorschau .fakten { display: none; }
@media (max-width: 1100px) { .navi { display: none; } }
@media (max-width: 860px) {
.lektion { grid-template-columns: 1fr; gap: 18px; }
.text { display: contents; }
.text .notiz { order: 1; } .text h1 { order: 2; } .buehne-wrap { order: 3; } .text > p { order: 4; } .fortschritt { order: 5; } .fakten { order: 6; }
.text { max-width: none; }
.fortschritt { max-width: none; }
:root { --kb: min(76vw, 330px); }
.werte { text-align: left; }
}
@media (max-width: 480px) { .cta { height: 38px; padding: 0 14px; font-size: 14px; } .marke span { display: none; } }
@media (hover: none) { .finger { display: none; } .karte { cursor: default; } }
/* Reduzierte Bewegung: Übergänge per JS ohne Feder und ohne Drehung, hier nur die CSS-Teile */
.reduziert .fortschritt .zahl.hops { animation: none; }
.reduziert .balken i, .reduziert .karte { transition: 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">
<a class="marke" href="#"><svg viewBox="0 0 28 28" aria-hidden="true"><rect width="28" height="28" rx="8" fill="#141414"/><circle cx="10.5" cy="12" r="3" fill="#7491ff"/><circle cx="17.5" cy="12" r="3" fill="#7491ff"/><circle cx="14" cy="18" r="3" fill="#f7c325"/></svg><span>Weinschule Mainschleife</span></a>
<nav class="navi" aria-label="Hauptnavigation"><a href="#">Für wen</a><a href="#" aria-current="page">Lernweg</a><a href="#">Termine</a><a href="#">Preise</a><a href="#">Fragen</a></nav>
<a class="cta" href="#">Platz sichern</a>
</header>
<main class="lektion">
<section class="text" aria-labelledby="titel">
<p class="notiz">Gratis-Probelektion · Modul 02 · 12 min</p>
<h1 id="titel">Frankenwein verstehen, <span class="mark">Karte für Karte.</span></h1>
<p>Acht Karten aus Modul 02, zum Durchwischen. So lernen Sie im Online-Kurs mit Theresa Kleinhenz: kurz, wiederholt und am Ende mit Probe im Glas.</p>
<ul class="fakten">
<li>Start <b>12. Januar 2027</b></li><li><b>6 Wochen</b> online</li><li><b>2 Live-Proben</b> in Würzburg</li><li><b>249 €</b> oder 3 × 85 €</li>
</ul>
<div class="fortschritt">
<div class="zeile"><span data-fortschritt-text>Gemerkt</span><span><span class="zahl" data-zahl>0</span> von 8</span></div>
<div class="balken" aria-hidden="true"><i data-balken></i></div>
<p class="geschafft notiz" data-geschafft hidden>Modul 02 geschafft. Weiter geht’s mit den Lagen am Main.</p>
</div>
</section>
<section class="buehne-wrap" aria-label="Lernkarten Modul 02">
<div class="buehne" data-buehne tabindex="0" aria-roledescription="Kartenstapel" aria-label="Lernkarten. Pfeil rechts: gemerkt, Pfeil links: nochmal.">
<article class="karte" style="--ton:#e3e9ff">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><g stroke="#141414" stroke-width="2" fill="#7491ff"><circle cx="76" cy="44" r="10"/><circle cx="96" cy="44" r="10"/><circle cx="116" cy="44" r="10"/><circle cx="86" cy="61" r="10"/><circle cx="106" cy="61" r="10"/><circle cx="126" cy="61" r="10" fill="#9fb2ff"/><circle cx="96" cy="78" r="10"/><circle cx="116" cy="78" r="10" fill="#9fb2ff"/><circle cx="106" cy="95" r="10"/></g><path d="M96 34c0-10 4-17 12-20" fill="none" stroke="#141414" stroke-width="2"/><path d="M108 16c10-12 32-12 40 2-12 6-28 8-40-2Z" fill="#f7c325" stroke="#141414" stroke-width="2"/><path d="M114 15c10 1 20 2 30 3" stroke="#141414" stroke-width="1.5" fill="none"/></svg></div>
<p class="notiz">Modul 02 · Karte 1</p>
<h3>Silvaner</h3>
<p class="erkl">Die Leitrebsorte Frankens. Erdig, kräuterwürzig, oft mit feiner Mineralität. Klassisch zu Spargel.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#fcf0c8">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><path d="M93 10h14v20c0 5 4 7 12 10 18 6 29 20 29 38 0 22-20 34-48 34S52 100 52 78c0-18 11-32 29-38 8-3 12-5 12-10Z" fill="#fff" stroke="#141414" stroke-width="2" stroke-linejoin="round"/><path d="M93 10h14" stroke="#141414" stroke-width="5"/><path d="M93 18h14" stroke="#7491ff" stroke-width="3"/><ellipse cx="100" cy="76" rx="27" ry="17" fill="#7491ff" stroke="#141414" stroke-width="2"/><path d="M86 72h28M90 80h20" stroke="#fff" stroke-width="2" stroke-linecap="round"/><path d="M60 64c6-10 14-16 22-19" fill="none" stroke="#141414" stroke-width="1.5" stroke-linecap="round" opacity=".35"/></svg></div>
<p class="notiz">Modul 02 · Karte 2</p>
<h3>Bocksbeutel</h3>
<p class="erkl">Die bauchig-flache Flasche ist geschützt. In Deutschland nur für Frankenwein und Teile Badens.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#eef0f5">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><path d="M74 20h52c2 26-6 44-26 46-20-2-28-20-26-46Z" fill="#fff" stroke="#141414" stroke-width="2"/><path d="M76 40h48c-2 14-10 24-24 25-14-1-22-11-24-25Z" fill="#f7c325"/><path d="M74 20h52c2 26-6 44-26 46-20-2-28-20-26-46Z" fill="none" stroke="#141414" stroke-width="2"/><path d="M100 66v34M84 106h32" stroke="#141414" stroke-width="2" stroke-linecap="round"/><path d="M134 30c10-4 16 4 10 10s4 14 12 8M140 16c8-6 16 0 12 6" fill="none" stroke="#7491ff" stroke-width="2.5" stroke-linecap="round"/></svg></div>
<p class="notiz">Modul 02 · Karte 3</p>
<h3>Müller-Thurgau</h3>
<p class="erkl">Neben dem Silvaner die zweite große Sorte Frankens. Duftig und leicht, oft mit zarter Muskatnote.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#e3e9ff">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><path d="M40 98a60 60 0 0 1 120 0" fill="#fff" stroke="#141414" stroke-width="2"/><path d="M40 98a60 60 0 0 1 22-46" fill="none" stroke="#10bf7a" stroke-width="8"/><path d="M62 52a60 60 0 0 1 22-13" fill="none" stroke="#f7c325" stroke-width="8"/><g stroke="#141414" stroke-width="2"><path d="M52 98h-8M60 68l-6-5M84 50l-3-7M116 50l3-7M140 68l6-5M148 98h8"/></g><path d="M100 98 72 62" stroke="#141414" stroke-width="3" stroke-linecap="round"/><circle cx="100" cy="98" r="6" fill="#7491ff" stroke="#141414" stroke-width="2"/><text x="100" y="82" text-anchor="middle" font-family="Onest, sans-serif" font-size="12" font-weight="600" fill="#141414">g/l</text></svg></div>
<p class="notiz">Modul 02 · Karte 4</p>
<h3>Trocken</h3>
<p class="erkl">Höchstens 4 g Restzucker pro Liter. Oder bis 9 g, wenn die Säure höchstens 2 g/l unter dem Zucker liegt (EU-Regel).</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#fcf0c8">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><path d="M0 70c30-6 60 6 100 0s70-6 100 0v50H0Z" fill="#e8dcb3"/><path d="M0 92c40-6 70 4 100 0s60-6 100 0" fill="none" stroke="#141414" stroke-width="1.5"/><path d="M0 70c30-6 60 6 100 0s70-6 100 0" fill="none" stroke="#141414" stroke-width="2"/><path d="M100 64 70 34a42 42 0 0 1 60 0Z" fill="#fff" stroke="#141414" stroke-width="2" stroke-linejoin="round"/><path d="M100 64 78 28M100 64l-10-38M100 64V24M100 64l10-38M100 64l22-36" stroke="#141414" stroke-width="1.5"/><path d="M92 64h16v6H92Z" fill="#7491ff" stroke="#141414" stroke-width="2"/><circle cx="46" cy="104" r="3" fill="#141414"/><circle cx="150" cy="82" r="2.5" fill="#141414"/><circle cx="160" cy="106" r="2" fill="#141414"/></svg></div>
<p class="notiz">Modul 02 · Karte 5</p>
<h3>Muschelkalk</h3>
<p class="erkl">Der Boden am Würzburger Stein. Weine von dort gelten als straff, oft mit salziger Länge.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#eef0f5">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><path d="M0 74h200v12H0Z" fill="#fff" stroke="#141414" stroke-width="2"/><path d="M8 86v34M8 120c0-18 14-26 28-26s28 8 28 26M64 86v34M64 120c0-18 14-26 28-26s28 8 28 26M120 86v34M120 120c0-18 14-26 28-26s28 8 28 26M176 86v34" fill="none" stroke="#141414" stroke-width="2"/><path d="M84 20h32l-3 26c-1 6-6 10-13 10s-12-4-13-10Z" fill="#fff" stroke="#141414" stroke-width="2"/><path d="M85 30h30l-2 16c-1 6-6 9-13 9s-12-3-13-9Z" fill="#f7c325"/><path d="M84 20h32l-3 26c-1 6-6 10-13 10s-12-4-13-10Z" fill="none" stroke="#141414" stroke-width="2"/><path d="M100 56v12M90 72h20" stroke="#141414" stroke-width="2" stroke-linecap="round"/><path d="M86 30h28" stroke="#7491ff" stroke-width="2"/></svg></div>
<p class="notiz">Modul 02 · Karte 6</p>
<h3>Schoppen</h3>
<p class="erkl">In Franken meist ein Viertelliter Wein im Glas. Bekannt: der Brückenschoppen auf der Alten Mainbrücke.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#e3e9ff">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><g stroke="#141414" stroke-width="2"><circle cx="46" cy="60" r="24" fill="#fff"/><circle cx="100" cy="60" r="24" fill="#7491ff"/><circle cx="154" cy="60" r="24" fill="#fff"/></g><path d="M72 60h2M126 60h2" stroke="#141414" stroke-width="2"/><path d="M74 54l6 6-6 6M128 54l6 6-6 6" fill="none" stroke="#141414" stroke-width="2"/><g font-family="Bricolage Grotesque, sans-serif" font-weight="700" font-size="22" text-anchor="middle"><text x="46" y="68" fill="#141414">1</text><text x="100" y="68" fill="#fff">2</text><text x="154" y="68" fill="#141414">3</text></g><path d="M86 22c6-6 22-6 28 0" fill="none" stroke="#141414" stroke-width="2" stroke-linecap="round"/><path d="M112 16l3 7-7 1" fill="none" stroke="#141414" stroke-width="2"/></svg></div>
<p class="notiz">Modul 02 · Karte 7</p>
<h3>Riechen in drei Schritten</h3>
<p class="erkl">Ruhig riechen, schwenken, noch einmal riechen. Beim zweiten Mal zeigt der Wein meist mehr Aroma.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
<article class="karte" style="--ton:#fcf0c8">
<div class="bildfeld" aria-hidden="true"><svg viewBox="0 0 200 120"><g stroke="#141414" stroke-width="2" fill="#2a2d45"><circle cx="76" cy="44" r="10"/><circle cx="96" cy="44" r="10"/><circle cx="116" cy="44" r="10"/><circle cx="86" cy="61" r="10"/><circle cx="106" cy="61" r="10" fill="#7491ff"/><circle cx="126" cy="61" r="10"/><circle cx="96" cy="78" r="10"/><circle cx="116" cy="78" r="10"/><circle cx="106" cy="95" r="10" fill="#7491ff"/></g><g fill="#fff" opacity=".7"><circle cx="73" cy="41" r="2.5"/><circle cx="93" cy="41" r="2.5"/><circle cx="83" cy="58" r="2.5"/><circle cx="123" cy="58" r="2.5"/><circle cx="93" cy="75" r="2.5"/></g><path d="M96 34c0-10 4-17 12-20" fill="none" stroke="#141414" stroke-width="2"/><path d="M58 104h84" stroke="#c45a3a" stroke-width="5" stroke-linecap="round"/><path d="M50 112h100" stroke="#c45a3a" stroke-width="3" stroke-linecap="round" opacity=".6"/></svg></div>
<p class="notiz">Modul 02 · Karte 8</p>
<h3>Spätburgunder</h3>
<p class="erkl">Rot vom Untermain: In Churfranken wächst er oft auf Buntsandstein.</p>
<span class="stempel gemerkt notiz">Gemerkt!</span><span class="stempel nochmal notiz">Nochmal</span>
</article>
</div>
<div class="steuer">
<button type="button" class="knopf zurueck" data-schritt="-1"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg><span data-label-zurueck>Nochmal</span></button>
<button type="button" class="knopf vor" data-schritt="1"><span data-label-vor>Gemerkt</span><svg viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></button>
</div>
<p class="hilfe" data-hilfe>Nach rechts: gemerkt · nach links: kommt wieder</p>
<p class="sr" data-live aria-live="polite" style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)"></p>
</section>
</main>
<footer class="fuss">
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-modus="stapel" aria-pressed="true">Stapel</button>
<button type="button" data-modus="faecher" aria-pressed="false">Fächer</button>
</div>
<p class="werte mono" data-werte></p>
</footer>
<div class="finger" data-finger aria-hidden="true"></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/Draggable.min.js"></script>
<script>
(() => {
gsap.registerPlugin(Draggable);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]');
const karten = [...buehne.querySelectorAll('.karte')];
const N = karten.length;
const live = $('[data-live]'), werte = $('[data-werte]'), finger = $('[data-finger]');
// Gemessene Werte (Osmo Flick Cards / Stacked Cards)
const W = {
schwelle: 0.10, // ab 10 % der Kartenbreite gilt der Wurf
weit: 1.15, // über 115 %: stärkerer Abgang
klick: 4, // unter 4 px Bewegung ist ein Klick
landungFaecher: { duration: 0.6, ease: 'elastic.out(1.2, 1)' },
landungStapel: { duration: 1, ease: 'elastic.out(1, 0.75)' },
zurueckFedern: { duration: 0.3, ease: 'power1.out' },
wurf: { duration: 0.3, ease: 'power2.out' },
reduziert: { duration: 0.2, ease: 'power2.out' },
};
// Konfiguration je relativer Position, dazwischen wird linear gemischt
const FAECHER = [
{ xPercent: 0, yPercent: 0, rotation: 0, scale: 1, opacity: 1 },
{ xPercent: 25, yPercent: 1, rotation: 10, scale: 0.9, opacity: 1 },
{ xPercent: 45, yPercent: 5, rotation: 15, scale: 0.8, opacity: 1 },
{ xPercent: 55, yPercent: 5, rotation: 20, scale: 0.6, opacity: 0 },
];
const STAPEL = [
{ y: 0, rotation: -2, scale: 1, opacity: 1 },
{ y: 18, rotation: 3, scale: 0.95, opacity: 1 },
{ y: 34, rotation: -4, scale: 0.9, opacity: 1 },
{ y: 48, rotation: 4, scale: 0.85, opacity: 1 },
{ y: 56, rotation: 0, scale: 0.82, opacity: 0 },
];
const mische = (a, b, t) => { const o = {}; for (const k in a) o[k] = a[k] + (b[k] - a[k]) * t; return o; };
function faecher(pos) { // pos: relative Position, auch gebrochen
const s = Math.sign(pos) || 1, a = Math.min(Math.abs(pos), FAECHER.length - 1), i = Math.floor(a), t = a - i;
const c = t ? mische(FAECHER[i], FAECHER[Math.min(i + 1, FAECHER.length - 1)], t) : { ...FAECHER[i] };
c.xPercent *= s; c.rotation *= s; c.x = 0; c.y = 0;
if (reduziert()) c.rotation = 0;
c.zIndex = 20 - Math.round(a * 2);
return c;
}
function stapel(tiefe) {
const a = Math.max(0, Math.min(tiefe, STAPEL.length - 1)), i = Math.floor(a), t = a - i;
const c = t ? mische(STAPEL[i], STAPEL[Math.min(i + 1, STAPEL.length - 1)], t) : { ...STAPEL[i] };
c.x = 0; c.xPercent = 0; c.yPercent = 0;
if (reduziert()) c.rotation = i % 2 ? 2 : -2; // Stapelneigung bleibt, aber statisch
c.zIndex = 20 - Math.round(a * 2);
return c;
}
let modus = 'stapel', aktiv = 0, reihe = karten.map((_, i) => i); // reihe[0] liegt oben
const gemerkt = new Set(), gesehen = new Set([0]);
const breite = () => karten[0].offsetWidth;
const rel = i => { let o = (i - aktiv + N) % N; if (o > N / 2) o -= N; return o; };
const oben = () => modus === 'stapel' ? karten[reihe[0]] : karten[aktiv];
function setzeAlle(dx = 0) {
const w = breite();
if (modus === 'faecher') {
const p = Math.max(-1, Math.min(1, dx / (w * 0.25))); // Karte folgt dem Finger bis zur Nachbarposition
karten.forEach((k, i) => gsap.set(k, faecher(rel(i) + p)));
} else {
const p = Math.min(1, Math.abs(dx) / (w * 0.6));
reihe.forEach((ki, d) => {
const c = stapel(d === 0 ? 0 : d - p);
if (d === 0) { c.x = dx; c.rotation = (reduziert() ? -2 : -2 + dx / w * 14); }
gsap.set(karten[ki], c);
});
stempel(karten[reihe[0]], dx / w);
}
}
function stempel(k, anteil) {
k.querySelector('.gemerkt').style.opacity = Math.max(0, Math.min(1, anteil / W.schwelle - 0.4));
k.querySelector('.nochmal').style.opacity = Math.max(0, Math.min(1, -anteil / W.schwelle - 0.4));
}
function stempelAus() { karten.forEach(k => gsap.to(k.querySelectorAll('.stempel'), { opacity: 0, duration: 0.25, ease: 'power1.out' })); }
// Ein Tween je Karte: Lage mit Feder, Deckkraft immer ohne Feder
function landen(k, c, art) {
const { opacity, zIndex, ...lage } = c;
gsap.set(k, { zIndex });
if (reduziert()) { gsap.to(k, { ...lage, opacity, ...W.reduziert, overwrite: 'auto' }); return; }
gsap.to(k, { ...lage, ...art, overwrite: 'auto' });
gsap.to(k, { opacity, duration: 0.3, ease: 'power1.out', overwrite: false });
}
function ordnen(art) {
if (modus === 'faecher') karten.forEach((k, i) => landen(k, faecher(rel(i)), art || W.landungFaecher));
else reihe.forEach((ki, d) => landen(karten[ki], stapel(d), art || W.landungStapel));
karten.forEach(k => { const top = k === oben(); k.setAttribute('aria-hidden', String(!top)); k.inert = !top; });
}
// Weiter/Zurück
function schritt(richtung, wurfX) {
stempelAus();
if (modus === 'faecher') {
aktiv = (aktiv + richtung + N) % N; gesehen.add(aktiv);
ordnen(); melden(); fortschritt(); return;
}
const w = breite();
if (richtung > 0 || wurfX !== undefined) {
// Oberste Karte fliegt weg (rechts: gemerkt, links: nochmal) und wandert ans Ende des Stapels
const seite = wurfX !== undefined ? Math.sign(wurfX) || 1 : 1, ki = reihe.shift(), k = karten[ki];
const weit = wurfX !== undefined && Math.abs(wurfX) > w * W.weit;
if (seite > 0) { gemerkt.add(ki); fortschritt(true); }
reihe.push(ki);
reihe.slice(0, -1).forEach((kj, d) => landen(karten[kj], stapel(d), W.landungStapel));
if (reduziert()) { gsap.set(k, { zIndex: 1 }); gsap.to(k, { ...stapel(N - 1), ...W.reduziert, zIndex: 1 }); }
else {
gsap.to(k, { x: seite * w * (weit ? 1.6 : 1.15), rotation: seite * (weit ? 26 : 18), ...W.wurf, overwrite: 'auto',
onComplete: () => { gsap.set(k, { zIndex: 1 }); const { opacity, zIndex, ...lage } = stapel(N - 1); gsap.to(k, { ...lage, ...W.landungStapel }); gsap.to(k, { opacity, duration: 0.3, ease: 'power1.out' }); } });
}
} else {
// Zurück: letzte Karte kommt von links oben auf den Stapel
const ki = reihe.pop(), k = karten[ki];
reihe.unshift(ki);
if (!reduziert()) gsap.set(k, { x: -w * 1.15, rotation: -18, opacity: 1, zIndex: 30 });
ordnen();
}
karten.forEach(k => { const top = k === oben(); k.setAttribute('aria-hidden', String(!top)); k.inert = !top; });
melden();
}
function melden() { const k = oben(); live.textContent = `${k.querySelector('h3').textContent}. Karte ${karten.indexOf(k) + 1} von ${N}.`; }
// Fortschritt: Erfolgsfarbe nur hier, Feder nur beim Hochzählen
const zahl = $('[data-zahl]'), balken = $('[data-balken]'), geschafft = $('[data-geschafft]'), fText = $('[data-fortschritt-text]');
function fortschritt(hops) {
const n = modus === 'stapel' ? gemerkt.size : gesehen.size;
fText.textContent = modus === 'stapel' ? 'Gemerkt' : 'Angesehen';
if (zahl.textContent !== String(n)) { zahl.textContent = n; if (hops !== false) { zahl.classList.remove('hops'); void zahl.offsetWidth; zahl.classList.add('hops'); } }
balken.style.width = n / N * 100 + '%';
geschafft.hidden = !(modus === 'stapel' && n === N);
}
// Ziehen: unsichtbarer Proxy, Auslöser ist die Bühne; senkrechtes Scrollen bleibt frei
const proxy = document.createElement('div');
let start = 0, zieht = false;
Draggable.create(proxy, {
type: 'x', trigger: buehne, allowNativeTouchScrolling: true, minimumMovement: 2,
onPress(e) { vorfuehrungAus(); start = this.x; zieht = false; gsap.killTweensOf(karten); oben().classList.add('gepackt'); },
onDrag() { zieht = true; setzeAlle(this.x - start); },
onRelease(e) {
const dx = this.x - start, w = breite();
oben().classList.remove('gepackt');
if (!zieht || Math.abs(dx) < W.klick) { klick(e); return; }
if (Math.abs(dx) > w * W.schwelle) {
if (modus === 'faecher') schritt(dx < 0 ? 1 : -1);
else schritt(dx > 0 ? 1 : -1, dx);
} else { stempelAus(); ordnen(reduziert() ? W.reduziert : W.zurueckFedern); }
},
});
function klick(e) {
// Fächer: Klick auf eine Nachbarkarte holt sie in die Mitte
if (modus !== 'faecher') return;
const k = e && e.target && e.target.closest && e.target.closest('.karte');
if (!k || k === oben()) return;
aktiv = karten.indexOf(k); gesehen.add(aktiv); ordnen(); melden(); fortschritt();
}
document.querySelectorAll('[data-schritt]').forEach(b => b.addEventListener('click', () => {
vorfuehrungAus();
const r = +b.dataset.schritt;
if (modus === 'stapel') schritt(r, r > 0 ? breite() * 0.5 : -breite() * 0.5); else schritt(r);
}));
buehne.addEventListener('keydown', e => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault(); vorfuehrungAus();
const r = e.key === 'ArrowRight' ? 1 : -1;
if (modus === 'stapel') schritt(r, r * breite() * 0.5); else schritt(r);
});
// Varianten
const WERTE = {
stapel: 'Neigung <b>−2° / 3° / −4° / 4°</b> · Versatz <b>0 / 18 / 34 / 48 px</b> · Scale <b>1 / 0,95 / 0,9 / 0,85</b> · Schwelle <b>10 %</b> · Wurf <b>0,3 s power2.out</b> · Landung <b>1 s elastic.out(1, 0.75)</b> · Rückfedern <b>0,3 s power1.out</b> · Schatten <b>250 ms cubic-bezier(0.625, 0.05, 0, 1)</b>',
faecher: '±1: <b>xPercent 25, yPercent 1, 10°, scale 0,9</b> · ±2: <b>45, 5, 15°, 0,8</b> · Rest <b>55, 20°, 0,6, unsichtbar</b> · Schwelle <b>10 %</b> · Landung <b>0,6 s elastic.out(1.2, 1)</b> · Klick <b>< 4 px</b>',
};
function setzeModus(m) {
modus = m;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
werte.innerHTML = WERTE[m];
$('[data-label-zurueck]').textContent = m === 'stapel' ? 'Nochmal' : 'Zurück';
$('[data-label-vor]').textContent = m === 'stapel' ? 'Gemerkt' : 'Weiter';
$('[data-hilfe]').textContent = m === 'stapel' ? 'Nach rechts: gemerkt · nach links: kommt wieder' : 'Karte ziehen oder Nachbarkarte antippen';
buehne.setAttribute('aria-label', m === 'stapel' ? 'Lernkarten. Pfeil rechts: gemerkt, Pfeil links: nochmal.' : 'Lernkarten im Fächer. Pfeiltasten blättern.');
stempelAus(); fortschritt(false); ordnen(); melden();
}
document.querySelectorAll('[data-modus]').forEach(b => b.addEventListener('click', () => { vorfuehrungAus(); setzeModus(b.dataset.modus); }));
addEventListener('resize', () => setzeAlle(0));
// Vorführung: gedachter Finger zieht die oberste Karte, mal nach rechts, mal nach links
let tl = null, vorf = false;
function wisch(dx, nr) {
const k = oben(), r = k.getBoundingClientRect(), x0 = r.left + r.width * 0.5, y0 = r.top + r.height * 0.55;
const o = { dx: 0 }, t = gsap.timeline();
t.call(() => { finger.style.transform = `translate(${x0}px, ${y0}px)`; finger.classList.add('an'); })
.to({}, { duration: 0.35 })
.call(() => { gsap.killTweensOf(karten); oben().classList.add('gepackt'); })
.to(o, { dx, duration: 0.55, ease: 'power2.inOut', onUpdate: () => { setzeAlle(o.dx); finger.style.transform = `translate(${x0 + o.dx}px, ${y0 + o.dx * 0.06}px)`; } })
.call(() => {
oben().classList.remove('gepackt'); finger.classList.remove('an');
if (modus === 'faecher') schritt(dx < 0 ? 1 : -1); else schritt(dx > 0 ? 1 : -1, dx);
})
.to({}, { duration: modus === 'stapel' ? 1.25 : 0.95 });
return t;
}
function vorfuehren() {
vorfuehrungAus(); vorf = true; html.classList.add('vorfuehrung');
const runde = () => {
if (!vorf) return;
const w = breite();
tl = gsap.timeline({ onComplete: runde, delay: 0.3 });
if (modus === 'stapel') {
if (gemerkt.size >= N) { gemerkt.clear(); fortschritt(false); }
tl.add(wisch(w * 0.62)).add(wisch(w * 0.55)).add(wisch(-w * 0.5)).add(wisch(w * 0.7));
} else {
tl.add(wisch(-w * 0.32)).add(wisch(-w * 0.3)).add(wisch(-w * 0.34)).add(wisch(w * 0.3));
}
};
runde();
}
function vorfuehrungAus() { vorf = false; if (tl) tl.kill(); tl = null; finger.classList.remove('an'); html.classList.remove('vorfuehrung'); karten.forEach(k => k.classList.remove('gepackt')); }
function pause() { if (tl) tl.pause(); }
function weiter() { if (tl && vorf) tl.resume(); }
function neustart() {
vorfuehrungAus(); gemerkt.clear(); gesehen.clear(); gesehen.add(0);
aktiv = 0; reihe = karten.map((_, i) => i);
gsap.killTweensOf(karten); stempelAus(); setzeAlle(0); karten.forEach((k, i) => gsap.set(k, modus === 'stapel' ? stapel(i) : faecher(rel(i))));
fortschritt(false); ordnen(); melden();
}
karten.forEach((k, i) => gsap.set(k, stapel(i)));
setzeModus('stapel');
})();
</script>
</body>
</html>