Button-Zustandsfolge (Idle → Hover → Press → Loading → Erfolg)
Ein Buchungs-Button zeigt Hover, Druck, Laden und Erfolg direkt an sich selbst, ohne zu springen.
Ein Button zeigt seinen Zustand selbst an: Ruhe, Hover (Farbe), Druck (scale ~0,97), Laden (Label weicht einem Spinner oder Fortschrittsring), Erfolg (Häkchen zeichnet sich, Farbe wird grün) und zurück. Bei Fehler: Rot und kurzes Schütteln. Ändert sich das Label, animiert die Breite weich mit, damit nichts springt. Die Wechsel dauern 150 bis 300 ms, der Erfolg bleibt etwa 2 s stehen.
Die Antwort auf „Hat es geklappt?“ erscheint genau dort, wo geklickt wurde. Das senkt Unsicherheit und verhindert Doppelklicks.
Wann einsetzen
Formular absenden, In den Warenkorb, Speichern, Newsletter-Anmeldung, Kopieren, Downloads.
Wann nicht
Bei Navigation, dort gibt der Seitenwechsel das Feedback. Bei Antworten unter etwa 100 ms keinen Ladezustand zeigen, sonst flackert es. Loading erst nach rund 300 ms einblenden.
Varianten
- Label: Text blendet aus (scale .9), Spinner erscheint im Button, danach Häkchen und neues Label (Glossier, Aurélien Vigne)
- Kreis: Button morpht zum Kreis mit Fortschrittsring, dann Erfolg (Codrops CircularProgressButton nach Colin Garvin)
- Fehler: Rot plus horizontales Schütteln (3 bis 4 Ausschläge, ±4 bis 6 px, 300 bis 400 ms)
- Label-Wechsel mit Blur-Überblendung und gezeichnetem Häkchen (Motion Copy Button)
- Punkte-Morph (Motion Dots Morph Button)
- Gedrückt halten statt klicken (siehe hold-to-confirm)
Umsetzung
Zustand als data-state="idle|loading|erfolg|fehler" am Button. CSS steuert die gestapelten Ebenen (Label, Spinner, Icon) über opacity und scale. Breite: per JS aus der Ebene des Zielzustands messen und als width mit transition setzen, oder interpolate-size: allow-keywords mit width: auto. Loading erst nach 300 ms zeigen (setTimeout) und mindestens 500 ms halten. Häkchen: <path pathLength="1"> mit stroke-dasharray 1 und stroke-dashoffset 1 → 0 in 300 ms. Während Loading aria-disabled="true" statt disabled, damit der Fokus bleibt; Klicks per pointer-events: none sperren. Status in einer aria-live="polite"-Region ansagen.
Robust umsetzen
Telefon. Gleiche Folge, nur ohne Hover. Das Druck-Feedback ist auf Touch besonders wichtig.
Ohne Bewegung. Zustände nur über Farbe, Icon und Text wechseln, ohne Morph und ohne Schütteln. Spinner langsamer drehen lassen (siehe Spinner).
Barrierefrei. Status per aria-live ansagen, etwa „Wird in den Warenkorb gelegt …“ und „Liegt im Warenkorb“. Fokus nicht verlieren, also kein display: none und kein disabled am Button. Farbe nie allein: immer Icon und Text dazu.
Code
<!DOCTYPE html>
<!--
Button-Zustandsfolge (Idle → Hover → Press → Loading → Erfolg)
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/button-zustaende/
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>Button-Zustandsfolge · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; font-style: italic; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-display: swap; }
:root {
--papier: #2a1210; --karte: #351816; --tinte: #f2e6d4; --tinte-hover: #fff6e8;
--leise: rgba(242, 230, 212, .6); --linie: rgba(242, 230, 212, .16);
--gruen: #3d8a5f; --rot: #e2583e; --hell: #fff8ec;
--kurve: cubic-bezier(0.42, 0, 0.58, 1); /* ease-in-out, gemessen bei Glossier */
--h: 60px;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(18px, 3.4vw, 44px); display: grid; grid-template-rows: auto 1fr auto; gap: clamp(20px, 3vw, 36px); }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.serif { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }
/* Kopf: Marke und Varianten-Umschalter */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.rechts { display: flex; align-items: center; gap: 22px; }
.korb b { display: inline-grid; place-items: center; min-width: 22px; height: 22px; margin-left: 6px; padding: 0 6px; border-radius: 999px; background: var(--karte); color: var(--tinte); font-weight: 500; transition: background-color .2s var(--kurve), color .2s var(--kurve), scale .2s var(--kurve); }
.korb b.neu { background: var(--gruen); color: var(--papier); scale: 1.15; }
.reduziert .korb b.neu { scale: 1; }
.umschalter { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--linie); border-radius: 999px; }
.umschalter button { font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); background: none; border: 0; border-radius: 999px; padding: 8px 12px; cursor: pointer; transition: background-color .15s var(--kurve), color .15s var(--kurve); }
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.umschalter button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
/* Bühne: Produkt der Rösterei */
.buehne { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr); gap: clamp(28px, 5vw, 80px); align-items: center; max-width: 1180px; width: 100%; margin: 0 auto; }
.bild { position: relative; aspect-ratio: 4 / 5; max-height: min(66vh, 620px); justify-self: end; width: 100%; border-radius: 4px; overflow: hidden; background: var(--karte); box-shadow: inset 0 0 0 1px var(--linie); }
.bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
.bild .mono { position: absolute; left: 16px; bottom: 14px; color: rgba(242, 230, 212, .7); }
.info { display: grid; gap: 22px; max-width: 540px; }
h1 { font-size: clamp(40px, 5.6vw, 80px); font-weight: 500; letter-spacing: -.045em; line-height: .92; }
h1 .serif { font-size: 1.08em; letter-spacing: -.02em; }
.text { font-size: 16px; line-height: 1.5; color: var(--leise); max-width: 38ch; }
.optionen { display: flex; gap: 8px; flex-wrap: wrap; }
.optionen span { font-size: 14px; padding: 8px 14px; border: 1px solid var(--linie); border-radius: 999px; }
.optionen span.an { border-color: var(--tinte); }
.zeile { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; padding-top: 6px; }
.preis { font-size: 26px; font-weight: 500; letter-spacing: -.02em; }
.preis small { display: block; font: 500 10.5px/1.4 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* ===== Der Button ===== */
.knopf {
position: relative; height: var(--h); width: var(--breite, 260px); min-width: var(--h); padding: 0; border: 0; border-radius: 999px;
background: var(--tinte); color: var(--papier); font: 500 17px/1 'Instrument Sans', system-ui, sans-serif; letter-spacing: -.005em;
cursor: pointer; overflow: hidden; isolation: isolate; -webkit-tap-highlight-color: transparent; touch-action: manipulation;
transition: width .3s var(--kurve), background-color .2s var(--kurve), box-shadow .2s var(--kurve), scale .15s var(--kurve);
}
.knopf:focus-visible { outline: 2px solid var(--tinte); outline-offset: 4px; }
.schicht {
position: absolute; left: 50%; top: 50%; translate: -50% -50%; display: inline-flex; align-items: center; gap: 12px;
padding: 0 30px; white-space: nowrap; pointer-events: none;
transition: opacity .2s var(--kurve), scale .2s var(--kurve);
}
.schicht svg { width: 20px; height: 20px; flex: none; }
.s-idle .pfeil { transition: translate .2s var(--kurve); }
.s-erfolg, .s-fehler { opacity: 0; scale: .9; }
/* Hover: Fläche hellt auf, Pfeil rückt nach */
@media (hover: hover) { .knopf:hover { background: var(--tinte-hover); } .knopf:hover .pfeil { translate: 3px 0; } }
.knopf.ist-hover { background: var(--tinte-hover); }
.knopf.ist-hover .pfeil { translate: 3px 0; }
/* Press */
.knopf:active, .knopf.ist-press { scale: .97; }
/* Loading: Label weicht (opacity 0, scale .9), Loader blendet in 150 ms ein, Spinner 600 ms linear */
.lader { position: absolute; inset: 0; display: grid; place-items: center; opacity: 0; transition: opacity .15s var(--kurve); pointer-events: none; }
.lader svg { width: 22px; height: 22px; animation: drehen .6s linear infinite; }
@keyframes drehen { to { rotate: 360deg; } }
.knopf[data-state="loading"] { cursor: progress; pointer-events: none; }
.knopf[data-state="loading"] .s-idle { opacity: 0; scale: .9; }
.knopf[data-state="loading"] .lader { opacity: 1; }
/* Erfolg: Grün, Häkchen zeichnet sich (pathLength 1, 300 ms) */
.knopf[data-state="erfolg"] { background: var(--gruen); color: var(--hell); }
.knopf[data-state="erfolg"] .s-idle, .knopf[data-state="fehler"] .s-idle { opacity: 0; scale: .9; }
.knopf[data-state="erfolg"] .s-erfolg, .knopf[data-state="fehler"] .s-fehler { opacity: 1; scale: 1; transition-delay: .06s; }
.haken path { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .3s var(--kurve) .12s; }
.knopf[data-state="erfolg"] .haken path { stroke-dashoffset: 0; }
/* Fehler: Rot und 4 Ausschläge ±6 px in 360 ms */
.knopf[data-state="fehler"] { background: var(--rot); color: var(--hell); animation: schuetteln .36s var(--kurve); }
@keyframes schuetteln { 0%, 100% { translate: 0; } 12.5% { translate: -6px; } 37.5% { translate: 6px; } 62.5% { translate: -6px; } 87.5% { translate: 6px; } }
/* Variante Kreis: Breite morpht zum Kreis, Ring zeigt den Fortschritt */
.ring { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; transition: opacity .15s var(--kurve); pointer-events: none; rotate: -90deg; }
.ring circle { fill: none; stroke-width: 2.5; }
.ring .spur { stroke: var(--linie); }
.ring .fort { stroke: var(--tinte); stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; }
[data-variante="kreis"] .knopf[data-state="loading"] { background: transparent; }
[data-variante="kreis"] .knopf[data-state="loading"] .lader { opacity: 0; }
[data-variante="kreis"] .knopf[data-state="loading"] .ring { opacity: 1; transition-delay: .2s; }
[data-variante="kreis"] .knopf[data-state="loading"] .ring .fort { animation: fuellen var(--ladedauer, 1.9s) var(--kurve) .2s forwards; }
@keyframes fuellen { to { stroke-dashoffset: 0; } }
/* Zustandsfolge unter dem Button */
.folge { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; max-width: 470px; }
.folge li { padding-top: 10px; border-top: 2px solid var(--linie); transition: border-color .2s var(--kurve), color .2s var(--kurve); }
.folge li.war { border-top-color: rgba(242, 230, 212, .38); }
.folge li.an { border-top-color: var(--tinte); color: var(--tinte); }
.folge li.an.gut { border-top-color: var(--gruen); color: var(--gruen); }
.folge li.an.schlecht { border-top-color: var(--rot); color: var(--rot); }
.folge li small { display: none; margin-top: 4px; font-size: 9.5px; letter-spacing: .08em; text-transform: none; opacity: .8; }
/* Werte-Anzeige */
.werte { display: flex; flex-wrap: wrap; gap: 10px 28px; padding-top: 14px; border-top: 1px solid var(--linie); }
.werte span b { font-weight: 500; color: var(--tinte); }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 20px; height: 20px; pointer-events: none; opacity: 0; transition: transform .7s var(--kurve), opacity .2s var(--kurve); }
.vorfuehrung .zeiger { opacity: 1; }
.zeiger.drueckt path { transform: scale(.88); transform-origin: 3px 2px; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; grid-template-rows: auto 1fr; }
.vorschau .werte, .vorschau .umschalter, .vorschau .text { display: none; }
.vorschau .bild { max-height: 560px; }
.vorschau .optionen { display: none; }
.vorschau .knopf { --h: 72px; font-size: 20px; }
.vorschau .schicht { padding: 0 36px; }
.vorschau .schicht svg, .vorschau .lader svg { width: 24px; height: 24px; }
/* Touch: kein Hover, Press-Feedback bleibt */
@media (hover: none) {
.folge li[data-schritt="hover"] { color: rgba(242, 230, 212, .32); }
.folge li[data-schritt="hover"] small { display: block; }
}
@media (max-width: 760px) {
.rechts { gap: 14px; justify-content: space-between; width: 100%; }
body { gap: 22px; }
.buehne { grid-template-columns: 1fr; gap: 24px; align-items: start; }
.bild { aspect-ratio: 5 / 3; max-height: none; justify-self: stretch; }
.bild img { object-position: 50% 44%; }
.bild .mono { display: none; }
.info { gap: 18px; }
.zeile { gap: 16px; }
:root { --h: 56px; }
.knopf { font-size: 16px; }
.schicht { padding: 0 24px; }
.folge { gap: 4px; }
.folge li { font-size: 9.5px; letter-spacing: .1em; }
.werte { gap: 8px 18px; }
}
/* Reduzierte Bewegung: Zustände per Farbe, Icon und Text; kein Morph, kein Shake, Spinner langsamer */
.reduziert .knopf { transition: background-color .15s linear; scale: 1 !important; animation: none !important; }
.reduziert .schicht { transition: opacity .15s linear; scale: 1 !important; }
.reduziert .s-idle .pfeil { transition: none; translate: none !important; }
.reduziert .haken path { transition: none; }
.reduziert .lader svg { animation-duration: 1.2s; }
.reduziert [data-variante="kreis"] .knopf[data-state="loading"] { background: var(--tinte); }
.reduziert [data-variante="kreis"] .knopf[data-state="loading"] .lader { opacity: 1; }
.reduziert .ring { display: none; }
.reduziert .knopf[data-state="fehler"] { box-shadow: 0 0 0 3px var(--papier), 0 0 0 5px var(--rot); }
.reduziert .zeiger { transition: opacity .15s linear; }
</style>
</head>
<body data-variante="label">
<header class="kopf">
<p class="mono">Werkstatt Scherbe · Lindenstraße 12</p>
<div class="rechts">
<p class="mono korb" data-korb>Termine <b data-anzahl>0</b></p>
<div class="umschalter" role="group" aria-label="Variante">
<button type="button" data-v="label" aria-pressed="true">Label</button>
<button type="button" data-v="kreis" aria-pressed="false">Kreis</button>
<button type="button" data-v="fehler" aria-pressed="false">Fehler</button>
</div>
</div>
</header>
<main class="buehne">
<figure class="bild">
<img src="bilder/keramik-stillleben-hoch.jpg" alt="Helle Vase, dunkle runde Vase, ein gelber Becher und flache Schalen auf weißen Sockeln vor Terrakotta">
<figcaption class="mono">Di bis Sa · 10 bis 19 Uhr</figcaption>
</figure>
<div class="info">
<p class="mono">Kurs · 2 Stunden</p>
<h1>Drehen <span class="serif">& Glasieren</span></h1>
<p class="text">Eine Stunde an der Scheibe, eine am Glasurtisch. Zwei Wochen später holen Sie Ihre Schale aus dem Ofen ab.</p>
<div class="optionen" aria-hidden="true"><span class="an">Do 14:30</span><span>Do 16:00</span><span>Fr 10:15</span><span>Sa 9:00</span></div>
<div class="zeile">
<p class="preis">58 €<small>vor Ort zahlen</small></p>
<button class="knopf" type="button" data-knopf data-state="idle">
<span class="schicht s-idle" data-schicht="idle">Termin buchen
<svg class="pfeil" viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11m-4.5-4.5L15 10l-4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span class="schicht s-erfolg" data-schicht="erfolg" aria-hidden="true">
<svg class="haken" viewBox="0 0 20 20"><path pathLength="1" d="M4.5 10.5l3.6 3.6L15.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>Gebucht</span>
<span class="schicht s-fehler" data-schicht="fehler" aria-hidden="true">
<svg viewBox="0 0 20 20"><circle cx="10" cy="10" r="7.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 6v4.6M10 13.6v.1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>Schon vergeben</span>
<span class="lader" aria-hidden="true"><svg viewBox="0 0 22 22"><circle cx="11" cy="11" r="8.5" fill="none" stroke="currentColor" stroke-opacity=".25" stroke-width="2"/><path d="M11 2.5a8.5 8.5 0 0 1 8.5 8.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
<svg class="ring" viewBox="0 0 60 60" aria-hidden="true"><circle class="spur" cx="30" cy="30" r="28.5"/><circle class="fort" cx="30" cy="30" r="28.5" pathLength="1"/></svg>
</button>
</div>
<p class="nur-sr" aria-live="polite" data-status></p>
<ol class="folge mono" aria-hidden="true" data-folge>
<li data-schritt="idle">Idle</li>
<li data-schritt="hover">Hover<small>entfällt</small></li>
<li data-schritt="press">Press</li>
<li data-schritt="loading">Loading</li>
<li data-schritt="ende" data-ende>Erfolg</li>
</ol>
</div>
</main>
<footer class="werte mono" data-werte></footer>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2 1.5 16.5 9.6l-6.4 1.4-3.1 6Z" fill="#f2e6d4" stroke="#2a1210" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const knopf = document.querySelector('[data-knopf]');
const schichten = Object.fromEntries([...knopf.querySelectorAll('[data-schicht]')].map(s => [s.dataset.schicht, s]));
const status = document.querySelector('[data-status]');
const schritte = [...document.querySelectorAll('[data-folge] li')];
const ende = document.querySelector('[data-ende]');
const werteFeld = document.querySelector('[data-werte]');
const zeiger = document.querySelector('[data-zeiger]');
const umschalter = [...document.querySelectorAll('[data-v]')];
const anzahl = document.querySelector('[data-anzahl]');
let imKorb = 0;
function korb(n) { imKorb = n; anzahl.textContent = n; anzahl.classList.toggle('neu', n > 0 && zustand === 'erfolg'); }
// Zeiten (ms)
const VERZOEGERUNG = 300; // Loading erst nach 300 ms zeigen (kein Flackern bei schnellen Antworten)
const MINDESTENS = 500; // Loading mindestens 500 ms halten
const ERFOLG_HALTEN = 2000; // Erfolg/Fehler stehen 2 s (Collins: 2000 ms)
const ANTWORT = { label: 1500, kreis: 2200, fehler: 1500 }; // simulierte Serverantwort
const WERTE = {
label: [['Label', '200 ms ease-in-out'], ['Laden', 'Label scale .9 · Loader 150 ms'], ['Spinner', '600 ms linear'], ['Press', 'scale .97 · 150 ms'], ['Breite', '300 ms'], ['Häkchen', 'pathLength 300 ms'], ['Erfolg', '2000 ms']],
kreis: [['Breite', '300 ms → Kreis 60 px'], ['Ring', 'Ladedauer · ease-in-out'], ['Label', '200 ms ease-in-out'], ['Press', 'scale .97'], ['Häkchen', 'pathLength 300 ms'], ['Erfolg', '2000 ms']],
fehler: [['Shake', '4 × ±6 px · 360 ms'], ['Farbe', '200 ms ease-in-out'], ['Laden', 'nach 300 ms · min. 500 ms'], ['Spinner', '600 ms linear'], ['Fehler', '2000 ms, dann zurück']],
};
let variante = 'label', zustand = 'idle', hover = false, press = false, uhren = [], ladeStart = 0;
const spaeter = (fn, ms) => { const u = setTimeout(fn, ms); uhren.push(u); return u; };
function breite(z) {
const h = knopf.offsetHeight;
if (z === 'loading') return (variante === 'kreis' && !reduziert()) ? h : breite('idle');
const s = schichten[z] || schichten.idle;
return Math.ceil(s.offsetWidth);
}
function feste() { // Breite immer aus der Schicht des Zustands, Morph per transition
if (reduziert()) { // ohne Morph: eine feste Breite für alle Zustände
knopf.style.setProperty('--breite', Math.max(breite('idle'), breite('erfolg'), breite('fehler')) + 'px');
} else knopf.style.setProperty('--breite', breite(zustand) + 'px');
}
function folge() {
const z = zustand === 'idle' ? (press ? 'press' : hover ? 'hover' : 'idle') : zustand === 'loading' ? 'loading' : 'ende';
const reihe = ['idle', 'hover', 'press', 'loading', 'ende'], i = reihe.indexOf(z);
schritte.forEach((li, j) => { li.classList.toggle('an', j === i); li.classList.toggle('war', j < i); li.classList.toggle('gut', zustand === 'erfolg'); li.classList.toggle('schlecht', zustand === 'fehler'); });
ende.firstChild.textContent = variante === 'fehler' ? 'Fehler' : 'Erfolg';
}
function setze(z) {
zustand = z;
knopf.dataset.state = z;
knopf.setAttribute('aria-disabled', z === 'loading' ? 'true' : 'false');
if (z === 'loading') knopf.style.setProperty('--ladedauer', (ANTWORT[variante] - VERZOEGERUNG - 200) + 'ms');
status.textContent = { idle: '', loading: 'Termin wird gebucht …', erfolg: 'Donnerstag, 14:30 Uhr ist für Sie reserviert.', fehler: 'Nicht gebucht. Der Termin wurde gerade vergeben.' }[z];
feste(); folge();
if (z === 'erfolg') korb(imKorb + 1); else anzahl.classList.remove('neu');
}
function ausloesen() {
if (zustand !== 'idle') return;
const t0 = performance.now();
const lade = spaeter(() => { ladeStart = performance.now(); setze('loading'); }, VERZOEGERUNG);
spaeter(() => {
const fertig = () => {
setze(variante === 'fehler' ? 'fehler' : 'erfolg');
spaeter(() => setze('idle'), ERFOLG_HALTEN);
};
if (!ladeStart) { clearTimeout(lade); fertig(); return; } // schneller als 300 ms: gar kein Loading
spaeter(fertig, Math.max(0, MINDESTENS - (performance.now() - ladeStart)));
}, ANTWORT[variante]);
ladeStart = 0;
}
function werte() {
werteFeld.innerHTML = WERTE[variante].map(([k, v]) => `<span>${k} <b>${v}</b></span>`).join('');
}
function waehle(v) {
variante = v;
document.body.dataset.variante = v;
umschalter.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
werte(); folge(); feste();
}
// Bedienung
knopf.addEventListener('click', ausloesen);
knopf.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse') { hover = true; folge(); } });
knopf.addEventListener('pointerleave', () => { hover = false; press = false; folge(); });
knopf.addEventListener('pointerdown', () => { press = true; folge(); });
addEventListener('pointerup', () => { if (press) { press = false; folge(); } });
knopf.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { press = true; folge(); setTimeout(() => { press = false; folge(); }, 150); } });
umschalter.forEach(b => b.addEventListener('click', () => { neustart(); waehle(b.dataset.v); }));
document.fonts.ready.then(feste);
addEventListener('resize', feste);
// Vorführung: ein gedachter Zeiger fährt an, hovert, drückt, klickt und zieht wieder ab
let runde = 0, laeuft = false;
function zeigerNach(x, y) { zeiger.style.transform = `translate(${x - 3}px, ${y - 2}px)`; }
function zeigerZiel(draussen) {
const r = knopf.getBoundingClientRect();
if (draussen) return [Math.min(innerWidth - 30, r.left + r.width * 0.8 + 70), Math.min(innerHeight - 30, r.bottom + 110)];
return [r.left + Math.min(r.width * 0.62, r.width - 26), r.top + r.height * 0.58];
}
function einmal() {
if (html.classList.contains('vorschau')) { waehle(['label', 'kreis', 'fehler'][runde % 3]); runde++; }
korb(0);
zeiger.style.transition = 'none'; zeigerNach(...zeigerZiel(true)); zeiger.getBoundingClientRect(); zeiger.style.transition = '';
spaeter(() => zeigerNach(...zeigerZiel(false)), 250);
spaeter(() => { hover = true; knopf.classList.add('ist-hover'); folge(); }, 950);
spaeter(() => { press = true; knopf.classList.add('ist-press'); zeiger.classList.add('drueckt'); folge(); }, 1650);
spaeter(() => { press = false; knopf.classList.remove('ist-press'); zeiger.classList.remove('drueckt'); folge(); ausloesen(); }, 1850);
const t = 1850 + ANTWORT[variante];
spaeter(() => { hover = false; knopf.classList.remove('ist-hover'); zeigerNach(...zeigerZiel(true)); folge(); }, t + 500);
spaeter(einmal, t + ERFOLG_HALTEN + 900);
}
function aufraeumen() {
uhren.forEach(clearTimeout); uhren = [];
hover = press = false; knopf.classList.remove('ist-hover', 'ist-press'); zeiger.classList.remove('drueckt');
setze('idle');
}
function neustart() { laeuft = false; html.classList.remove('vorfuehrung'); aufraeumen(); korb(0); }
function vorfuehren() { aufraeumen(); laeuft = true; html.classList.add('vorfuehrung'); einmal(); }
function pause() { uhren.forEach(clearTimeout); uhren = []; }
function weiter() { if (laeuft) vorfuehren(); }
waehle('label'); setze('idle');
})();
</script>
</body>
</html>