Toast-Benachrichtigungen im Stapel
Rückmeldungen gleiten unten rechts herein und schieben sich zu einem Stapel zusammen, ältere rücken kleiner nach hinten; Hover fächert auf und hält die Zeit an.
Kurze Rückmeldungen erscheinen am Bildschirmrand und gleiten herein. Kommen mehrere, stapeln sie sich: Ältere rücken nach hinten, werden 5 % kleiner je Ebene und schauen nur als schmaler Rand hervor. Beim Hover oder Fokus fächert der Stapel auf. Jeder Toast schließt sich nach 4 s von selbst; Hover und Fokus halten die Zeit an. Auf Touch schließt Wischen.
Die Rückmeldung kommt, ohne die Handlung zu unterbrechen. Der Stapel hält viele Meldungen auf kleinem Raum und zeigt trotzdem, dass es mehrere sind. Die Tiefe ordnet nach Zeit: vorn das Neueste.
Wann einsetzen
Speichern, Kopieren, Warenkorb, kurze Netzwerkfehler, Undo-Angebote.
Wann nicht
Für kritische Fehler, die eine Entscheidung verlangen (Inline-Hinweis oder Dialog). Für Formularvalidierung (Meldung direkt am Feld). Für Inhalte, die man länger lesen muss.
Varianten
- Stapel mit Tiefenstaffel: ältere Toasts kleiner und versetzt, Hover fächert auf (Sonner, Motion Toast stack).
- Liste ohne Stapel: alle sichtbaren Toasts untereinander mit festem Abstand.
- Undo-Toast mit Countdown-Linie: die Linie zeigt die Restzeit und hält bei Hover an.
- Nativ mit popover="manual" im Top-Layer, Abstand per sibling-index() (Chrome).
Umsetzung
Toaster fixed unten rechts (24 px Rand), Toasts absolut am unteren Rand gestapelt. Lage je Toast über Variablen: --y = −Ebene × 14 px, --s = 1 − Ebene × 0,05, Höhe der hinteren Ebenen = Höhe des vordersten, ihr Inhalt opacity 0. Aufgefächert: --y = Summe der Höhen der neueren Toasts plus 14 px Abstand, --s = 1. transition: transform, opacity, height 400 ms ease. Höchstens drei Ebenen sichtbar. Eintritt aus translateY(100%) mit opacity 0. Lebensdauer 4000 ms als Web Animation (z. B. auf der Countdown-Linie): pause() bei pointerenter und focusin, play() beim Verlassen, onfinish schließt. Nativ geht es ohne JS-Lage: popover="manual" (kein Light-Dismiss), Abstand per sibling-index(), Ein- und Austritt per @starting-style und transition-behavior: allow-discrete für display und overlay. Ansage separat in einer Live-Region.
Robust umsetzen
Telefon. Unten über die volle Breite mit 16 px Rand. Tippen fächert den Stapel auf, Wischen schließt. Größere Trefferflächen für Schließen und Aktion.
Ohne Bewegung. Als Liste ohne Tiefenstaffel zeigen und nur per Opacity ein- und ausblenden. Kein Gleiten, kein Skalieren.
Barrierefrei. Meldungstext zusätzlich in eine Live-Region schreiben: role="status" (polite) für Bestätigungen, role="alert" für Fehler. Die Zeit hält bei Hover und Fokus an (WCAG 2.2.1). Toasts, Aktionen und Schließen sind per Tastatur erreichbar, Escape schließt den fokussierten Toast. Wichtige Aktionen nie nur im Toast anbieten.
Code
<!DOCTYPE html>
<!--
Toast-Benachrichtigungen im Stapel
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/toast-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>Toast-Benachrichtigungen im Stapel · 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: 'Big Shoulders'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/big-shoulders:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Archivo'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/archivo:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-stretch: 62% 125%; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept verein-nonprofit-wirkung: tiefer Markenton, helle Töne derselben Farbe, Gelb nur fürs Spenden */
--grund: #ffffff; --flaeche: #edf5fa; --marke: #002e6d; --marke-2: #0b4290; --hellblau: #bfd6ea;
--tinte: #222520; --leise: rgba(34, 37, 32, .68); --linie: rgba(0, 46, 109, .14); --grau: #dfdfd7;
--signal: #ffca0a; --signal-hover: #f2bb00; --rot: #b3261e;
--toast: #ffffff; --tief: #bfd6ea;
--kurve: cubic-bezier(0.25, 0.1, 0.25, 1); /* gemessen: ease, Sonner */
--dauer: 400ms; /* Eintritt, Stapel, Auffächern */
--ui: cubic-bezier(0.16, 1, 0.3, 1); /* Zeiger der Vorführung, Knöpfe */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Archivo', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; overflow-x: hidden; background: var(--grund); }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.display { font-family: 'Big Shoulders', 'Archivo', sans-serif; font-weight: 800; text-transform: uppercase; letter-spacing: .005em; font-variant-numeric: tabular-nums; }
/* Kopf */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 10px; color: var(--marke); text-decoration: none; }
.marke:focus-visible { outline: 2px solid var(--marke); outline-offset: 4px; border-radius: 4px; }
.marke svg { width: 30px; height: 30px; flex: none; }
.marke b { display: block; font: 800 19px/.9 'Big Shoulders', sans-serif; letter-spacing: .02em; text-transform: uppercase; }
.marke small { display: block; margin-top: 3px; font: 500 9.5px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.nav { display: flex; align-items: center; gap: clamp(14px, 2.4vw, 32px); list-style: none; padding: 0; }
.nav li { color: var(--marke); }
.spenden-kopf { display: inline-flex; align-items: center; gap: 8px; height: 38px; padding: 0 8px 0 16px; border-radius: 999px; background: var(--signal); color: var(--tinte); font: 600 14px/1 'Archivo', sans-serif; letter-spacing: 0; text-transform: none; }
.zaehler { display: inline-grid; place-items: center; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px; background: var(--marke); color: #fff; font: 500 11px/1 'Geist Mono', monospace; transition: scale .4s var(--ui); }
.zaehler.puls { scale: 1.25; transition-duration: .15s; }
/* Spendenseite */
.produkt { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(24px, 5vw, 72px); align-items: center; padding: clamp(20px, 4vw, 48px); max-width: 1280px; width: 100%; margin: 0 auto; }
.bildflaeche { position: relative; aspect-ratio: 4 / 4.4; max-height: 68svh; width: 100%; border-radius: 6px; background: var(--marke); color: #fff; overflow: hidden; display: grid; grid-template-rows: auto auto minmax(0, 1fr); container-type: inline-size; }
.bildflaeche .oben { display: flex; justify-content: space-between; gap: 12px; padding: 16px 18px 0; }
.bildflaeche .mono { color: rgba(237, 245, 250, .66); }
.kiste { width: 100%; height: 100%; min-height: 0; display: block; }
.zahlen { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; padding: 18px 18px 0; }
.zahlen div { padding: 14px 10px 2px 0; border-top: 1px solid rgba(191, 214, 234, .28); }
.zahlen div + div { padding-left: 12px; border-left: 1px solid rgba(191, 214, 234, .28); }
.zahlen strong { display: block; font-size: clamp(30px, 10cqi, 58px); line-height: .9; color: #fff; }
.zahlen span { display: block; margin-top: 6px; font: 400 12.5px/1.3 'Archivo', sans-serif; color: rgba(237, 245, 250, .78); }
.zahlen .quelle { grid-column: 1 / -1; border: 0; padding: 10px 0 0; }
.zahlen .quelle.mono { color: rgba(237, 245, 250, .64); font-size: 9.5px; }
.info { display: grid; gap: 20px; max-width: 480px; }
.info > .mono { color: var(--marke-2); }
h1 { font-size: clamp(2.75rem, 1.2rem + 4.2vw, 5rem); line-height: .92; color: var(--marke); text-wrap: balance; }
.text { font-size: 1.0625rem; line-height: 1.6; color: rgba(34, 37, 32, .82); max-width: 42ch; text-wrap: pretty; }
.betrag { display: flex; gap: 8px; flex-wrap: wrap; border: 0; padding: 0; }
.betrag legend { margin-bottom: 10px; }
.betrag label { position: relative; }
.betrag input { position: absolute; opacity: 0; inset: 0; }
.betrag span { display: block; min-width: 76px; text-align: center; padding: 10px 16px; border: 1px solid var(--linie); border-radius: 8px; font: 600 15px/1 'Archivo', sans-serif; font-variant-numeric: tabular-nums; color: var(--marke); background: var(--grund); transition: background-color .15s, color .15s, border-color .15s; }
.betrag label:hover span { border-color: var(--marke); }
.betrag input:checked + span { background: var(--marke); color: #fff; border-color: var(--marke); }
.betrag input:focus-visible + span { outline: 2px solid var(--marke); outline-offset: 2px; }
.wirkung { display: flex; align-items: baseline; gap: 6px 14px; flex-wrap: wrap; padding: 14px 16px; border-radius: 8px; background: var(--flaeche); }
.wirkung strong { font: 700 17px/1.3 'Archivo', sans-serif; color: var(--marke); }
.aktionen { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-height: 54px; padding: 0 22px; border-radius: 999px; font: 600 1rem/1 'Archivo', sans-serif; transition: scale .4s var(--ui), background-color .15s, border-color .15s; }
.knopf.voll { background: var(--signal); color: var(--tinte); }
.knopf.voll:hover { background: var(--signal-hover); }
.knopf.rand { border: 1.5px solid var(--marke); color: var(--marke); padding: 0 18px; }
.knopf.rand:hover { background: var(--flaeche); }
.knopf.gedrueckt, .knopf:active { scale: .96; transition-duration: .15s; }
.knopf:focus-visible, .klein:focus-visible, .spenden-kopf:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
.nebenaktionen { display: flex; flex-wrap: wrap; gap: 8px 22px; }
.klein { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: var(--marke); padding: 6px 0; border-bottom: 1px solid var(--linie); transition: border-color .15s, scale .4s var(--ui); }
.klein:hover { border-color: var(--marke); }
.klein.gedrueckt { scale: .96; transition-duration: .15s; }
.code { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .06em; padding: 4px 6px; border: 1px dashed rgba(0, 46, 109, .4); border-radius: 3px; }
/* Fuß: Varianten und Werte */
.fuss { display: flex; justify-content: space-between; align-items: flex-end; gap: 16px 32px; flex-wrap: wrap; padding: 14px clamp(16px, 4vw, 48px) 20px; border-top: 1px solid var(--linie); background: var(--flaeche); }
.varianten { display: flex; gap: 4px; align-items: center; }
.varianten .mono:first-child { margin-right: 8px; }
.varianten button { font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); padding: 7px 9px; border-radius: 999px; transition: color .15s, background-color .15s; }
.varianten button[aria-pressed="true"] { color: #fff; background: var(--marke); }
.varianten button:focus-visible { outline: 1px solid var(--marke); }
.werte { max-width: 620px; }
@media (min-width: 761px) { .fuss { flex-direction: column; align-items: flex-start; gap: 10px; padding-right: 420px; } }
.werte b { font-weight: 500; color: var(--marke); }
/* ---------- Der Stapel ---------- */
.toaster { position: fixed; right: 24px; bottom: 24px; z-index: 10; width: 356px; }
.toaster ol { position: relative; list-style: none; padding: 0; height: 0; transition: height var(--dauer) var(--kurve); }
.toast {
--y: 0px; --s: 1; --i: 0; --dx: 0px;
position: absolute; left: 0; right: 0; bottom: 0;
border-radius: 12px; background: color-mix(in oklab, var(--toast), var(--tief) calc(min(var(--i), 3) * 30%));
border: 1px solid rgba(0, 46, 109, .12);
box-shadow: 0 1px 2px rgba(0, 30, 70, .07), 0 12px 30px -10px rgba(0, 30, 70, .32);
transform: translate(var(--dx), var(--y)) scale(var(--s));
transition: transform var(--dauer) var(--kurve), opacity var(--dauer) var(--kurve), height var(--dauer) var(--kurve), background-color var(--dauer) var(--kurve);
touch-action: pan-y; user-select: none; outline: none;
}
.toast[data-zustand="ein"] { opacity: 0; transform: translateY(100%) scale(1); }
.toast[data-zustand="aus"] { opacity: 0; pointer-events: none; }
.toast[data-zustand="aus"][data-front="true"] { transform: translate(var(--dx), calc(var(--y) + 100%)) scale(var(--s)); }
.toast[data-zustand="weg"] { opacity: 0; pointer-events: none; transform: translate(calc(var(--dx) * 3), var(--y)) scale(var(--s)); }
.toast[data-zieht] { transition: opacity var(--dauer) var(--kurve), height var(--dauer) var(--kurve); }
.toast[data-verborgen] { opacity: 0; pointer-events: none; }
.toast:focus-visible { box-shadow: 0 0 0 2px var(--grund), 0 0 0 4px var(--marke); }
.inhalt { display: grid; grid-template-columns: 36px 1fr auto; gap: 12px; align-items: center; padding: 14px; transition: opacity var(--dauer) var(--kurve); }
.ol-zu .toast:not([data-front="true"]) .inhalt { opacity: 0; pointer-events: none; }
.symbol { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: var(--flaeche); color: var(--marke); overflow: hidden; }
.symbol svg { width: 18px; height: 18px; }
.symbol.tuete { background: var(--marke); }
.symbol.tuete svg { width: 26px; height: 26px; }
.toast[data-art="fehler"] .symbol { background: #fbe4e1; color: var(--rot); }
.titel { font-size: 14px; font-weight: 650; line-height: 1.3; color: var(--marke); }
.toast[data-art="fehler"] .titel { color: var(--rot); }
.zeile2 { font-size: 13px; line-height: 1.4; color: var(--leise); margin-top: 1px; }
.aktion { font-size: 13px; font-weight: 600; padding: 0 12px; height: 30px; border-radius: 999px; background: var(--marke); color: #fff; white-space: nowrap; transition: background-color .15s; }
.aktion:hover { background: var(--marke-2); }
.aktion:focus-visible, .zu:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
.zu { position: absolute; left: -7px; top: -7px; width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; background: var(--toast); border: 1px solid rgba(0, 46, 109, .16); color: var(--marke); opacity: 0; transition: opacity .15s; }
.zu svg { width: 10px; height: 10px; }
.zu::after { content: ''; position: absolute; inset: -11px; } /* Trefferfläche 44 px */
.toast:hover .zu, .toast:focus-within .zu { opacity: 1; }
.ol-zu .toast:not([data-front="true"]) .zu { opacity: 0; pointer-events: none; }
.zeit { position: absolute; left: 14px; right: 14px; bottom: 0; height: 2px; border-radius: 2px; background: var(--marke); transform-origin: left; opacity: 0; }
.mit-linie .zeit { opacity: .6; }
.ol-zu .toast:not([data-front="true"]) .zeit { opacity: 0; }
/* Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 20; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .55s var(--ui); }
.vorfuehrung .zeiger { display: block; }
/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .fuss { visibility: hidden; }
/* Touch: unten über die volle Breite, Tippen fächert auf, Wischen schließt */
@media (hover: none) {
.toaster { left: 16px; right: 16px; bottom: 16px; width: auto; }
.zu { opacity: 1; width: 24px; height: 24px; }
.ol-zu .toast:not([data-front="true"]) .zu { opacity: 0; }
.aktion { height: 36px; padding: 0 14px; }
}
@media (max-width: 760px) {
.produkt { grid-template-columns: 1fr; padding-top: 16px; }
.bildflaeche { aspect-ratio: auto; max-height: none; }
.kiste { height: auto; aspect-ratio: 400 / 270; }
.nav li:not(:last-child) { display: none; }
.fuss { padding-bottom: 120px; }
}
@media (max-width: 380px) { .marke small { display: none; } }
/* Reduzierte Bewegung: immer als Liste, nur Einblenden */
.reduziert .toast { transition: opacity .15s linear; transform: translateY(var(--y)); }
.reduziert .toast[data-zustand] { transform: translateY(var(--y)); }
.reduziert .toaster ol, .reduziert .zeiger, .reduziert .inhalt { transition: none; }
.reduziert .zeit { display: none; }
.reduziert .knopf, .reduziert .klein, .reduziert .zaehler { transition: none; scale: none; }
@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="#" aria-label="Tafel Nordstadt e. V., Hannover">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#002e6d"/><path d="M7 15h18a9 9 0 0 1-18 0Z" fill="#fff"/><path d="M12.5 12.5c0-3 1.5-5 3.5-6.5 2 1.5 3.5 3.5 3.5 6.5" fill="none" stroke="#bfd6ea" stroke-width="2.2" stroke-linecap="round"/></svg>
<span><b>Tafel Nordstadt</b><small>e. V. · Hannover</small></span>
</a>
<ul class="nav mono">
<li>Ausgabe</li><li>Mitmachen</li><li>Termine</li>
<li><span class="spenden-kopf">Spenden <span class="zaehler" data-zaehler aria-label="vorgemerkte Spenden">0</span></span></li>
</ul>
</header>
<main class="produkt">
<div class="info">
<p class="mono">Spenden · Ehrenamt · Hannover-Nordstadt</p>
<h1 class="display">Lebensmittel retten. Nachbarn satt machen.</h1>
<p class="text">Bäckereien und Märkte in der Nordstadt geben uns, was sie nicht mehr verkaufen. Wir sortieren, kühlen und verteilen es dreimal pro Woche am Lister Platz.</p>
<fieldset class="betrag">
<legend class="mono">Dein Beitrag</legend>
<label><input type="radio" name="betrag" value="15 €" data-wirkung="6 Tüten für Familien"><span>15 €</span></label>
<label><input type="radio" name="betrag" value="30 €" data-wirkung="12 Tüten für Familien" checked><span>30 €</span></label>
<label><input type="radio" name="betrag" value="60 €" data-wirkung="Kühlung für einen Ausgabetag"><span>60 €</span></label>
</fieldset>
<div class="wirkung"><strong data-wirkung-text>30 € = 12 Tüten für Familien</strong><span class="mono">Quittung per Mail</span></div>
<div class="aktionen">
<button class="knopf voll" data-ausloeser="spende">Jetzt spenden</button>
<button class="knopf rand" data-ausloeser="termin" aria-label="Schicht am Samstag merken">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4M9 14.5l2 2 4-4"/></svg>
</button>
</div>
<div class="nebenaktionen">
<button class="klein" data-ausloeser="iban">IBAN <span class="code">DE00 2501 …</span> kopieren</button>
<button class="klein" data-ausloeser="schichten">Freie Schichten anzeigen</button>
</div>
</div>
<div class="bildflaeche" aria-hidden="true">
<div class="oben"><p class="mono">Ausgabe Lister Platz</p><p class="mono">Di · Do · Sa</p></div>
<div class="zahlen">
<div><strong class="display">1.240</strong><span>Haushalte pro Woche</span></div>
<div><strong class="display">38 t</strong><span>Lebensmittel gerettet</span></div>
<div><strong class="display">64</strong><span>Ehrenamtliche</span></div>
<p class="quelle mono">Stand: Jahresbericht 2025</p>
</div>
<svg class="kiste" viewBox="0 0 400 300" preserveAspectRatio="xMidYMax meet">
<defs>
<pattern id="raster" width="16" height="16" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1.1" fill="#1d4f94"/></pattern>
<clipPath id="halb"><rect x="0" y="0" width="400" height="236"/></clipPath>
</defs>
<rect width="400" height="300" fill="url(#raster)" opacity=".7"/>
<circle cx="200" cy="236" r="150" fill="#063a80" clip-path="url(#halb)"/>
<circle cx="200" cy="236" r="104" fill="#0b4290" clip-path="url(#halb)"/>
<ellipse cx="200" cy="268" rx="150" ry="10" fill="#001d47"/>
<!-- Lauch -->
<g transform="rotate(-14 150 150)">
<rect x="140" y="58" width="22" height="110" rx="11" fill="#edf5fa"/>
<path d="M143 66c-6-22-2-40 8-52 2 18 4 30 4 52Z" fill="#7fa6d4"/>
<path d="M159 66c4-24 12-38 24-46-4 18-10 34-16 48Z" fill="#9cc0e6"/>
<path d="M151 68c0-26 4-44 10-58 4 20 2 40-2 58Z" fill="#bfd6ea"/>
</g>
<!-- Baguette -->
<g transform="rotate(18 268 128)">
<rect x="254" y="54" width="30" height="132" rx="15" fill="#dfdfd7"/>
<path d="M261 80l16-8M261 104l16-8M261 128l16-8M261 152l16-8" stroke="#a9a99c" stroke-width="3" stroke-linecap="round"/>
</g>
<!-- Milch -->
<g>
<rect x="178" y="92" width="46" height="80" fill="#ffffff"/>
<path d="M178 92l8-20h30l8 20Z" fill="#edf5fa"/>
<rect x="194" y="62" width="14" height="10" fill="#ffffff"/>
<rect x="178" y="118" width="46" height="22" fill="#0b4290"/>
<circle cx="201" cy="129" r="6" fill="#bfd6ea"/>
</g>
<!-- Brotlaib -->
<path d="M96 168c0-34 26-52 58-52s58 18 58 52Z" fill="#c9c9bd"/>
<path d="M126 132l10 18M152 122l6 22M180 130l-6 20" stroke="#a9a99c" stroke-width="3.5" stroke-linecap="round"/>
<!-- Äpfel und Kohl -->
<circle cx="250" cy="160" r="26" fill="#9cc0e6"/>
<path d="M232 152c10-10 26-10 36 0M236 166c8-8 20-8 28 0" fill="none" stroke="#7fa6d4" stroke-width="3" stroke-linecap="round"/>
<circle cx="292" cy="166" r="18" fill="#edf5fa"/>
<path d="M292 148c0-6 3-9 7-11" stroke="#bfd6ea" stroke-width="3" stroke-linecap="round" fill="none"/>
<path d="M300 141c6-4 12-2 14 2-6 3-10 3-14-2Z" fill="#7fa6d4"/>
<circle cx="112" cy="166" r="16" fill="#edf5fa"/>
<path d="M112 150c0-5 2-8 6-10" stroke="#bfd6ea" stroke-width="3" stroke-linecap="round" fill="none"/>
<!-- Kiste -->
<rect x="70" y="160" width="260" height="104" rx="4" fill="#3d6fb6"/>
<rect x="70" y="160" width="260" height="14" rx="3" fill="#5583c4"/>
<path d="M70 198h260M70 230h260" stroke="#2f5e9e" stroke-width="3"/>
<rect x="84" y="182" width="36" height="12" rx="6" fill="#002e6d"/>
<rect x="280" y="182" width="36" height="12" rx="6" fill="#002e6d"/>
<text x="200" y="224" text-anchor="middle" fill="#edf5fa" style="font: 800 30px 'Big Shoulders', sans-serif; letter-spacing: .14em">GERETTET</text>
<text x="200" y="252" text-anchor="middle" fill="#bfd6ea" style="font: 500 8.5px 'Geist Mono', monospace; letter-spacing: .26em">NORDSTADT · KISTE 0412</text>
</svg>
</div>
</main>
<footer class="fuss">
<div class="varianten" role="group" aria-label="Variante">
<span class="mono">Variante</span>
<button aria-pressed="true" data-variante="stapel">Stapel</button>
<button aria-pressed="false" data-variante="liste">Liste</button>
<button aria-pressed="false" data-variante="undo">Undo-Linie</button>
</div>
<p class="mono werte" data-werte></p>
</footer>
<section class="toaster" aria-label="Benachrichtigungen">
<ol data-toaster class="ol-zu"></ol>
</section>
<div class="sr" role="status" aria-live="polite" data-ansage></div>
<div class="sr" role="alert" data-alarm></div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#002e6d" stroke="#ffffff" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const ol = $('[data-toaster]'), ansage = $('[data-ansage]'), alarm = $('[data-alarm]');
const zaehlerEl = $('[data-zaehler]'), werteEl = $('[data-werte]'), zeiger = $('[data-zeiger]');
// Werte (Sonner, gemessen auf cursor.com und resend.com)
const GAP = 14; // px Versatz je älterer Ebene, auch Abstand im aufgefächerten Stapel
const SKAL = 0.05; // −5 % Skalierung je älterer Ebene
const SICHTBAR = 3; // höchstens drei Ebenen sichtbar
const LEBEN = 4000; // ms bis zum Auto-Dismiss, pausiert bei Hover/Fokus
const WISCHEN = 45; // px Wischweg zum Schließen
const DAUER = 400; // ms, ease
const SYM = {
tuete: '<svg viewBox="0 0 26 26"><path d="M7 4.5c1.5 1 2 3 2 5.5" stroke="#9cc0e6" stroke-width="2.2" stroke-linecap="round" fill="none"/><rect x="14" y="3" width="4.5" height="9" rx="2.2" fill="#dfdfd7" transform="rotate(16 16 8)"/><circle cx="11.5" cy="10" r="3.4" fill="#edf5fa"/><path d="M5 10.5h16l-1.6 12h-12.8Z" fill="#3d6fb6"/><path d="M6 15h14" stroke="#2f5e9e" stroke-width="1.6"/></svg>',
kalender: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3.5" y="5" width="17" height="15" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4M9 14.5l2 2 4-4"/></svg>',
kopie: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>',
fehler: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 8v5M12 16.5v.01"/><circle cx="12" cy="12" r="9"/></svg>',
beleg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 3h9l4 4v14H6Z"/><path d="M15 3v4h4M9 12h7M9 16h5"/></svg>',
hand: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M7 11V6.5a1.5 1.5 0 0 1 3 0V11M10 10V4.5a1.5 1.5 0 0 1 3 0V10M13 10V5.5a1.5 1.5 0 0 1 3 0V12M16 9.5a1.5 1.5 0 0 1 3 0V14a7 7 0 0 1-7 7h-.5A6.5 6.5 0 0 1 6 17.4L3.8 13.6a1.5 1.5 0 0 1 2.5-1.6L8 14"/></svg>',
zu: '<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.4"><path d="m2 2 6 6M8 2 2 8"/></svg>',
};
const gewaehlt = () => document.querySelector('[name=betrag]:checked') || document.querySelector('[name=betrag]');
const wirkungEl = $('[data-wirkung-text]');
document.querySelectorAll('[name=betrag]').forEach(r => r.addEventListener('change', () => { wirkungEl.textContent = `${r.value} = ${r.dataset.wirkung}`; }));
const ARTEN = {
spende: () => ({ bild: true, titel: 'Spende vorgemerkt', text: `${gewaehlt().value} einmalig · ${gewaehlt().dataset.wirkung}`, aktion: 'Rückgängig', tun: () => zaehlen(-1) }),
termin: () => ({ sym: 'kalender', titel: 'Schicht im Kalender', text: 'Sa, 7. November · Sortieren am Lister Platz, 9 bis 12 Uhr', aktion: 'Ansehen' }),
iban: () => ({ sym: 'kopie', titel: 'IBAN kopiert', text: 'Verwendungszweck: Spende Tafel Nordstadt' }),
schichten: () => ({ sym: 'fehler', art: 'fehler', titel: 'Schichtplan nicht erreichbar', text: 'Die Verbindung hakt. Gleich noch einmal?', aktion: 'Erneut', wichtig: true }),
quittung: () => ({ sym: 'beleg', titel: 'Deine Zuwendungsbestätigung 2025', text: 'Liegt als PDF in deinem Postfach.' }),
schicht: () => ({ sym: 'hand', titel: 'Neue Schicht frei: Ausgabe Mittwoch', text: 'Lister Platz, 14 bis 17 Uhr. Zwei Plätze offen.' }),
};
let toasts = []; // älteste zuerst
let variante = 'stapel', offen = false, fokus = false, angehalten = false, spenden = 0;
function zaehlen(d) {
spenden = Math.max(0, spenden + d); zaehlerEl.textContent = spenden;
if (!reduziert()) { zaehlerEl.classList.add('puls'); setTimeout(() => zaehlerEl.classList.remove('puls'), 150); }
}
function zeigen(name) {
const d = ARTEN[name]();
if (name === 'spende') zaehlen(1);
const li = document.createElement('li');
li.className = 'toast'; li.tabIndex = 0; li.dataset.zustand = 'ein';
if (d.art) li.dataset.art = d.art;
li.innerHTML = `<div class="inhalt"><span class="symbol${d.bild ? ' tuete' : ''}" aria-hidden="true">${d.bild ? SYM.tuete : SYM[d.sym]}</span>`
+ `<div><p class="titel"></p><p class="zeile2"></p></div>`
+ (d.aktion ? `<button class="aktion"></button>` : '<span></span>') + `</div>`
+ `<button class="zu" aria-label="Meldung schließen">${SYM.zu}</button><span class="zeit" aria-hidden="true"></span>`;
li.querySelector('.titel').textContent = d.titel;
li.querySelector('.zeile2').textContent = d.text;
if (d.aktion) li.querySelector('.aktion').textContent = d.aktion; // vor dem Messen, sonst stimmt die Höhe nicht
ol.appendChild(li);
li.style.height = 'auto';
const t = { el: li, h: li.getBoundingClientRect().height, weg: false };
li.style.height = t.h + 'px';
if (d.aktion) li.querySelector('.aktion').addEventListener('click', e => { e.stopPropagation(); if (d.tun) d.tun(); schliessen(t); });
li.querySelector('.zu').addEventListener('click', e => { e.stopPropagation(); schliessen(t); });
wischbar(t);
// Lebensdauer als Animation der Countdown-Linie: pausieren und fortsetzen in einem
t.uhr = li.querySelector('.zeit').animate([{ transform: 'scaleX(1)' }, { transform: 'scaleX(0)' }], { duration: LEBEN, easing: 'linear', fill: 'forwards' });
t.uhr.onfinish = () => schliessen(t);
toasts.push(t);
(d.wichtig ? alarm : ansage).textContent = `${d.titel}. ${d.text}`;
ordnen(); uhren();
requestAnimationFrame(() => requestAnimationFrame(() => { delete li.dataset.zustand; ordnen(); }));
}
function schliessen(t, wisch) {
if (t.weg) return;
t.weg = true; t.uhr.cancel();
t.el.dataset.zustand = wisch ? 'weg' : 'aus';
if (document.activeElement && t.el.contains(document.activeElement)) ol.focus?.();
ordnen();
setTimeout(() => { t.el.remove(); toasts = toasts.filter(x => x !== t); if (!toasts.length) { fokus = false; offen = variante === 'liste' && offen; } ordnen(); uhren(); }, reduziert() ? 150 : DAUER);
}
// Lage aller Toasts: zusammengeschoben (Stapel) oder aufgefächert (Hover, Fokus, Liste, reduziert)
function ordnen() {
const aktiv = toasts.filter(t => !t.weg), n = aktiv.length;
const auf = variante === 'liste' || offen || fokus || reduziert();
ol.classList.toggle('ol-zu', !auf);
const front = aktiv[n - 1];
let summe = 0, letztesY = 0;
aktiv.slice().reverse().forEach((t, i) => {
const el = t.el, sichtbar = i < SICHTBAR;
let y, s, h;
if (auf) { y = sichtbar ? -summe : letztesY; s = 1; h = t.h; if (sichtbar) { letztesY = y; summe += t.h + GAP; } }
else { y = -Math.min(i, SICHTBAR - 1) * GAP; s = 1 - Math.min(i, SICHTBAR) * SKAL; h = front.h; }
el.style.setProperty('--y', y + 'px');
el.style.setProperty('--s', s.toFixed(3));
el.style.setProperty('--i', auf ? 0 : i);
el.style.height = h + 'px';
el.style.zIndex = n - i;
el.dataset.front = i === 0;
if (sichtbar) delete el.dataset.verborgen; else el.dataset.verborgen = '';
});
// weggehende Toasts behalten ihre Lage, die Front-Kennung bleibt für die Austrittsrichtung
const hoehe = !n ? 0 : auf ? summe - GAP : front.h + (Math.min(n, SICHTBAR) - 1) * GAP;
ol.style.height = Math.max(0, hoehe) + 'px';
ol.parentElement.classList.toggle('mit-linie', variante === 'undo');
}
function uhren() {
const halt = offen || fokus || angehalten || document.hidden;
toasts.forEach(t => { if (t.weg) return; if (halt) t.uhr.pause(); else if (t.uhr.playState === 'paused') t.uhr.play(); });
}
// Maus: Hover fächert auf und hält die Zeit an
const istMaus = e => e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung');
ol.addEventListener('pointerenter', e => { if (istMaus(e)) { offen = true; ordnen(); uhren(); } });
ol.addEventListener('pointerleave', e => { if (istMaus(e)) { offen = false; ordnen(); uhren(); } });
// Touch: Tippen auf den Stapel fächert auf bzw. zu
ol.addEventListener('click', e => {
if (e.target.closest('button') || ol.dataset.gewischt) return;
if (matchMedia('(hover: none)').matches) { offen = !offen; ordnen(); uhren(); }
});
// Tastatur: Fokus im Stapel fächert auf und hält die Zeit an (WCAG 2.2.1)
ol.addEventListener('focusin', () => { fokus = true; ordnen(); uhren(); });
ol.addEventListener('focusout', e => { if (!ol.contains(e.relatedTarget)) { fokus = false; ordnen(); uhren(); } });
ol.addEventListener('keydown', e => { if (e.key === 'Escape') { const t = toasts.find(x => x.el.contains(e.target)); if (t) schliessen(t); } });
document.addEventListener('visibilitychange', uhren);
function wischbar(t) {
let x0 = null, dx = 0, id = null;
t.el.addEventListener('pointerdown', e => {
if (e.target.closest('button') || html.classList.contains('vorfuehrung')) return;
x0 = e.clientX; dx = 0; id = e.pointerId; t.el.setPointerCapture(id); t.el.dataset.zieht = '';
});
t.el.addEventListener('pointermove', e => {
if (x0 === null || e.pointerId !== id) return;
dx = e.clientX - x0;
t.el.style.setProperty('--dx', (Math.abs(dx) > WISCHEN ? dx : dx * 0.9) + 'px');
t.el.style.opacity = Math.max(.3, 1 - Math.abs(dx) / 260);
});
const ende = () => {
if (x0 === null) return;
x0 = null; delete t.el.dataset.zieht; t.el.style.opacity = '';
if (Math.abs(dx) > WISCHEN) { ol.dataset.gewischt = '1'; setTimeout(() => delete ol.dataset.gewischt, 50); schliessen(t, true); }
else t.el.style.setProperty('--dx', '0px');
};
t.el.addEventListener('pointerup', ende);
t.el.addEventListener('pointercancel', ende);
}
// Auslöser auf der Seite
document.querySelectorAll('[data-ausloeser]').forEach(b => b.addEventListener('click', () => zeigen(b.dataset.ausloeser)));
// Varianten
function werteZeigen() {
const basis = `<b>ease</b> cubic-bezier(.25,.1,.25,1) · <b>400 ms</b> · Auto-Dismiss <b>4000 ms</b>, pausiert bei Hover/Fokus`;
const art = {
stapel: ` · Stapel <b>−5 %</b> und <b>14 px</b> je Ebene · max. <b>3</b> sichtbar · Hover fächert auf`,
liste: ` · Liste, Abstand <b>14 px</b> · max. <b>3</b> sichtbar · keine Tiefenstaffel`,
undo: ` · Stapel <b>−5 %</b> / <b>14 px</b> · Countdown-Linie <b>linear 4000 ms</b>`,
}[variante];
werteEl.innerHTML = basis + art + ` · Wischen ab <b>45 px</b>`;
}
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => {
variante = b.dataset.variante;
document.querySelectorAll('[data-variante]').forEach(x => x.setAttribute('aria-pressed', x === b));
werteZeigen(); ordnen();
if (!toasts.some(t => !t.weg)) { zeigen('spende'); setTimeout(() => zeigen('iban'), 450); setTimeout(() => zeigen('termin'), 900); }
}));
werteZeigen();
// ---------- Vorführung ----------
let plan = [];
const spaeter = (ms, f) => plan.push(setTimeout(f, ms));
function zeigerAuf(el, fx = .5, fy = .5) {
const r = typeof el === 'string' ? $(el).getBoundingClientRect() : el.getBoundingClientRect();
zeiger.style.transform = `translate(${r.left + r.width * fx - 2}px, ${r.top + r.height * fy - 1}px)`;
}
function druecken(sel) {
const b = $(sel); b.classList.add('gedrueckt');
setTimeout(() => b.classList.remove('gedrueckt'), 150);
zeigen(b.dataset.ausloeser);
}
function allesWeg() {
toasts.forEach(t => { t.uhr.cancel(); t.el.remove(); }); toasts = [];
offen = false; fokus = false; spenden = 0; zaehlerEl.textContent = 0; ordnen();
}
function stopp() { plan.forEach(clearTimeout); plan = []; }
function vorfuehren() {
stopp(); angehalten = false; allesWeg();
html.classList.add('vorfuehrung');
zeiger.style.transition = 'none';
zeigerAuf('.info', .35, 1.08); zeiger.getBoundingClientRect();
zeiger.style.transition = '';
spaeter(250, () => zeigerAuf('[data-ausloeser=spende]', .62, .6));
spaeter(850, () => druecken('[data-ausloeser=spende]'));
spaeter(1100, () => zeigerAuf('[data-ausloeser=iban]', .55, .6));
spaeter(1550, () => druecken('[data-ausloeser=iban]'));
spaeter(1800, () => zeigerAuf('[data-ausloeser=termin]', .5, .55));
spaeter(2250, () => druecken('[data-ausloeser=termin]'));
spaeter(2500, () => zeigerAuf('[data-ausloeser=schichten]', .5, .6));
spaeter(2950, () => druecken('[data-ausloeser=schichten]'));
spaeter(3700, () => zeigerAuf(ol, .5, .35)); // auf den Stapel
spaeter(4200, () => { offen = true; ordnen(); uhren(); });
spaeter(5100, () => zeigerAuf(ol, .62, .1));
spaeter(6700, () => { zeigerAuf('.info', .9, .4); offen = false; ordnen(); uhren(); });
spaeter(10600, vorfuehren);
}
function pause() { stopp(); angehalten = true; uhren(); }
function weiter() { angehalten = false; uhren(); if (html.classList.contains('vorfuehrung')) vorfuehren(); }
function neustart() {
stopp(); angehalten = false; html.classList.remove('vorfuehrung'); allesWeg();
// Ausgangslage beim Öffnen: zwei Meldungen vom letzten Besuch, dann eine Spende
spaeter(300, () => zeigen('quittung'));
spaeter(800, () => zeigen('schicht'));
spaeter(1300, () => zeigen('spende'));
}
addEventListener('resize', () => {
toasts.forEach(t => { const alt = t.el.style.height; t.el.style.height = 'auto'; t.h = t.el.getBoundingClientRect().height; t.el.style.height = alt; });
ordnen();
});
if (!new URLSearchParams(location.search).has('vorschau')) neustart();
})();
</script>
</body>
</html>