Zum Inhalt
← Mikroanimationen

Konfetti / Erfolgs-Feier

Spendenformular eines Küstenvereins: Nach dem Absenden wechselt die Karte zum Dank, Konfetti in Vereinsfarben schießt aus dem Spenden-Knopf, der Spendenbalken wächst.

Nach einem echten Meilenstein fällt einmal ein Partikelregen. Beispiele: Kauf abgeschlossen, Konto erstellt, Spende gesendet. Die Teile starten am auslösenden Knopf oder am Bildschirmrand. Sie fliegen gestreut nach oben, werden langsamer, fallen mit Schwerkraft und flattern dabei. Farben kommen aus der Markenpalette, Formen können auch Emoji, Logo oder Text sein. Nach ein bis drei Sekunden verschwindet die Leinwand. Gleichzeitig bestätigt ein klarer Text den Erfolg.

Ein Höhepunkt am Ende eines Ablaufs bleibt in Erinnerung (Peak-End-Effekt) und belohnt. Konfetti in Markenfarben stärkt die Wiedererkennung.

Wann einsetzen

Abschluss des Onboardings, erster veröffentlichter Inhalt, erfolgreiche Bestellung oder Spende, Gewinnspiele, Lern-Apps, Easter Eggs wie ein Klick aufs Logo.

Wann nicht

Bei Routineaktionen wie jedem Speichern. In seriösen Kontexten wie Bank, Gesundheit, Behörden oder B2B-Rechnungen. Bei Fehlern oder unsicherem Ergebnis. Nicht mehrfach pro Sitzung.

Varianten

  • Burst vom Knopf aus (Ursprung = Knopfposition)
  • Seitenkanonen links und rechts (zwei Schüsse mit Winkel 60° und 120°)
  • Fallender Regen über mehrere Sekunden
  • Emoji- oder Logo-Konfetti (shapeFromText, shapeFromPath)
  • Sterne-Burst am Element (siehe like-herz-burst)
  • Lottie- oder Rive-Animation statt Partikelsystem

Umsetzung

Mit canvas-confetti: die Bibliothek erst beim Erfolg per import() laden, dann confetti({ particleCount: 80, spread: 70, origin: { x: knopfX / innerWidth, y: knopfY / innerHeight }, colors: ['#hex1', '#hex2'], disableForReducedMotion: true }). Seitenkanonen sind zwei Aufrufe mit angle 60 und 120 und origin.x 0 bzw. 1. Eigene Formen über confetti.shapeFromText({ text: '🎉', scalar: 2 }). Für flüssiges Scrollen confetti.create(canvas, { useWorker: true }). Ohne Bibliothek reicht ein Canvas mit einfacher Physik: Tempo pro Tick mit 0,9 multiplizieren, Schwerkraft konstant addieren, nach 200 Ticks ausblenden. Parallel die Erfolgsmeldung im DOM mit role=status zeigen.

Robust umsetzen

Telefon. particleCount halbieren, scalar 0.8. useWorker nutzen, damit Scrollen nicht ruckelt.

Ohne Bewegung. Konfetti entfällt komplett (disableForReducedMotion: true). Die Erfolgsmeldung als Text bleibt, gern mit einer statischen Illustration.

Barrierefrei. Canvas mit aria-hidden und pointer-events: none. Den Erfolg zusätzlich als Text mit role=status melden. Keine Blitze oder Flackern (WCAG 2.3.1). Den Fokus nicht verschieben.

Code

<!DOCTYPE html>
<!--
  Konfetti / Erfolgs-Feier
  Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/konfetti-erfolg/
  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>Konfetti / Erfolgs-Feier · 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 */
  --grund: #ffffff; --flaeche: #edf5fa; --hell: #b8d8ea; --blau: #008fc3; --marke: #002e6d;
  --text: #222520; --meta: #5d625b; --warm: #dfdfd7; --linie: #d6dde2; --signal: #ffca0a;
  --inout: cubic-bezier(0.645, 0.045, 0.355, 1);   /* easeInOutCubic: große Zustandswechsel */
  --feder: cubic-bezier(0.34, 1.56, 0.64, 1);      /* Überschwinger nur beim Dank */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Archivo', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: hidden; }
button, input { font: inherit; color: inherit; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.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; }

/* Kopf */
.kopf { display: flex; align-items: center; gap: 28px; height: 68px; padding: 0 clamp(16px, 4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: center; gap: 10px; color: var(--marke); text-decoration: none; margin-right: auto; }
.marke svg { width: 34px; height: 34px; flex: none; }
.marke b { display: block; font: 800 22px/1 'Big Shoulders', sans-serif; letter-spacing: .02em; text-transform: uppercase; }
.marke small { display: block; font-size: 11.5px; color: var(--meta); margin-top: 2px; }
.nav { display: flex; gap: 26px; list-style: none; padding: 0; }
.nav a { color: var(--text); text-decoration: none; font-size: 14.5px; font-weight: 500; }
.nav a:hover { color: var(--marke); }
.spenden { display: inline-flex; align-items: center; gap: 8px; height: 42px; padding: 0 18px; border-radius: 3px; background: var(--signal); color: var(--text); font-weight: 600; font-size: 15px; text-decoration: none; transition: background-color .15s linear; }
.spenden:hover { background: #f2bc00; }

/* Bühne */
.buehne { display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(0, .88fr); gap: clamp(28px, 4vw, 64px); max-width: 1320px; margin: 0 auto; padding: clamp(28px, 4vw, 56px) clamp(16px, 4vw, 48px) clamp(24px, 3vw, 40px); }
.eyebrow { display: flex; align-items: center; gap: 10px; color: var(--marke); }
.eyebrow::before { content: ''; width: 18px; height: 2px; background: var(--blau); }
h1 { font-size: clamp(2.75rem, 1.6rem + 3.6vw, 5.25rem); line-height: .95; color: var(--marke); margin: 14px 0 18px; text-wrap: balance; }
.lead { font-size: 1.0625rem; line-height: 1.6; max-width: 46ch; text-wrap: pretty; }

.bild { position: relative; margin-top: clamp(20px, 2.6vw, 32px); border-radius: 4px; overflow: hidden; background: var(--flaeche); aspect-ratio: 16 / 7.4; }
.bild svg { display: block; width: 100%; height: 100%; }
.bild figcaption { position: absolute; left: 12px; bottom: 12px; padding: 6px 9px 5px; background: rgba(255, 255, 255, .92); color: var(--marke); border-radius: 2px; }

.ziel { margin-top: 18px; }
.ziel-kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.ziel-zahl { font: 800 clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem)/1 'Big Shoulders', sans-serif; color: var(--marke); font-variant-numeric: tabular-nums; }
.ziel-zahl small { font: 500 .9375rem 'Archivo', sans-serif; color: var(--meta); margin-left: 6px; }
.balken { position: relative; height: 10px; margin-top: 10px; background: var(--flaeche); border-radius: 5px; overflow: hidden; }
.balken i { position: absolute; inset: 0; background: var(--marke); border-radius: inherit; transform-origin: left; transform: scaleX(var(--anteil, .7368)); transition: transform 1s var(--inout); }
.balken b { position: absolute; top: 0; bottom: 0; left: 0; width: 100%; transform-origin: left; transform: scaleX(var(--anteil, .7368)); pointer-events: none; }
.balken b::after { content: ''; position: absolute; right: 0; top: 0; bottom: 0; width: 0; }
.ziel-fuss { display: flex; justify-content: space-between; gap: 12px; margin-top: 8px; font-size: .8125rem; color: var(--meta); font-variant-numeric: tabular-nums; }

/* Spendenkarte */
.karte { position: relative; align-self: start; background: var(--grund); border: 1px solid var(--linie); border-radius: 6px; box-shadow: 0 1px 0 rgba(0, 46, 109, .04), 0 24px 48px -28px rgba(0, 46, 109, .35); overflow: hidden; }
.karte-kopf { padding: 22px 24px 0; }
.karte h2 { font-size: 1.875rem; line-height: 1; color: var(--marke); }
.form, .dank { padding: 18px 24px 24px; transition: opacity .6s var(--inout), translate .6s var(--inout); }
.umschalter { position: relative; display: grid; grid-template-columns: 1fr 1fr; padding: 4px; background: var(--flaeche); border-radius: 999px; border: 0; }
.umschalter legend { position: absolute; }
.umschalter input { position: absolute; opacity: 0; pointer-events: none; }
.umschalter label { position: relative; z-index: 1; text-align: center; padding: 9px 0; font-size: 14.5px; font-weight: 600; color: var(--meta); cursor: pointer; transition: color .15s linear; }
.umschalter input:checked + label { color: var(--grund); }
.umschalter input:focus-visible + label { outline: 2px solid var(--blau); outline-offset: 2px; border-radius: 999px; }
.umschalter .gleiter { position: absolute; z-index: 0; top: 4px; bottom: 4px; left: 4px; width: calc(50% - 4px); background: var(--marke); border-radius: 999px; transition: translate .2s var(--inout); }
.umschalter:has(#rhythmus-monat:checked) .gleiter { translate: 100% 0; }

.betraege { display: grid; gap: 8px; margin-top: 16px; border: 0; padding: 0; }
.betraege legend { margin-bottom: 8px; }
.betrag input { position: absolute; opacity: 0; pointer-events: none; }
.betrag label { display: grid; grid-template-columns: 4.6rem 1fr 18px; align-items: center; gap: 12px; padding: 11px 14px; border: 1px solid var(--linie); border-radius: 4px; cursor: pointer; transition: border-color .15s linear, background-color .15s linear; }
.betrag label:hover { border-color: var(--hell); }
.betrag strong { font: 800 1.75rem/1 'Big Shoulders', sans-serif; color: var(--marke); font-variant-numeric: tabular-nums; }
.betrag span { font-size: .875rem; line-height: 1.35; color: var(--meta); }
.betrag i { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid #9fb0bd; transition: border-width .15s linear, border-color .15s linear; }
.betrag input:checked + label { border-color: var(--marke); background: var(--flaeche); }
.betrag input:checked + label span { color: var(--text); }
.betrag input:checked + label i { border: 5.5px solid var(--marke); }
.betrag input:focus-visible + label { outline: 2px solid var(--blau); outline-offset: 2px; }

.feld { display: grid; gap: 6px; margin-top: 14px; }
.feld input { height: 44px; padding: 0 12px; border: 1px solid var(--linie); border-radius: 4px; background: var(--grund); font-size: 1rem; transition: border-color .15s linear; }
.feld input:focus { outline: none; border-color: var(--marke); box-shadow: 0 0 0 3px rgba(0, 143, 195, .22); }

.senden { position: relative; display: flex; align-items: center; justify-content: center; gap: 10px; width: 100%; height: 52px; margin-top: 16px; border: 0; border-radius: 3px; background: var(--signal); color: var(--text); font-size: 1.0625rem; font-weight: 650; cursor: pointer; transition: background-color .15s linear, scale .15s linear; }
.senden:hover { background: #f2bc00; }
.senden:active, .senden.gedrueckt { scale: .98; }
.senden:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
.senden .rad { display: none; width: 18px; height: 18px; border-radius: 50%; border: 2px solid rgba(34, 37, 32, .25); border-top-color: var(--text); animation: drehen .7s linear infinite; }
.senden.laedt { pointer-events: none; }
.senden.laedt .rad { display: block; }
@keyframes drehen { to { rotate: 1turn; } }
.hinweis { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 12px; font-size: .8125rem; color: var(--meta); text-align: center; }

/* Dank: liegt über dem Formular, gleiche Zelle */
.karte-inhalt { display: grid; }
.karte-inhalt > * { grid-area: 1 / 1; }
.dank { opacity: 0; translate: 0 16px; visibility: hidden; display: flex; flex-direction: column; justify-content: center; padding-bottom: 40px; }
.karte.fertig .form { opacity: 0; translate: 0 -16px; visibility: hidden; transition: opacity .3s var(--inout), translate .3s var(--inout), visibility 0s .3s; }
.karte.fertig .dank { opacity: 1; translate: 0 0; visibility: visible; transition: opacity .6s var(--inout) .2s, translate .6s var(--inout) .2s, visibility 0s .2s; }
.siegel { width: 112px; height: 112px; margin: 6px 0 14px; }
.karte.fertig .siegel { animation: siegel .6s var(--feder) .15s both; }
@keyframes siegel { from { scale: .6; rotate: -12deg; opacity: 0; } to { scale: 1; rotate: 0deg; opacity: 1; } }
.dank h3 { font-size: clamp(2.5rem, 2rem + 1.4vw, 3.25rem); line-height: .92; color: var(--marke); }
.dank p { margin-top: 12px; font-size: 1.0625rem; line-height: 1.55; max-width: 34ch; }
.dank .quittung { font-size: .875rem; color: var(--meta); margin-top: 10px; }
.dank-wege { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
.dank-wege a { display: inline-flex; align-items: center; height: 40px; padding: 0 14px; border: 1px solid var(--marke); border-radius: 3px; color: var(--marke); text-decoration: none; font-size: .9375rem; font-weight: 600; transition: background-color .15s linear, color .15s linear; }
.dank-wege a:hover { background: var(--marke); color: var(--grund); }
.dank-wege a.leise { border-color: var(--linie); color: var(--text); }
.dank-wege a.leise:hover { background: var(--flaeche); color: var(--marke); }

/* Konfetti-Leinwand: über allem, nimmt keine Klicks */
.konfetti { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 50; pointer-events: none; display: none; }
.konfetti.an { display: block; }

/* Demo-Bedienung */
.pult { max-width: 1320px; margin: 0 auto; padding: 0 clamp(16px, 4vw, 48px) 36px; }
.pult-innen { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; padding-top: 14px; border-top: 1px solid var(--linie); }
.modi { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.modi .mono { margin-right: 6px; }
.modi button { font: 500 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s linear, background-color .15s linear, border-color .15s linear; }
.modi button:hover { color: var(--marke); border-color: #a9b6c0; }
.modi button[aria-pressed='true'] { background: var(--marke); color: var(--grund); border-color: var(--marke); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.werte b { color: var(--marke); font-weight: 500; }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .7s var(--inout), scale .15s linear; }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueck { scale: .82; }

/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .pult, .vorschau .nav li:nth-child(n+3) { display: none; }
.vorschau .buehne { padding-top: 30px; }
.vorschau .lead { display: none; }

/* Reduzierte Bewegung: kein Konfetti, Dank blendet nur ein */
.reduziert .form, .reduziert .dank, .reduziert .balken i, .reduziert .zeiger { transition: opacity .15s linear !important; translate: none !important; }
.reduziert .karte.fertig .siegel { animation: none; }
.reduziert .umschalter .gleiter { transition: none; }
.reduziert .senden .rad { animation-duration: 1.6s; }

@media (max-width: 1080px) { .nav { display: none; } }
@media (max-width: 860px) {
  .buehne { grid-template-columns: 1fr; }
  .bild { aspect-ratio: 16 / 9; }
}
@media (max-width: 520px) {
  .kopf { gap: 12px; height: 60px; }
  .marke small { display: none; }
  .spenden { height: 38px; padding: 0 14px; font-size: 14px; }
  .karte-kopf { padding: 18px 18px 0; }
  .form, .dank { padding: 16px 18px 20px; }
  .betrag label { grid-template-columns: 3.8rem 1fr 18px; gap: 10px; padding: 10px 12px; }
  .bild figcaption { font-size: 10px; }
}
@media (hover: none) { .zeiger { display: none !important; } .senden:hover, .spenden:hover { background: var(--signal); } }
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#start">
    <svg viewBox="0 0 40 40" aria-hidden="true"><rect width="40" height="40" rx="3" fill="#002e6d"/><path d="M6 25c4-3 7-3 10 0s7 3 10 0 7-3 10 0" fill="none" stroke="#b8d8ea" stroke-width="2.6" stroke-linecap="round"/><path d="M14 22c0-6 1-10 3-13M19 22c0-5 1-8 4-11M24 22c0-4-1-7-3-10" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round"/><path d="M6 31c4-3 7-3 10 0s7 3 10 0 7-3 10 0" fill="none" stroke="#008fc3" stroke-width="2.6" stroke-linecap="round"/></svg>
    <span><b>Küstenpaten</b><small>e.V. · Rostock</small></span>
  </a>
  <ul class="nav">
    <li><a href="#projekte">Projekte</a></li><li><a href="#mitmachen">Mitmachen</a></li>
    <li><a href="#transparenz">Transparenz</a></li><li><a href="#verein">Über uns</a></li>
  </ul>
  <a class="spenden" href="#spenden">Spenden</a>
</header>

<main class="buehne" id="start">
  <div class="links">
    <p class="eyebrow mono">Pflanzaktion Diedrichshagen · Herbst 2026</p>
    <h1 class="display">Pflanz mit uns die Düne von morgen.</h1>
    <p class="lead">Jeder Sturm trägt Sand ab. Strandhafer und Sandfangzäune halten ihn fest. Mit Schulklassen und Freiwilligen sichern wir in diesem Herbst 600 Meter Vordüne zwischen Warnemünde und Diedrichshagen.</p>

    <figure class="bild">
      <svg viewBox="0 0 800 370" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Vordüne mit frisch gesetztem Strandhafer und Sandfangzaun, dahinter die Ostsee und der Leuchtturm von Warnemünde">
        <defs>
          <linearGradient id="k-himmel" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfe3ef"/><stop offset=".7" stop-color="#edf5fa"/><stop offset="1" stop-color="#f6f3ea"/></linearGradient>
          <linearGradient id="k-see" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3f86ad"/><stop offset="1" stop-color="#0d4f86"/></linearGradient>
          <linearGradient id="k-sand" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9e3d2"/><stop offset="1" stop-color="#cfc6b0"/></linearGradient>
          <linearGradient id="k-sand2" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#ddd5bf"/><stop offset="1" stop-color="#bfb59c"/></linearGradient>
        </defs>
        <rect width="800" height="370" fill="url(#k-himmel)"/>
        <g fill="#ffffff" opacity=".8"><ellipse cx="180" cy="62" rx="70" ry="9"/><ellipse cx="226" cy="54" rx="40" ry="8"/><ellipse cx="610" cy="88" rx="90" ry="8"/></g>
        <!-- Leuchtturm Warnemünde, weit hinten -->
        <g transform="translate(648 112)">
          <path d="M-7 70 -5 6h10l2 64Z" fill="#f4f1ea"/>
          <path d="M-6.4 52h12.8M-6 34h12M-5.6 18h11.2" stroke="#b9c7d1" stroke-width="2"/>
          <rect x="-8" y="0" width="16" height="6" fill="#55708a"/><rect x="-4" y="-8" width="8" height="8" fill="#e9e1c9"/><path d="M-5-8 0-14 5-8Z" fill="#55708a"/>
        </g>
        <rect y="178" width="800" height="70" fill="url(#k-see)"/>
        <g stroke="#cfe3ef" stroke-linecap="round" opacity=".55"><path d="M60 196h70M210 206h40M330 192h90M520 210h60M640 198h80M120 222h50M420 226h70"/></g>
        <path d="M0 236c90-10 190-6 300 2s260 6 500-10v30H0Z" fill="#f2ecdc"/>
        <path d="M0 252c80-8 180-4 290 4 120 9 250 4 510-16v130H0Z" fill="url(#k-sand)"/>
        <!-- Vordüne -->
        <path d="M0 300c60-34 150-58 250-52 90 5 150 32 230 30s170-40 320-34v126H0Z" fill="url(#k-sand2)"/>
        <!-- Sandfangzaun -->
        <g data-zaun stroke="#7a6248" stroke-width="3" stroke-linecap="round"></g>
        <!-- Strandhafer -->
        <g data-hafer fill="none" stroke-linecap="round"></g>
        <path d="M0 340c120-12 260-6 400 4s270 4 400-6v32H0Z" fill="#e7dfca"/>
        <!-- Bohlenweg über die Düne -->
        <path d="M352 370 384 286h34l44 84Z" fill="#b59a76"/>
        <g stroke="#8e7458" stroke-width="2"><path d="M360 352h96M366 336h82M371 322h70M375 309h60M379 297h50"/></g>
        <path d="M384 286 352 370M418 286l44 84" stroke="#6e5840" stroke-width="2.4"/>
        <!-- Möwen -->
        <g fill="none" stroke="#002e6d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M300 96q7-7 13 0q6-7 13 0"/><path d="M346 120q5-5 9 0q4-5 9 0"/><path d="M520 70q6-6 11 0q5-6 11 0"/></g>
      </svg>
      <figcaption class="mono">Strandaufgang 33 · Oktober 2026</figcaption>
    </figure>

    <div class="ziel">
      <div class="ziel-kopf">
        <p class="ziel-zahl" data-summe>18.420 €<small>von 25.000 €</small></p>
        <p class="mono">Spendenziel 600 m Vordüne</p>
      </div>
      <div class="balken" role="progressbar" aria-label="Spendenziel" aria-valuemin="0" aria-valuemax="25000" aria-valuenow="18420" data-balken><i></i></div>
      <div class="ziel-fuss"><span data-anzahl>412 Spenderinnen und Spender</span><span>Stand 1. November 2026</span></div>
    </div>
  </div>

  <section class="karte" id="spenden" aria-labelledby="spenden-titel" data-karte>
    <div class="karte-kopf">
      <p class="mono">Spende für die Pflanzaktion</p>
    </div>
    <div class="karte-inhalt">
      <form class="form" data-form novalidate>
        <h2 class="display" id="spenden-titel" style="margin-bottom:16px">Deine Spende wirkt</h2>
        <fieldset class="umschalter">
          <legend class="sr">Rhythmus</legend>
          <input type="radio" name="rhythmus" id="rhythmus-einmal" value="einmalig" checked><label for="rhythmus-einmal">Einmalig</label>
          <input type="radio" name="rhythmus" id="rhythmus-monat" value="monatlich"><label for="rhythmus-monat">Monatlich</label>
          <span class="gleiter" aria-hidden="true"></span>
        </fieldset>
        <fieldset class="betraege">
          <legend class="mono">Betrag wählen</legend>
          <div class="betrag"><input type="radio" name="betrag" id="b15" value="15" checked><label for="b15"><strong>15 €</strong><span>50 Halme Strandhafer, von Hand gesetzt</span><i aria-hidden="true"></i></label></div>
          <div class="betrag"><input type="radio" name="betrag" id="b30" value="30"><label for="b30"><strong>30 €</strong><span>12 Meter Sandfangzaun aus Kiefernholz</span><i aria-hidden="true"></i></label></div>
          <div class="betrag"><input type="radio" name="betrag" id="b60" value="60"><label for="b60"><strong>60 €</strong><span>Ein Pflanztag mit einer Schulklasse</span><i aria-hidden="true"></i></label></div>
        </fieldset>
        <label class="feld"><span class="mono">Vorname für die Quittung</span><input type="text" name="vorname" value="Ricarda" autocomplete="given-name"></label>
        <button class="senden" type="submit" data-senden><span class="rad" aria-hidden="true"></span><span data-knopftext>15 € spenden</span></button>
        <p class="hinweis">Gemeinnützig anerkannt · Spendenquittung per E-Mail</p>
      </form>

      <div class="dank" data-dank aria-hidden="true">
        <svg class="siegel" viewBox="0 0 120 120" aria-hidden="true">
          <circle cx="60" cy="60" r="56" fill="#edf5fa"/>
          <circle cx="60" cy="60" r="56" fill="none" stroke="#002e6d" stroke-width="2" stroke-dasharray="3 5"/>
          <path d="M22 82c10-6 18-6 26 0s18 6 26 0 18-6 26 0" fill="none" stroke="#008fc3" stroke-width="4" stroke-linecap="round"/>
          <path d="M44 76c0-14 2-26 8-36M58 76c0-16 1-28 6-42M70 76c0-12-2-22-8-30M80 76c0-9 1-17 5-23" fill="none" stroke="#002e6d" stroke-width="3.6" stroke-linecap="round"/>
          <path d="M28 92c10-6 18-6 26 0s18 6 26 0 18-6 26 0" fill="none" stroke="#b8d8ea" stroke-width="4" stroke-linecap="round"/>
        </svg>
        <h3 class="display" data-dank-titel>Danke, Ricarda!</h3>
        <p data-dank-text>Deine 15 € werden zu 50 Halmen Strandhafer in Diedrichshagen.</p>
        <p class="quittung">Die Spendenquittung kommt in den nächsten Minuten per E-Mail. Im Frühjahr schicken wir Fotos von deinem Abschnitt.</p>
        <div class="dank-wege">
          <a href="#teilen">Freunde einladen</a>
          <a class="leise" href="#pflanztag">Beim Pflanztag mitmachen</a>
        </div>
      </div>
    </div>
  </section>
</main>

<div class="pult">
  <div class="pult-innen">
    <div class="modi" role="group" aria-label="Variante">
      <span class="mono">Variante</span>
      <button type="button" data-variante="burst" aria-pressed="true">Burst</button>
      <button type="button" data-variante="kanonen" aria-pressed="false">Seitenkanonen</button>
      <button type="button" data-variante="regen" aria-pressed="false">Regen</button>
    </div>
    <p class="werte mono" data-werte></p>
  </div>
</div>

<p class="sr" role="status" aria-live="polite" data-ansage></p>
<canvas class="konfetti" aria-hidden="true" data-konfetti></canvas>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2.5 1.5 17 10l-6.6 1.4-3.2 6.1Z" fill="#002e6d" stroke="#ffffff" stroke-width="1.3" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const schmal = () => matchMedia('(max-width: 640px), (hover: none)').matches;

  /* Bildwelt: Strandhafer und Zaun werden einmal mit festem Zufall gesetzt */
  (function bild() {
    let s = 7; const zufall = () => (s = (s * 16807) % 2147483647) / 2147483647;
    const ns = 'http://www.w3.org/2000/svg', hafer = document.querySelector('[data-hafer]'), zaun = document.querySelector('[data-zaun]');
    const duene = x => 300 - 34 * Math.sin(Math.PI * Math.min(1, x / 500)) + (x > 480 ? (x - 480) * -.02 : 0);
    for (let x = 30; x < 800; x += 46) {
      const y = duene(x) + 6, l = document.createElementNS(ns, 'path');
      l.setAttribute('d', `M${x} ${y + 2}v-26`); zaun.appendChild(l);
    }
    const quer = document.createElementNS(ns, 'path');
    let d = ''; for (let x = 30; x < 800; x += 46) d += `${x === 30 ? 'M' : 'L'}${x} ${duene(x) - 8}`;
    quer.setAttribute('d', d); quer.setAttribute('fill', 'none'); quer.setAttribute('stroke-width', '2'); zaun.appendChild(quer);
    for (let i = 0; i < 150; i++) {
      const x = zufall() * 800, y = duene(x) + 14 + zufall() * 40, h = 14 + zufall() * 26, k = (zufall() - .3) * 12;
      const p = document.createElementNS(ns, 'path');
      p.setAttribute('d', `M${x.toFixed(1)} ${y.toFixed(1)}q${(k * .3).toFixed(1)} ${(-h * .6).toFixed(1)} ${k.toFixed(1)} ${(-h).toFixed(1)}`);
      p.setAttribute('stroke', ['#6f8a4f', '#8da064', '#5b7a45', '#a7ae78'][i % 4]);
      p.setAttribute('stroke-width', (1.2 + zufall() * 1.2).toFixed(2));
      hafer.appendChild(p);
    }
  })();

  /* ---------- Konfetti: eigene Partikel-Physik mit den Standardwerten von canvas-confetti ---------- */
  const leinwand = document.querySelector('[data-konfetti]'), ctx = leinwand.getContext('2d');
  const FARBEN = ['#002e6d', '#002e6d', '#008fc3', '#008fc3', '#b8d8ea', '#ffffff', '#ffca0a', '#dfdfd7'];
  const STANDARD = { anzahl: 80, winkel: 90, streuung: 70, tempo: 45, abnahme: .9, schwerkraft: 1, drift: 0, ticks: 200, skala: 1 };
  const VARIANTEN = {
    burst:   { schuesse: [{ ursprung: 'knopf', anzahl: 80, winkel: 90, streuung: 70 }, { ursprung: 'knopf', anzahl: 40, winkel: 90, streuung: 110, tempo: 30, versatz: 180 }],
               text: ['Ursprung <b>Spenden-Knopf</b>', '<b>80 + 40</b> Teile, 2. Schuss <b>180 ms</b> später', 'Streuung <b>70° / 110°</b>', 'startVelocity <b>45 / 30</b>'] },
    kanonen: { schuesse: [{ ursprung: [0, .72], anzahl: 60, winkel: 60, streuung: 55, tempo: 55 }, { ursprung: [1, .72], anzahl: 60, winkel: 120, streuung: 55, tempo: 55, versatz: 200 }],
               text: ['Ursprung <b>links und rechts</b>, y 0,72', '<b>2 × 60</b> Teile, <b>200 ms</b> versetzt', 'Winkel <b>60° / 120°</b>, Streuung <b>55°</b>', 'startVelocity <b>55</b>'] },
    regen:   { regen: { dauer: 2400, proBild: 3, schwerkraft: .8, drift: .4, ticks: 400, skala: [.6, 1] },
               text: ['<b>3 Teile pro Bild</b> über <b>2400 ms</b>', 'von oben, startVelocity <b>0</b>', 'gravity <b>0,8</b> (±20 %), drift <b>±0,4</b>', 'ticks <b>400</b>, scalar <b>0,6–1</b>'] }
  };
  let variante = 'burst', teile = [], lauf = 0, regenBis = 0, letzte = 0, dpr = 1;

  function groesse() {
    dpr = Math.min(devicePixelRatio || 1, 2);
    leinwand.width = innerWidth * dpr; leinwand.height = innerHeight * dpr;
  }
  function teil(x, y, o) {
    const rad = Math.PI / 180, mobil = schmal();
    const tempo = o.tempo ?? STANDARD.tempo;
    const skala = (o.skala ?? STANDARD.skala) * (mobil ? .8 : 1);
    const zufall = Math.random();
    return {
      x, y, form: zufall < .45 ? 'rechteck' : zufall < .75 ? 'kreis' : 'welle',
      farbe: FARBEN[(Math.random() * FARBEN.length) | 0],
      winkel: -(o.winkel ?? STANDARD.winkel) * rad + ((o.streuung ?? STANDARD.streuung) * rad) * (.5 - Math.random()),
      v: tempo * .5 + Math.random() * tempo,
      abnahme: o.abnahme ?? STANDARD.abnahme,
      fall: (o.schwerkraft ?? STANDARD.schwerkraft) * 3,
      drift: o.drift ?? STANDARD.drift,
      flatter: Math.random() * 10, flatterTempo: .05 + Math.random() * .06,
      kipp: Math.random() * Math.PI, kippTempo: .08 + Math.random() * .06,
      dreh: Math.random() * Math.PI * 2, drehTempo: (Math.random() - .5) * .16,
      groesse: (7 + Math.random() * 4) * skala, tick: 0, ticks: o.ticks ?? STANDARD.ticks
    };
  }
  function schuss(s) {
    const mobil = schmal();
    let x, y;
    if (s.ursprung === 'knopf') {
      const r = document.querySelector('[data-senden]').getBoundingClientRect();
      x = r.left + r.width / 2; y = r.top + r.height / 2;
    } else { x = s.ursprung[0] * innerWidth; y = s.ursprung[1] * innerHeight; }
    const n = Math.round(s.anzahl * (mobil ? .5 : 1));
    for (let i = 0; i < n; i++) teile.push(teil(x, y, s));
  }
  function zeichne(t) {
    const r = t.groesse, alpha = Math.min(1, (1 - t.tick / t.ticks) / .3);
    ctx.globalAlpha = Math.max(0, alpha);
    ctx.fillStyle = t.farbe; ctx.strokeStyle = t.farbe;
    // Flattern: seitliches Pendeln; Kippen: eine Achse wird per cos gestaucht (3D-Wende)
    const px = t.x + Math.cos(t.flatter) * 6 * (r / 9), py = t.y + Math.sin(t.flatter) * 3;
    ctx.setTransform(dpr, 0, 0, dpr, px * dpr, py * dpr);
    ctx.rotate(t.dreh);
    ctx.scale(1, Math.cos(t.kipp));
    if (t.form === 'rechteck') ctx.fillRect(-r / 2, -r * .32, r, r * .64);
    else if (t.form === 'kreis') { ctx.beginPath(); ctx.arc(0, 0, r * .38, 0, Math.PI * 2); ctx.fill(); }
    else { ctx.lineWidth = r * .26; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(-r * .7, 0); ctx.quadraticCurveTo(-r * .35, -r * .45, 0, 0); ctx.quadraticCurveTo(r * .35, r * .45, r * .7, 0); ctx.stroke(); }
    // weiße Teile bekommen eine feine Kontur, damit sie auf hellem Grund sichtbar bleiben
    if (t.farbe === '#ffffff' && t.form !== 'welle') { ctx.lineWidth = .8; ctx.strokeStyle = 'rgba(0,46,109,.35)'; if (t.form === 'kreis') ctx.stroke(); else ctx.strokeRect(-r / 2, -r * .32, r, r * .64); }
  }
  function bild(jetzt) {
    const k = Math.min(3, (jetzt - letzte) / (1000 / 60)) || 1; letzte = jetzt;
    if (jetzt < regenBis) {
      const R = VARIANTEN.regen.regen;
      for (let i = 0; i < Math.round(R.proBild * (schmal() ? .5 : 1) * k); i++) {
        teile.push(teil(Math.random() * innerWidth, -20 - Math.random() * 40, { tempo: 0, schwerkraft: R.schwerkraft * (.8 + Math.random() * .4), drift: (Math.random() * 2 - 1) * R.drift, ticks: R.ticks, skala: R.skala[0] + Math.random() * (R.skala[1] - R.skala[0]) }));
      }
    }
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, leinwand.width, leinwand.height);
    teile = teile.filter(t => {
      t.x += Math.cos(t.winkel) * t.v * k + t.drift * k;
      t.y += Math.sin(t.winkel) * t.v * k + t.fall * k;
      t.v *= Math.pow(t.abnahme, k);
      t.flatter += t.flatterTempo * k * 2; t.kipp += t.kippTempo * k; t.dreh += t.drehTempo * k;
      t.tick += k;
      if (t.tick >= t.ticks || t.y > innerHeight + 40) return false;
      zeichne(t); return true;
    });
    if (teile.length || jetzt < regenBis) lauf = requestAnimationFrame(bild);
    else { lauf = 0; leinwand.classList.remove('an'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, leinwand.width, leinwand.height); }
  }
  const zeitgeber = [];
  function feiern() {
    if (reduziert()) return;   // disableForReducedMotion: Konfetti entfällt, die Textmeldung bleibt
    groesse(); leinwand.classList.add('an');
    const V = VARIANTEN[variante];
    if (V.regen) regenBis = performance.now() + V.regen.dauer;
    else V.schuesse.forEach(s => zeitgeber.push(setTimeout(() => schuss(s), s.versatz || 0)));
    if (!lauf) { letzte = performance.now(); lauf = requestAnimationFrame(bild); }
  }
  function konfettiWeg() {
    zeitgeber.splice(0).forEach(clearTimeout);
    cancelAnimationFrame(lauf); lauf = 0; teile = []; regenBis = 0;
    ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, leinwand.width, leinwand.height);
    leinwand.classList.remove('an');
  }
  addEventListener('resize', () => { if (lauf) groesse(); });

  /* ---------- Spendenformular ---------- */
  const form = document.querySelector('[data-form]'), karte = document.querySelector('[data-karte]');
  const knopf = document.querySelector('[data-senden]'), knopftext = document.querySelector('[data-knopftext]');
  const dank = document.querySelector('[data-dank]'), ansage = document.querySelector('[data-ansage]');
  const WIRKUNG = { 15: '50 Halmen Strandhafer', 30: '12 Metern Sandfangzaun', 60: 'einem Pflanztag mit einer Schulklasse' };
  const ZIEL = 25000, START = 18420, START_N = 412;
  const eur = n => n.toLocaleString('de-DE') + ' €';
  const betrag = () => +form.betrag.value, monatlich = () => form.rhythmus.value === 'monatlich';
  function knopfText() { knopftext.textContent = `${betrag()} € ${monatlich() ? 'monatlich ' : ''}spenden`; }
  form.addEventListener('change', knopfText);

  function stand(summe, n) {
    document.querySelector('[data-summe]').innerHTML = `${eur(summe)}<small>von ${eur(ZIEL)}</small>`;
    document.querySelector('[data-anzahl]').textContent = `${n} Spenderinnen und Spender`;
    const b = document.querySelector('[data-balken]');
    b.style.setProperty('--anteil', (summe / ZIEL).toFixed(4)); b.setAttribute('aria-valuenow', summe);
  }
  let warte = 0;
  function abschicken() {
    if (karte.classList.contains('fertig') || knopf.classList.contains('laedt')) return;
    knopf.classList.add('laedt'); knopftext.textContent = 'Wird übermittelt …';
    warte = setTimeout(() => {
      knopf.classList.remove('laedt'); knopfText();
      const name = (form.vorname.value || '').trim(), b = betrag();
      document.querySelector('[data-dank-titel]').textContent = name ? `Danke, ${name}!` : 'Danke!';
      document.querySelector('[data-dank-text]').textContent = `Deine ${b} €${monatlich() ? ' im Monat' : ''} werden zu ${WIRKUNG[b]} in Diedrichshagen.`;
      karte.classList.add('fertig'); dank.removeAttribute('aria-hidden'); form.setAttribute('aria-hidden', 'true'); form.inert = true;
      ansage.textContent = `Spende erfolgreich. ${document.querySelector('[data-dank-text]').textContent}`;
      feiern();
      stand(START + b, START_N + 1);
    }, 450);
  }
  form.addEventListener('submit', e => { e.preventDefault(); abschicken(); });

  function zuruecksetzen() {
    clearTimeout(warte); konfettiWeg();
    karte.classList.remove('fertig'); dank.setAttribute('aria-hidden', 'true'); form.removeAttribute('aria-hidden'); form.inert = false;
    knopf.classList.remove('laedt');
    form.rhythmus.value = 'einmalig'; form.betrag.value = '15'; form.vorname.value = 'Ricarda';
    knopfText(); stand(START, START_N); ansage.textContent = '';
  }
  document.querySelector('[data-dank]').addEventListener('click', e => { if (e.target.closest('a')) { e.preventDefault(); zuruecksetzen(); } });
  document.querySelectorAll('a[href^="#"]').forEach(a => a.addEventListener('click', e => e.preventDefault()));

  /* ---------- Varianten und Werte ---------- */
  const werte = document.querySelector('[data-werte]');
  function zeigeWerte() {
    werte.innerHTML = reduziert()
      ? '<span>Reduziert: <b>kein Konfetti</b>, Dank blendet in <b>150 ms</b> ein, Textmeldung per role=status</span>'
      : VARIANTEN[variante].text.map(t => `<span>${t}</span>`).join('') + (variante === 'regen' ? '' : '<span>decay <b>0,9</b> · gravity <b>1</b> · ticks <b>200</b></span>') + '<span>Mobil <b>× 0,5</b> Teile, scalar <b>0,8</b></span>';
  }
  function setzeVariante(v) {
    variante = v;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
    zeigeWerte();
  }
  document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => {
    setzeVariante(b.dataset.variante);
    if (karte.classList.contains('fertig')) { konfettiWeg(); feiern(); }
  }));
  new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });

  /* ---------- Vorführung: Zeiger wählt 30 €, spendet, Konfetti, nächste Variante ---------- */
  const zeiger = document.querySelector('[data-zeiger]');
  const REIHE = ['burst', 'kanonen', 'regen'];
  let uhren = [], runde = 0, laeuft = false;
  const spaeter = (fn, ms) => uhren.push(setTimeout(fn, ms));
  function zeigerAuf(el, fx = .5, fy = .55) {
    const r = el.getBoundingClientRect();
    zeiger.style.transform = `translate(${r.left + r.width * fx}px, ${r.top + r.height * fy}px)`;
  }
  function drueck(fn, el) {
    zeiger.classList.add('drueck'); el && el.classList.add('gedrueckt');
    setTimeout(() => { zeiger.classList.remove('drueck'); el && el.classList.remove('gedrueckt'); fn(); }, 150);
  }
  function runde_spielen() {
    zuruecksetzen();
    setzeVariante(REIHE[runde % 3]);
    const ziel = document.querySelector('label[for="' + (runde % 2 ? 'b60' : 'b30') + '"]');
    let t = 0;
    if (runde > 0) {   // erste Runde direkt zum Knopf, damit die Vorschau früh feiert
      spaeter(() => zeigerAuf(ziel, .82, .5), t += 80);
      spaeter(() => drueck(() => { form.betrag.value = ziel.htmlFor.slice(1); knopfText(); }), t += 720);
    } else spaeter(() => { form.betrag.value = '30'; knopfText(); }, 0);
    spaeter(() => zeigerAuf(knopf, .62, .6), t += 200);
    spaeter(() => drueck(() => abschicken(), knopf), t += 750);
    spaeter(() => zeigerAuf(knopf, .9, 1.9), t += 900);
    spaeter(() => { runde++; runde_spielen(); }, t += (REIHE[runde % 3] === 'regen' ? 6400 : 3800));
  }
  function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; }
  function vorfuehren() {
    pause(); html.classList.add('vorfuehrung'); laeuft = true;
    const r = knopf.getBoundingClientRect();
    zeiger.style.transition = 'none';
    zeiger.style.transform = `translate(${r.left - 40}px, ${r.bottom + 90}px)`;
    zeiger.getBoundingClientRect(); zeiger.style.transition = '';
    runde_spielen();
  }
  function weiter() { if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; runde_spielen(); } }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung'); runde = 0;
    zuruecksetzen(); setzeVariante(variante);
    spaeter(() => { form.betrag.value = '30'; knopfText(); abschicken(); }, 400);
  }
  zeigeWerte(); knopfText();

})();
</script>
</body>
</html>