Spinner und Ladeindikatoren
Ein Ring mit atmendem Bogen, zwölf verblassende Balken oder die gezeichnete Blattkontur aus dem Logo: Ladeindikatoren im Spenden-Knopf und in einer Terminliste eines Weimarer Baumvereins.
Ein Indikator für unbestimmte Wartezeiten. Gängige Formen: ein Ring mit wachsendem und schrumpfendem Bogen, zwölf Balken, die reihum verblassen, springende oder pulsierende Punkte, sich füllender Text oder ein endlos gezeichneter Pfad. Nativ geht das 2026 mit einem <progress> ohne value: conic-gradient mit background-clip: border-area, Drehung 1,5 s linear, Bogen 3 s ease-in-out.
Der Indikator sagt: Das System arbeitet, deine Eingabe ist angekommen. Gleichmäßige Bewegung beruhigt beim Warten. Mit einer eigenen Form, etwa der Logo-Kontur, wird er zum Gestaltungselement der Marke.
Wann einsetzen
Kurze, unbestimmte Wartezeiten: in Buttons nach dem Absenden, in Overlays und in Inline-Bereichen, die nachladen.
Wann nicht
Bei bekannter Dauer, dann den Fortschritt zeigen. Beim Laden der Seitenstruktur, dann Skeleton. Nicht als Preloader vor der ganzen Seite.
Varianten
- Nativer <progress>-Ring mit conic-gradient und atmendem Bogen
- 12-Balken-Spinner mit Opacity-Abfall von 1 auf .15 (Sonner bei Attio 1,2 s, Geist-Spinner bei Vercel 16 px)
- Springende Punkte, Puls-Punkte, Ripple
- Text, der sich füllt
- Endlos gezeichneter SVG-Pfad, etwa die Logo-Kontur
- Ladelinie oben am Bildschirm
Umsetzung
<progress class="spinner" aria-label="Lädt"></progress>. CSS: appearance: none; inline-size: 40px; aspect-ratio: 1; border-radius: 50%; border: 2px solid transparent; background: conic-gradient(var(--farbe) var(--bogen), var(--spur) 0) border-area; background-clip: border-area; animation: drehen 1.5s linear infinite, bogen 3s ease-in-out infinite. --bogen per @property als <percentage> registrieren, damit es animierbar ist. ::-webkit-progress-bar und ::-webkit-progress-value transparent setzen. Rückfall ohne border-area: die Innenfläche mit mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 0) ausstanzen. Im Button den Indikator aria-hidden setzen, den Knopf auf aria-disabled stellen und den Status über eine role="status"-Region ansagen; die Knopfbreite fest halten, damit nichts springt.
Robust umsetzen
Telefon. Identisch.
Ohne Bewegung. Verlangsamen statt anhalten, etwa auf 6 s je Umdrehung. Ein stehender Spinner wirkt wie ein Absturz.
Barrierefrei. Natives <progress> mit aria-label, keine zusätzliche Rolle nötig. Eigene Formen bekommen role="progressbar" und aria-label. Der ladende Bereich trägt aria-busy="true", das Ergebnis kommt über aria-live an. Dekorative Indikatoren im Button sind aria-hidden, der Status wird per role="status" angesagt.
Code
<!DOCTYPE html>
<!--
Spinner und Ladeindikatoren
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/spinner-ladeindikator/
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>Spinner und Ladeindikatoren · 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: ein tiefer Markenton (hier Petrol-Grün für Stadtbäume),
helle Töne derselben Farbe als Flächen, warmes Grau, Gelb gehört nur dem Spenden-Knopf */
--grund: #ffffff; --flaeche: #eaf2ef; --marke: #0f3d37; --marke-2: #1d5a50; --blatt: #9fc7b8;
--tinte: #222520; --leise: rgba(34, 37, 32, .7); --linie: rgba(15, 61, 55, .14); --grau: #dfdfd7;
--signal: #ffca0a; --signal-hover: #f2bb00;
/* Gemessen (Chrome-Spinner): Drehung 1,5 s linear, Bogen 3 s ease-in-out, 40 px, Ring 2 px */
--drehung: 1.5s; --bogen-dauer: 3s; --inout: cubic-bezier(0.42, 0, 0.58, 1);
/* Gemessen (Sonner bei Attio): 12 Balken, 1,2 s linear, Opacity 1 → .15 */
--balken-dauer: 1.2s;
/* Rezept: Zustandswechsel der Bedienelemente 200 ms easeInOutCubic */
--ui: cubic-bezier(0.645, 0.045, 0.355, 1); --ui-dauer: 200ms;
}
* { 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; }
ul { list-style: none; padding: 0; }
.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; }
/* ───────── Ladeindikatoren (die drei Varianten) ───────── */
.sp { --gr: 40px; --st: 2px; --farbe: currentColor; --spur: color-mix(in srgb, currentColor 16%, transparent);
display: inline-block; flex: none; inline-size: var(--gr); block-size: var(--gr); position: relative; vertical-align: middle; }
/* 1 · Nativer <progress> ohne value: Ring mit wachsendem und schrumpfendem Bogen */
@property --bogen { syntax: '<percentage>'; inherits: false; initial-value: 25%; }
progress.sp {
appearance: none; -webkit-appearance: none; padding: 0; color: inherit;
aspect-ratio: 1; border-radius: 50%; border: var(--st) solid transparent;
background: conic-gradient(var(--farbe) var(--bogen), var(--spur) 0) border-box;
/* Rückfall ohne background-clip: border-area: Innenfläche per Maske ausstanzen */
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - var(--st)), #000 calc(100% - var(--st) + .5px));
mask: radial-gradient(farthest-side, transparent calc(100% - var(--st)), #000 calc(100% - var(--st) + .5px));
animation: sp-drehen var(--drehung) linear infinite, sp-bogen var(--bogen-dauer) var(--inout) infinite;
}
@supports (background-clip: border-area) {
progress.sp { background: conic-gradient(var(--farbe) var(--bogen), var(--spur) 0) border-area; background-clip: border-area; -webkit-mask: none; mask: none; }
}
progress.sp::-webkit-progress-bar { background: transparent; }
progress.sp::-webkit-progress-value { background: transparent; }
progress.sp::-moz-progress-bar { background: transparent; }
@keyframes sp-drehen { to { rotate: 1turn; } }
@keyframes sp-bogen { 0%, 100% { --bogen: 8%; } 50% { --bogen: 72%; } }
/* 2 · Zwölf Balken, jeder verblasst von 1 auf .15, um 100 ms versetzt */
.sp.balken i { position: absolute; left: 50%; top: 50%; width: 27%; height: 8.5%; margin: -4.25% 0 0 0; border-radius: 99px;
background: var(--farbe); transform-origin: 0 50%;
transform: rotate(calc(var(--i) * 30deg - 90deg)) translateX(68%);
animation: sp-verblassen var(--balken-dauer) linear infinite; animation-delay: calc(var(--i) * var(--balken-dauer) / 12 - var(--balken-dauer)); }
@keyframes sp-verblassen { from { opacity: 1; } to { opacity: .15; } }
/* 3 · Markenform: die Kontur des Lindenblatts aus dem Logo zeichnet sich endlos */
.sp.blatt svg { display: block; width: 100%; height: 100%; overflow: visible; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.sp.blatt path { stroke-width: var(--st); vector-effect: non-scaling-stroke; }
.sp.blatt .spur { stroke: var(--spur); }
.sp.blatt .zug { stroke: var(--farbe); stroke-dasharray: 100 100; animation: sp-zeichnen var(--bogen-dauer) var(--inout) infinite; }
@keyframes sp-zeichnen { 0% { stroke-dashoffset: 100; } 50% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: -100; } }
body.angehalten .sp, body.angehalten .sp i, body.angehalten .sp path { animation-play-state: paused; }
/* Reduzierte Bewegung: verlangsamen statt anhalten (6 s), Bogen bleibt fest */
.reduziert .sp { --drehung: 6s; --balken-dauer: 6s; --bogen-dauer: 6s; }
.reduziert progress.sp { animation: sp-drehen var(--drehung) linear infinite; }
/* ───────── 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); background: var(--grund); position: relative; z-index: 2; }
.marke { display: flex; align-items: center; gap: 11px; color: var(--marke); text-decoration: none; }
.marke:focus-visible { outline: 2px solid var(--marke); outline-offset: 4px; border-radius: 4px; }
.marke svg { width: 32px; height: 32px; flex: none; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.marke b { display: block; font: 800 22px/.86 'Big Shoulders', sans-serif; letter-spacing: .02em; text-transform: uppercase; }
.marke small { display: block; margin-top: 4px; 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); }
.nav li { color: var(--marke); }
.spenden-kopf { display: inline-flex; align-items: center; height: 40px; padding: 0 20px; border-radius: 999px; background: var(--signal); color: var(--tinte); font: 600 14px/1 'Archivo', sans-serif; text-decoration: none; transition: background-color .15s linear; }
.spenden-kopf:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
/* ───────── Bühne: dunkle Missions-Seite links, Spendenformular rechts ───────── */
.buehne { display: contents; }
/* Breit: Kopf oben, Missions-Fläche links über die ganze Höhe, rechts Formular und Demo-Leiste */
body { grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); }
.kopf { grid-column: 1 / -1; }
.mission { grid-column: 1; grid-row: 2 / 4; }
.spende { grid-column: 2; grid-row: 2; }
.leiste { grid-column: 2; grid-row: 3; }
.mission { position: relative; overflow: hidden; background: var(--marke); color: #fff; padding: clamp(28px, 3.4vw, 48px) clamp(16px, 4vw, 56px) clamp(24px, 2.8vw, 40px) clamp(16px, 4vw, 48px); display: grid; align-content: start; gap: clamp(16px, 2vw, 24px); }
.ringe { position: absolute; right: -16%; bottom: -30%; width: 78%; aspect-ratio: 1; pointer-events: none; }
.ringe path { fill: none; stroke: rgba(159, 199, 184, .11); stroke-width: 1.2; }
.mission > :not(.ringe) { position: relative; }
.mission .mono { color: rgba(234, 242, 239, .62); }
.mission .mono b { color: var(--blatt); font-weight: 500; }
h1 { font-size: clamp(2.75rem, 1.6rem + 5vw, 6rem); line-height: 1.02; color: #fff; text-wrap: balance; }
h1 em { font-style: normal; color: var(--blatt); }
.text { font-size: 1.0625rem; line-height: 1.6; color: rgba(255, 255, 255, .8); max-width: 46ch; text-wrap: pretty; }
/* Inline-Bereich: Pflanztermine laden */
.termine { margin-top: clamp(4px, 1vw, 12px); border-top: 1px solid rgba(159, 199, 184, .26); padding-top: 16px; max-width: 640px; }
.termine-kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
.orte { display: flex; gap: 6px; flex-wrap: wrap; }
.orte button { height: 32px; padding: 0 13px; border-radius: 99px; border: 1px solid rgba(159, 199, 184, .34); color: rgba(255, 255, 255, .86); font: 500 13px/1 'Archivo', sans-serif; transition: background-color var(--ui-dauer) var(--ui), color var(--ui-dauer) var(--ui), border-color var(--ui-dauer) var(--ui); }
.orte button[aria-pressed="true"] { background: #fff; color: var(--marke); border-color: #fff; }
.orte button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.bereich { position: relative; min-height: calc(4 * 54px); margin-top: 10px; }
.laden { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 14px; color: #fff; opacity: 0; visibility: hidden; transition: opacity var(--ui-dauer) var(--ui), visibility 0s var(--ui-dauer); }
.laden .mono { color: rgba(234, 242, 239, .7); }
.bereich[aria-busy="true"] .laden { opacity: 1; visibility: visible; transition-delay: 0s; }
.liste li { display: grid; grid-template-columns: 5.6em minmax(0, 1fr) auto; align-items: baseline; gap: 14px; padding: 13px 0 12px; border-bottom: 1px solid rgba(159, 199, 184, .18);
opacity: 0; translate: 0 8px; transition: opacity .4s var(--ui), translate .4s var(--ui); transition-delay: calc(var(--n, 0) * 60ms); }
.liste.da li { opacity: 1; translate: none; }
.liste .tag { font: 800 19px/1 'Big Shoulders', sans-serif; text-transform: uppercase; letter-spacing: .02em; color: var(--blatt); font-variant-numeric: tabular-nums; }
.liste .ort { font-size: 15.5px; line-height: 1.35; color: #fff; }
.liste .ort span { display: block; font-size: 13px; color: rgba(255, 255, 255, .62); }
.liste .frei { text-align: right; color: rgba(234, 242, 239, .66); }
/* Spendenformular */
.spende { background: var(--flaeche); display: grid; align-content: center; padding: clamp(24px, 4vw, 56px) clamp(16px, 4vw, 56px); }
.karte { background: #fff; border-radius: 10px; padding: clamp(20px, 2.4vw, 32px); display: grid; gap: 18px; max-width: 460px; width: 100%; justify-self: center; box-shadow: 0 1px 0 var(--linie), 0 24px 48px -32px rgba(15, 61, 55, .35); }
.karte h2 { font-size: clamp(1.75rem, 1.2rem + 2vw, 2.6rem); line-height: .95; color: var(--marke); }
.rhythmus { display: grid; grid-template-columns: 1fr 1fr; padding: 4px; border-radius: 99px; background: var(--flaeche); }
.rhythmus button { height: 36px; border-radius: 99px; font: 600 14px/1 'Archivo', sans-serif; color: var(--marke); transition: background-color var(--ui-dauer) var(--ui), color var(--ui-dauer) var(--ui); }
.rhythmus button[aria-pressed="true"] { background: var(--marke); color: #fff; }
.betraege { display: grid; gap: 8px; }
.betraege button { display: grid; grid-template-columns: 4.2em 1fr; align-items: center; gap: 12px; text-align: left; padding: 11px 14px; border: 1px solid var(--linie); border-radius: 8px; transition: border-color var(--ui-dauer) var(--ui), background-color var(--ui-dauer) var(--ui); }
.betraege strong { font: 800 26px/1 'Big Shoulders', sans-serif; color: var(--marke); font-variant-numeric: tabular-nums; }
.betraege span { font-size: 14px; line-height: 1.35; color: var(--leise); }
.betraege button[aria-pressed="true"] { border-color: var(--marke); background: var(--flaeche); box-shadow: inset 0 0 0 1px var(--marke); }
.rhythmus button:focus-visible, .betraege button:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
/* Der Spenden-Knopf mit drei Zuständen: bereit · lädt · danke */
.knopf { position: relative; display: grid; min-height: 56px; border-radius: 999px; background: var(--signal); color: var(--tinte); font: 600 1rem/1 'Archivo', sans-serif; overflow: hidden; transition: background-color .15s linear, scale .15s var(--ui); }
.knopf > span { grid-area: 1 / 1; display: flex; align-items: center; justify-content: center; gap: 10px; transition: opacity var(--ui-dauer) var(--ui), translate var(--ui-dauer) var(--ui); }
.knopf .k-laden, .knopf .k-danke { opacity: 0; translate: 0 70%; }
.knopf[data-zustand="laedt"] .k-bereit, .knopf[data-zustand="danke"] .k-bereit { opacity: 0; translate: 0 -70%; }
.knopf[data-zustand="laedt"] .k-laden { opacity: 1; translate: none; }
.knopf[data-zustand="danke"] .k-laden { opacity: 0; translate: 0 -70%; }
.knopf[data-zustand="danke"] .k-danke { opacity: 1; translate: none; }
.knopf[data-zustand="laedt"] { cursor: progress; }
.knopf[data-zustand="danke"] { background: var(--marke); color: #fff; }
.knopf.gedrueckt, .knopf:active { scale: .98; }
.knopf:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
.knopf .haken { width: 18px; height: 18px; fill: none; stroke: var(--signal); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.kleingedruckt { display: flex; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; }
/* ───────── Demo-Leiste: Variante und Werte ───────── */
.leiste { display: grid; justify-items: start; gap: 10px; padding: 14px clamp(16px, 4vw, 56px) 18px; border-top: 1px solid var(--linie); background: var(--flaeche); }
.umschalter { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.umschalter .mono:first-child { margin-right: 10px; }
.umschalter button { height: 28px; padding: 0 12px; border-radius: 99px; font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: var(--leise); transition: background-color var(--ui-dauer) var(--ui), color var(--ui-dauer) var(--ui); }
.umschalter button[aria-pressed="true"] { background: var(--marke); color: #fff; }
.umschalter button:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
.werte { color: var(--marke-2); text-transform: none; letter-spacing: .04em; }
.werte span { white-space: nowrap; }
.werte span:not(:last-child)::after { content: ' ·'; color: var(--leise); }
@media (hover: hover) {
.spenden-kopf:hover, .knopf[data-zustand="bereit"]:hover { background: var(--signal-hover); }
.orte button:not([aria-pressed="true"]):hover { border-color: #fff; color: #fff; }
.betraege button:not([aria-pressed="true"]):hover { border-color: var(--marke-2); }
.umschalter button:not([aria-pressed="true"]):hover { color: var(--marke); }
}
/* Touch: größere Tippflächen, sonst identisch (Spinner läuft ohne Zeiger) */
@media (hover: none) {
.orte button { height: 40px; padding: 0 12px; }
.umschalter button { height: 36px; }
}
.reduziert .knopf > span, .reduziert .liste li, .reduziert .laden { transition: opacity .15s linear, visibility 0s; translate: none !important; transition-delay: 0s; }
/* Schmale Bildschirme: untereinander */
@media (max-width: 900px) {
body { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; }
.kopf, .mission, .spende, .leiste { grid-column: 1; grid-row: auto; }
.nav li:not(:last-child) { display: none; }
.ringe { width: 120%; right: -50%; bottom: -10%; }
}
@media (max-width: 480px) {
.marke small, .nur-breit { display: none; }
.liste li { grid-template-columns: 4.4em minmax(0, 1fr); }
.liste .frei { grid-column: 2; text-align: left; margin-top: 2px; }
.bereich { min-height: calc(4 * 72px); }
.betraege button { grid-template-columns: 3.6em 1fr; }
}
/* Galerie-Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .text, .vorschau .kleingedruckt, .vorschau .nur-breit, .vorschau .nav li:not(:last-child) { display: none; }
.vorschau h1 { font-size: 76px; }
.vorschau .mission { padding: 36px 36px 28px; gap: 16px; }
.vorschau .spende { padding: 28px; }
.vorschau .karte { gap: 14px; padding: 22px; }
.vorschau .betraege button { padding: 8px 12px; }
.vorschau .bereich { min-height: calc(4 * 50px); }
.vorschau .liste li { padding: 12px 0 11px; }
/* 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="#start" aria-label="Ilmlinde e. V., Startseite">
<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M18.4 37.6C19.5 35.6 20 33.4 20 31C13.5 34 5 30 5 21.5 5 12.5 12.5 5.5 22 5.5 26 5.5 29 4.5 32.5 2.5 31.6 6.4 31.4 9.4 32.2 12.2 34.2 15 35 18.3 35 22 35 30 26.5 34.5 20 31C22 24.5 24.6 15.6 29.4 8.2"/></svg>
<span><b>Ilmlinde</b><small>Stadtbäume für Weimar e. V.</small></span>
</a>
<ul class="nav mono">
<li>Projekte</li><li>Mitmachen</li><li>Transparenz</li>
<li><a class="spenden-kopf" href="#spende">Spenden</a></li>
</ul>
</header>
<main class="buehne" id="start">
<section class="mission" aria-labelledby="titel">
<svg class="ringe" viewBox="-100 -100 200 200" aria-hidden="true" data-ringe></svg>
<p class="mono"><span class="nur-breit">Herbstpflanzung 2026 · </span><b>412 von 1.000 gepflanzt</b> · Stand 31.10.2026</p>
<h1 class="display" id="titel">1.000 Linden <em>für Weimar.</em></h1>
<p class="text">Hitzesommer, verdichtete Böden, Streusalz: Jede dritte Straßenlinde in Weimar ist geschwächt. Wir pflanzen nach, gießen drei Sommer lang und suchen für jeden Baum eine Patin oder einen Paten.</p>
<div class="termine">
<div class="termine-kopf">
<h2 class="mono">Pflanztermine · Mitpflanzen</h2>
<div class="orte" role="group" aria-label="Ort wählen" data-orte>
<button type="button" aria-pressed="true" data-ort="alle">Alle</button>
<button type="button" aria-pressed="false" data-ort="Ilmpark">Ilmpark</button>
<button type="button" aria-pressed="false" data-ort="Schöndorf">Schöndorf</button>
<button type="button" aria-pressed="false" data-ort="Weimar-West">Weimar-West</button>
</div>
</div>
<div class="bereich" aria-busy="true" data-bereich>
<div class="laden" data-laden>
<span data-slot="bereich"></span>
<p class="mono">Termine werden geladen</p>
</div>
<ul class="liste" data-liste aria-live="polite"></ul>
</div>
</div>
</section>
<section class="spende" id="spende" aria-labelledby="spende-titel">
<form class="karte" data-form novalidate>
<h2 class="display" id="spende-titel">Werde Baumpate</h2>
<div class="rhythmus" role="group" aria-label="Rhythmus" data-rhythmus>
<button type="button" aria-pressed="false" data-r="einmalig">Einmalig</button>
<button type="button" aria-pressed="true" data-r="monatlich">Monatlich</button>
</div>
<div class="betraege" role="group" aria-label="Betrag" data-betraege>
<button type="button" aria-pressed="false" data-b="10"><strong>10 €</strong><span>gießt eine junge Linde einen Sommer lang</span></button>
<button type="button" aria-pressed="true" data-b="25"><strong>25 €</strong><span>Pflanzgrube, Substrat und Baumpfahl</span></button>
<button type="button" aria-pressed="false" data-b="60"><strong>60 €</strong><span>eine ganze Linde samt Pflanzung im ersten Jahr</span></button>
</div>
<button class="knopf" type="submit" data-zustand="bereit" data-knopf>
<span class="k-bereit" data-knopf-text>25 € monatlich spenden</span>
<span class="k-laden" aria-hidden="true"><span data-slot="knopf"></span>Wird übermittelt</span>
<span class="k-danke" aria-hidden="true"><svg class="haken" viewBox="0 0 18 18"><path d="M3.5 9.5 7.5 13.5 14.5 5"/></svg>Danke, deine Linde wächst!</span>
</button>
<p class="sr" role="status" data-status></p>
<div class="kleingedruckt"><span class="mono">Gemeinnützig · VR 1207</span><span class="mono">Quittung per Mail</span></div>
</form>
</section>
</main>
<footer class="leiste">
<div class="umschalter" role="group" aria-label="Variante des Ladeindikators" data-varianten>
<span class="mono">Variante</span>
<button type="button" aria-pressed="true" data-v="ring">Ring</button>
<button type="button" aria-pressed="false" data-v="balken">12 Balken</button>
<button type="button" aria-pressed="false" data-v="blatt">Blattkontur</button>
</div>
<p class="mono werte" data-werte></p>
</footer>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
/* Die Kontur des Lindenblatts aus dem Logo, als ein durchgehender Strich */
const BLATT = 'M18.4 37.6C19.5 35.6 20 33.4 20 31C13.5 34 5 30 5 21.5 5 12.5 12.5 5.5 22 5.5 26 5.5 29 4.5 32.5 2.5 31.6 6.4 31.4 9.4 32.2 12.2 34.2 15 35 18.3 35 22 35 30 26.5 34.5 20 31C22 24.5 24.6 15.6 29.4 8.2';
const VARIANTEN = {
ring: {
bau: (gr, name) => `<progress class="sp" style="--gr:${gr}px" ${name ? `aria-label="${name}"` : 'aria-hidden="true"'}></progress>`,
werte: () => reduziert() ? ['<progress>', '40 / 16 px', 'Ring 2 px', 'Drehung 6 s linear', 'Bogen fest 25 %']
: ['<progress>', '40 / 16 px', 'Ring 2 px', 'Drehung 1,5 s linear', 'Bogen 8 → 72 % in 3 s', 'cubic-bezier(.42,0,.58,1)']
},
balken: {
bau: (gr, name) => `<span class="sp balken" style="--gr:${gr}px" ${name ? `role="progressbar" aria-label="${name}"` : 'aria-hidden="true"'}>${Array.from({ length: 12 }, (_, i) => `<i style="--i:${i}"></i>`).join('')}</span>`,
werte: () => ['12 Balken', '40 / 16 px', 'Opacity 1 → .15', `${reduziert() ? '6 s' : '1,2 s'} linear`, `Versatz ${reduziert() ? '500' : '100'} ms`]
},
blatt: {
bau: (gr, name) => `<span class="sp blatt" style="--gr:${gr}px" ${name ? `role="progressbar" aria-label="${name}"` : 'aria-hidden="true"'}><svg viewBox="0 0 40 40"><path class="spur" d="${BLATT}"/><path class="zug" d="${BLATT}" pathLength="100"/></svg></span>`,
werte: () => ['SVG-Pfad', '40 / 16 px', 'pathLength 100', 'Strich 2 px', `zeichnen + löschen ${reduziert() ? '6 s' : '3 s'}`, 'cubic-bezier(.42,0,.58,1)']
}
};
let variante = 'ring';
function setzeVariante(v) {
variante = v;
$$('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
$('[data-slot="bereich"]').innerHTML = VARIANTEN[v].bau(40, 'Termine werden geladen');
$('[data-slot="knopf"]').innerHTML = VARIANTEN[v].bau(16, '');
zeigeWerte();
}
function zeigeWerte() { $('[data-werte]').innerHTML = VARIANTEN[variante].werte().map(w => `<span>${w.replace(/</g, '<')}</span>`).join(' '); }
$$('[data-v]').forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.v)));
/* Jahresringe als stille Bildwelt der Missions-Fläche */
(() => {
let d = '';
for (let k = 1; k <= 11; k++) {
const R = 8 + k * 8.2, pkt = [];
for (let j = 0; j < 96; j++) {
const t = j / 96 * Math.PI * 2;
const r = R * (1 + .035 * Math.sin(3 * t + k * .7) + .022 * Math.sin(5 * t + k * 1.3) + .012 * Math.sin(9 * t - k));
pkt.push((r * Math.cos(t)).toFixed(1) + ' ' + (r * Math.sin(t) * .92).toFixed(1));
}
d += 'M' + pkt.join('L') + 'Z';
}
$('[data-ringe]').innerHTML = `<path d="${d}"/>`;
})();
/* Inline-Bereich: Pflanztermine (lädt beim Start und bei jedem Ortswechsel) */
const TERMINE = [
['Sa 07.11.', 'Ilmpark', 'Wiese am Sternbrückenweg', '24 Linden · 9 frei'],
['Sa 14.11.', 'Schöndorf', 'Streuobstwiese am Ortsrand', '18 Linden · 6 frei'],
['So 22.11.', 'Weimar-West', 'Grünzug hinter der Schwimmhalle', '30 Linden · 14 frei'],
['Sa 28.11.', 'Ilmpark', 'Allee unterhalb der Parkhöhle', '12 Linden · 3 frei'],
['Sa 05.12.', 'Weimar-West', 'Spielplatz am Wohngebiet', '16 Linden · 11 frei']
];
const bereich = $('[data-bereich]'), liste = $('[data-liste]');
let ort = 'alle', ladeUhr = null;
function ladeTermine(dauer = 1600) {
clearTimeout(ladeUhr);
bereich.setAttribute('aria-busy', 'true');
liste.classList.remove('da');
ladeUhr = setTimeout(() => {
const treffer = TERMINE.filter(t => ort === 'alle' || t[1] === ort).slice(0, 4);
liste.innerHTML = treffer.map((t, n) => `<li style="--n:${n}"><span class="tag">${t[0]}</span><span class="ort">${t[1]}<span>${t[2]}</span></span><span class="mono frei">${t[3]}</span></li>`).join('');
bereich.setAttribute('aria-busy', 'false');
requestAnimationFrame(() => requestAnimationFrame(() => liste.classList.add('da')));
}, dauer);
}
function waehleOrt(o) {
ort = o;
$$('[data-ort]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.ort === o)));
ladeTermine();
}
$$('[data-ort]').forEach(b => b.addEventListener('click', () => { if (b.dataset.ort !== ort) waehleOrt(b.dataset.ort); }));
/* Spendenformular */
const knopf = $('[data-knopf]'), knopfText = $('[data-knopf-text]'), status = $('[data-status]');
let betrag = '25', rhythmus = 'monatlich', knopfUhr = [];
const beschriften = () => { knopfText.textContent = `${betrag} € ${rhythmus === 'monatlich' ? 'monatlich ' : ''}spenden`; };
function waehleBetrag(b) { betrag = b; $$('[data-b]').forEach(x => x.setAttribute('aria-pressed', String(x.dataset.b === b))); beschriften(); }
function waehleRhythmus(r) { rhythmus = r; $$('[data-r]').forEach(x => x.setAttribute('aria-pressed', String(x.dataset.r === r))); beschriften(); }
$$('[data-b]').forEach(x => x.addEventListener('click', () => waehleBetrag(x.dataset.b)));
$$('[data-r]').forEach(x => x.addEventListener('click', () => waehleRhythmus(x.dataset.r)));
function knopfZurueck() { knopfUhr.forEach(clearTimeout); knopfUhr = []; knopf.dataset.zustand = 'bereit'; knopf.removeAttribute('aria-disabled'); status.textContent = ''; }
function spenden() {
if (knopf.dataset.zustand !== 'bereit') return;
knopf.dataset.zustand = 'laedt';
knopf.setAttribute('aria-disabled', 'true');
status.textContent = 'Spende wird übermittelt';
knopfUhr.push(setTimeout(() => {
knopf.dataset.zustand = 'danke';
status.textContent = 'Danke, deine Spende ist angekommen.';
knopfUhr.push(setTimeout(knopfZurueck, 2200));
}, 2400));
}
$('[data-form]').addEventListener('submit', e => { e.preventDefault(); spenden(); });
$('.spenden-kopf').addEventListener('click', e => { e.preventDefault(); knopf.focus({ preventScroll: true }); });
/* Vorführung: Termine laden, Betrag wählen, spenden, Ort wechseln, nächste Variante */
const REIHE = ['blatt', 'ring', 'balken']; /* Vorführung beginnt mit der Markenform */
let uhren = [], laeuft = false, runde = 0;
const spaeter = (ms, f) => uhren.push(setTimeout(f, ms));
const druecken = el => { el.classList.add('gedrueckt'); setTimeout(() => el.classList.remove('gedrueckt'), 160); };
function runde1() {
setzeVariante(REIHE[runde % 3]);
knopfZurueck(); waehleBetrag('25'); waehleRhythmus('monatlich');
ort = 'alle'; $$('[data-ort]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.ort === 'alle')));
ladeTermine(3000);
spaeter(1300, () => waehleBetrag('60'));
spaeter(2100, () => { druecken(knopf); spenden(); });
spaeter(6400, () => waehleOrt(['Ilmpark', 'Weimar-West', 'Schöndorf'][runde % 3]));
spaeter(9400, () => { runde++; runde1(); });
}
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
laeuft = true; runde = 0;
runde1();
}
function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; document.body.classList.add('angehalten'); }
function weiter() { document.body.classList.remove('angehalten'); if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; runde1(); } }
function neustart() {
uhren.forEach(clearTimeout); uhren = []; laeuft = false;
document.body.classList.remove('angehalten');
html.classList.remove('vorfuehrung');
knopfZurueck(); waehleBetrag('25'); waehleRhythmus('monatlich');
setzeVariante(variante);
ort = 'alle'; $$('[data-ort]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.ort === 'alle')));
ladeTermine(1800);
}
setzeVariante('ring');
/* Werte-Anzeige folgt dem Schalter für reduzierte Bewegung der Detailseite */
new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });
ladeTermine(1800);
})();
</script>
</body>
</html>