Gedrückt halten zum Bestätigen
Ein Angebot wird per Halten angenommen: Ring, Balken oder Schwelle füllen sich in 1,2 s, Loslassen spult dreimal so schnell zurück.
Für folgenreiche Aktionen wie Löschen, Absenden oder Kaufen muss der Knopf gehalten werden. Während des Haltens füllt sich ein Ring oder Balken, der Knopf schrumpft leicht und ein Schein wächst. Wer vorher loslässt, spult den Fortschritt zurück. Ist er voll, folgen eine kurze Erfolgsanimation und die Aktion.
Die Geste ersetzt den Bestätigungsdialog. Ein versehentlicher Klick löst nichts aus, das Halten fühlt sich bewusst an. Der wachsende Fortschritt zeigt jederzeit, wie weit es noch ist. Das Zurückspulen beim Loslassen macht klar, dass noch nichts passiert ist.
Wann einsetzen
Bei destruktiven oder verbindlichen Aktionen: Löschen, Angebot annehmen, Bestellung absenden. Außerdem in Spielen und auf Erlebnis-Seiten als „Halten zum Starten“.
Wann nicht
Nicht für häufige Standardaktionen, dort bremst das Halten nur. Nie als einzigen Weg, wenn Nutzer motorisch eingeschränkt sein könnten.
Varianten
- Ring: SVG-Ring um einen runden Knopf, dazu leichtes Schrumpfen und wachsender Schein (Motion-Beispiel)
- Balken: Füllung im Knopf von links
- Schwelle: Der Druck zählt erst nach 250 ms, dann springen Haltekreise von scale .65 auf 1 (Unseen, dort als Navigation)
- „Halten zum Starten“ als Intro auf Erlebnis-Seiten
Umsetzung
Den Fortschritt p (0 bis 1) als CSS-Variable --p führen, per requestAnimationFrame oder mit el.animate() und @property --p. Halten füllt in 1200 ms linear. Ring: SVG-Kreis mit pathLength="1", stroke-dashoffset: calc(1 - var(--p)), um -90° gedreht. Balken: clip-path: inset(0 calc((1 - var(--p)) * 100%) 0 0). Bei pointerup, pointerleave oder pointercancel vor dem Ende läuft p dreimal so schnell zurück (mit el.animate: reverse() und playbackRate). Bei p = 1 Erfolg zeigen und die Aktion auslösen. Tastatur: Leertaste oder Enter halten, keydown mit e.repeat ignorieren, keyup lässt los.
Robust umsetzen
Telefon. Sehr gut geeignet. Am Knopf Kontextmenü und Textauswahl unterdrücken: user-select: none, -webkit-touch-callout: none, touch-action: none und contextmenu verhindern. Das Ziel groß halten.
Ohne Bewegung. Fortschritt als schlichter Balken, ohne Schein, ohne Schrumpfen und ohne Kreise.
Barrierefrei. Immer eine Alternative anbieten, etwa einen Link oder einen Bestätigungsdialog (WCAG 2.5.1 und 2.5.7). Die Anleitung gehört ins Label („Zum Annehmen gedrückt halten“). Den Fortschritt optional per role="progressbar" und aria-valuenow melden, den Erfolg per aria-live ansagen.
Code
<!DOCTYPE html>
<!--
Gedrückt halten zum Bestätigen
Fundus · Mikroanimationen · https://cecodesign.de/lab/fundus/mikroanimationen/hold-to-confirm/
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>Gedrückt halten zum Bestätigen · 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: 'Host Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/host-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 300 800; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; 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; }
/* Welt: Rezept „Stille Visitenkarte“. Graustufen, eine einzige Farbe für Zustände. */
:root {
--grund: #fcfcfc; --text: #0b0b09; --zweit: #646464; --linie: #e8e8e8; --flaeche: #f0f0f0;
--blau: #2f5bea; /* einzige Farbe: Fokus, Fortschritt */
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, Messwert (Unseen) */
--ruhig: cubic-bezier(0.165, 0.84, 0.44, 1); /* easeOutQuart, Rezept: Hover, Text */
--p: 0; /* Haltefortschritt 0 … 1, per JS */
}
@media (prefers-color-scheme: dark) {
:root { --grund: #111110; --text: #ededeb; --zweit: #9a9a97; --linie: #262624; --flaeche: #1b1b1a; --blau: #6d8cff; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 16px/1.6 'Host Grotesk', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; padding: clamp(24px, 5vw, 56px) clamp(16px, 5vw, 56px); display: grid; align-content: center; }
.mono { font: 450 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--zweit); }
.zahl { font-variant-numeric: tabular-nums; }
em { font: italic 400 1.05em/1 'Newsreader', Georgia, serif; font-optical-sizing: auto; }
.nw { white-space: nowrap; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Seite 1072 px, Lesespalte 640 px links, rechts eine schmale Randspalte */
.blatt { width: 100%; max-width: 1072px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 640px) minmax(0, 1fr); column-gap: clamp(32px, 6vw, 96px); }
.kopf { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: baseline; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--linie); }
.kopf .name { font-weight: 500; }
.kopf .name span { color: var(--zweit); font-weight: 400; margin-left: .4em; }
.kopf .mono { white-space: nowrap; }
.spalte { grid-column: 1; padding-top: clamp(28px, 4.4vw, 44px); }
.satz { font-size: clamp(1.25rem, 2vw, 1.5rem); font-weight: 500; line-height: 1.4; letter-spacing: -.01em; text-wrap: pretty; max-width: 34ch; }
.satz em { font-weight: 400; }
.posten { list-style: none; margin-top: clamp(22px, 3.4vw, 34px); font-size: .9375rem; }
.posten li { display: grid; grid-template-columns: 2.6em 1fr 5.5em 6em; gap: 12px; align-items: baseline; padding: 9px 0; border-top: 1px solid var(--linie); }
.posten .nr, .posten .tage { color: var(--zweit); }
.posten .nr { font: 450 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .08em; }
.posten .tage, .posten .betrag { text-align: right; font-variant-numeric: tabular-nums; }
.posten .summe { border-top-color: var(--text); font-weight: 500; }
.posten .summe .tage { font-weight: 400; }
/* Randspalte */
.rand { grid-column: 2; padding-top: clamp(28px, 4.4vw, 44px); display: grid; align-content: start; gap: 18px; font-size: .875rem; }
.rand dt { margin-bottom: 2px; }
.rand dd { font-variant-numeric: tabular-nums; }
/* ——— Die Aktion ——— */
.aktion { margin-top: clamp(26px, 4vw, 40px); display: grid; grid-template-columns: auto 1fr; align-items: center; column-gap: 28px; min-height: 120px; }
.zone { position: relative; width: 120px; height: 120px; display: grid; place-items: center; }
.zone > .kreis { margin: -8px; }
.zone > * { grid-area: 1 / 1; }
.halten {
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: none;
border: 0; cursor: pointer; font: inherit; color: var(--grund); background: var(--text);
-webkit-tap-highlight-color: transparent; outline: none;
}
.halten:focus-visible { outline: 2px solid var(--blau); outline-offset: 5px; }
/* Rund (Ring, Schwelle) */
.rund { position: relative; z-index: 2; width: 88px; height: 88px; border-radius: 50%; display: grid; place-items: center; scale: calc(1 - .06 * var(--p)); font-weight: 500; font-size: 14px; }
.rund > * { grid-area: 1 / 1; }
.rund .wort { transition: opacity .12s var(--ruhig); }
.rund .punkt { width: 8px; height: 8px; border-radius: 50%; background: var(--grund); scale: 0; transition: scale .2s var(--kurve); }
.rund .haken { width: 30px; height: 30px; overflow: visible; }
.haken path { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; }
.schein { z-index: 0; width: 88px; height: 88px; border-radius: 50%; background: var(--blau); filter: blur(20px); opacity: calc(var(--p) * .42); scale: calc(1 + var(--p)); pointer-events: none; }
.ring { z-index: 1; width: 120px; height: 120px; rotate: -90deg; overflow: visible; pointer-events: none; transition: scale .2s var(--kurve), opacity .2s var(--kurve); }
.ring circle { fill: none; }
.ring .spur { stroke: var(--linie); stroke-width: 1.5; }
.ring .bogen { stroke: var(--blau); stroke-width: 3; stroke-linecap: round; stroke-dasharray: 1 2; stroke-dashoffset: calc(1 - var(--p)); opacity: calc(var(--p) * 40); }
.kreis { z-index: 0; width: 136px; height: 136px; border-radius: 50%; border: 1px solid var(--linie); scale: .65; opacity: 0; pointer-events: none; transition: scale .2s var(--kurve), opacity .2s var(--kurve); }
.stand { display: none; }
/* Variante Schwelle: Ring wartet hinter dem Knopf, nach 250 ms springen die Kreise auf */
.aktion[data-variante="schwelle"] .ring { scale: .65; opacity: 0; }
.aktion[data-variante="schwelle"] .wort { opacity: 0; }
.aktion[data-variante="schwelle"] .punkt { scale: 1; }
.aktion[data-variante="schwelle"] .schein { display: none; }
.aktion[data-variante="schwelle"].scharf .ring,
.aktion[data-variante="schwelle"].erledigt .ring { scale: 1; opacity: 1; }
.aktion[data-variante="schwelle"].scharf .kreis { scale: 1; opacity: 1; }
.aktion[data-variante="schwelle"].scharf .punkt,
.aktion[data-variante="schwelle"].erledigt .punkt { scale: 0; }
.aktion:not([data-variante="schwelle"]) .kreis { display: none; }
/* Variante Balken: Pille, Füllung von links */
.lang { display: none; position: relative; width: 340px; max-width: 100%; height: 56px; border-radius: 999px; background: transparent; color: var(--text); box-shadow: inset 0 0 0 1px var(--text); overflow: hidden; scale: calc(1 - .02 * var(--p)); }
.lang .zeile { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 22px; font-weight: 500; font-size: 15px; }
.lang .zeile .mono { color: inherit; opacity: .6; }
.lang .fuellung { position: absolute; inset: 0; background: var(--text); color: var(--grund); clip-path: inset(0 calc((1 - var(--p)) * 100%) 0 0); }
.lang .fertigzeile { opacity: 0; justify-content: center; gap: 10px; }
.lang .fertigzeile .haken { width: 18px; height: 18px; }
.aktion[data-variante="balken"] { column-gap: 32px; }
.aktion[data-variante="balken"] .zone { display: none; }
.aktion[data-variante="balken"] .lang { display: block; }
/* Erfolg */
.erledigt .rund { scale: 1; transition: scale .25s var(--kurve); }
.erledigt .wort { opacity: 0; }
.erledigt .haken path { stroke-dashoffset: 0; transition: stroke-dashoffset .32s var(--kurve) .08s; }
.erledigt .schein { opacity: 0; transition: opacity .48s var(--ruhig); }
.erledigt .lang { scale: 1; transition: scale .25s var(--kurve); }
.erledigt .lang .fuellung .zeile:not(.fertigzeile) { opacity: 0; transition: opacity .12s var(--ruhig); }
.erledigt .lang .fertigzeile { opacity: 1; transition: opacity .2s var(--ruhig) .06s; }
.aktion.erledigt .halten { cursor: default; }
.text .titel { font-weight: 500; }
.text .hinweis { color: var(--zweit); font-size: .875rem; line-height: 1.55; max-width: 40ch; margin-top: 2px; text-wrap: pretty; }
.text .hinweis a, .text button.leise { color: inherit; text-decoration: underline; text-decoration-color: var(--linie); text-underline-offset: .2em; transition: color .12s var(--ruhig), text-decoration-color .12s var(--ruhig); }
.text .hinweis a:hover, .text button.leise:hover { color: var(--text); text-decoration-color: currentColor; }
.text button.leise { font: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
.text :focus-visible { outline: 2px solid var(--blau); outline-offset: 2px; border-radius: 2px; }
.text > * { transition: opacity .16s var(--ruhig); }
.text.wechsel > * { opacity: 0; }
/* Werkbank: Varianten-Umschalter und verwendete Werte */
.werkbank { grid-column: 1 / -1; margin-top: clamp(28px, 4vw, 44px); padding-top: 12px; border-top: 1px solid var(--linie); display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; align-items: baseline; }
.wahl { display: flex; align-items: baseline; gap: 4px; }
.wahl .mono:first-child { margin-right: 8px; }
.wahl button { font: 450 11px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--zweit); background: none; border: 0; padding: 6px 8px; border-radius: 4px; cursor: pointer; transition: color .12s var(--ruhig), background-color .12s var(--ruhig); }
.wahl button:hover { background: var(--flaeche); color: var(--text); }
.wahl button[aria-pressed="true"] { color: var(--text); background: var(--flaeche); }
.wahl button:focus-visible { outline: 2px solid var(--blau); outline-offset: 1px; }
.werte { text-transform: none; letter-spacing: .02em; }
/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .7s var(--kurve); }
.zeiger path { transition: scale .12s var(--ruhig); transform-origin: 3px 2px; }
.zeiger.drueckt path { scale: .82; }
.vorfuehrung .zeiger { display: block; }
.vorschau body { overflow: hidden; padding-top: 32px; padding-bottom: 24px; }
.vorschau .spalte, .vorschau .rand { padding-top: 26px; }
.vorschau .posten { margin-top: 20px; }
.vorschau .posten li { padding: 7px 0; }
.vorschau .aktion { margin-top: 24px; }
.vorschau .werkbank { margin-top: 22px; }
/* Touch: kein Hover, größere Ziele, Kontextmenü und Textauswahl sind am Knopf unterdrückt */
@media (hover: none) {
.wahl button { padding: 10px 10px; }
.rund { width: 96px; height: 96px; }
}
/* Schmal: Randspalte unter den Kopf, Spalten der Posten vereinfachen */
@media (max-width: 760px) {
.blatt { grid-template-columns: minmax(0, 1fr); }
.rand { grid-column: 1; grid-row: 2; grid-template-columns: 1fr 1fr; gap: 12px 16px; padding-top: 16px; }
.spalte { padding-top: 24px; }
.kopf .name span { display: block; margin-left: 0; }
}
@media (max-width: 480px) {
.posten li { grid-template-columns: 1.8em 1fr auto; }
.posten .tage { display: none; }
.aktion { column-gap: 16px; }
.aktion { grid-template-columns: 120px 1fr; }
.aktion[data-variante="balken"] { grid-template-columns: 1fr; row-gap: 16px; }
.lang { width: 100%; }
.werte { width: 100%; }
}
/* Reduzierte Bewegung: kein Schrumpfen, kein Schein, keine Kreise. Fortschritt als schlichter Balken. */
.reduziert .rund, .reduziert .lang, .reduziert .erledigt .rund, .reduziert .erledigt .lang { scale: none; }
.reduziert .schein, .reduziert .ring, .reduziert .kreis { display: none; }
.reduziert .aktion[data-variante="schwelle"] .punkt { scale: 1; transition: none; }
.reduziert .aktion[data-variante="schwelle"].scharf .punkt { scale: 1; }
.reduziert .stand { display: block; position: absolute; z-index: 3; bottom: 4px; width: 88px; height: 3px; border-radius: 2px; background: var(--linie); overflow: hidden; align-self: end; }
.reduziert .stand i { display: block; height: 100%; width: calc(var(--p) * 100%); background: var(--blau); }
.reduziert .zone { place-items: center; }
.reduziert * { transition-duration: .01ms !important; transition-delay: 0s !important; animation-duration: .01ms !important; }
</style>
</head>
<body>
<main class="blatt">
<header class="kopf">
<p class="name">Hanna Pölzl <span>Interaktion & Interface, Salzburg</span></p>
<p class="mono">Angebot 26-031</p>
</header>
<div class="spalte">
<h1 class="satz">Eine neue Website für die <em>Hutmacherei Ebner</em> in Hallein: ruhig, schnell und mit einem kleinen Shop für Maßanfertigungen.</h1>
<ol class="posten" aria-label="Positionen">
<li><span class="nr">01</span><span>Kennenlernen und Struktur</span><span class="tage">1 Tag</span><span class="betrag">960 €</span></li>
<li><span class="nr">02</span><span>Gestaltung, Startseite und vier Vorlagen</span><span class="tage">4 Tage</span><span class="betrag">3.840 €</span></li>
<li><span class="nr">03</span><span>Umsetzung mit Shop für Anfragen</span><span class="tage">5 Tage</span><span class="betrag">4.800 €</span></li>
<li><span class="nr">04</span><span>Einschulung, zwei Termine</span><span class="tage">0,5 Tage</span><span class="betrag">480 €</span></li>
<li class="summe"><span></span><span>Summe netto</span><span class="tage">10,5 Tage</span><span class="betrag">10.080 €</span></li>
</ol>
<section class="aktion" data-aktion data-variante="ring" aria-label="Angebot annehmen">
<div class="zone">
<span class="kreis" aria-hidden="true"></span>
<span class="schein" aria-hidden="true"></span>
<svg class="ring" viewBox="0 0 120 120" aria-hidden="true"><circle class="spur" cx="60" cy="60" r="56"/><circle class="bogen" cx="60" cy="60" r="56" pathLength="1"/></svg>
<button type="button" class="halten rund" data-halten aria-describedby="hinweis">
<span class="wort" aria-hidden="true">Halten</span>
<span class="punkt" aria-hidden="true"></span>
<svg class="haken" viewBox="0 0 30 30" aria-hidden="true"><path d="M7.5 15.5l5 5 10-11" pathLength="1"/></svg>
<span class="nur-sr">Angebot annehmen, gedrückt halten</span>
</button>
<span class="stand" aria-hidden="true"><i></i></span>
</div>
<button type="button" class="halten lang" data-halten aria-describedby="hinweis">
<span class="zeile" aria-hidden="true">Angebot annehmen<span class="mono">halten</span></span>
<span class="fuellung" aria-hidden="true">
<span class="zeile">Angebot annehmen<span class="mono">halten</span></span>
<span class="zeile fertigzeile"><svg class="haken" viewBox="0 0 30 30"><path d="M7.5 15.5l5 5 10-11" pathLength="1"/></svg>Angenommen</span>
</span>
<span class="nur-sr">Angebot annehmen, gedrückt halten</span>
</button>
<div class="text" data-text>
<p class="titel" data-titel>Zum Annehmen gedrückt halten</p>
<p class="hinweis" id="hinweis" data-hinweis>Eine Sekunde halten, Loslassen bricht ab. Lieber schriftlich? Eine kurze Antwort per <a href="#mail" class="nw">E-Mail</a> genügt.</p>
</div>
<p class="nur-sr" aria-live="polite" data-ansage></p>
<span class="nur-sr" role="progressbar" aria-label="Haltefortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-fortschritt></span>
</section>
</div>
<dl class="rand">
<div><dt class="mono">Für</dt><dd>Hutmacherei Ebner, Hallein</dd></div>
<div><dt class="mono">Gültig bis</dt><dd>15.11.2026</dd></div>
<div><dt class="mono">Tagessatz</dt><dd>960 € netto</dd></div>
<div><dt class="mono">Start</dt><dd>ab 16.11.2026</dd></div>
</dl>
<footer class="werkbank">
<div class="wahl" role="group" aria-label="Variante">
<span class="mono">Variante</span>
<button type="button" data-v="ring" aria-pressed="true">Ring</button>
<button type="button" data-v="balken" aria-pressed="false">Balken</button>
<button type="button" data-v="schwelle" aria-pressed="false">Schwelle</button>
</div>
<p class="mono werte" data-werte></p>
</footer>
</main>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M3 2 17 10.2l-6.4 1.4-3.1 6Z" fill="#0b0b09" stroke="#fcfcfc" stroke-width="1.3" stroke-linejoin="round"/></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const aktion = document.querySelector('[data-aktion]');
const knoepfe = [...aktion.querySelectorAll('[data-halten]')];
const textBox = aktion.querySelector('[data-text]');
const titel = aktion.querySelector('[data-titel]');
const hinweis = aktion.querySelector('[data-hinweis]');
const ansage = aktion.querySelector('[data-ansage]');
const fortschritt = aktion.querySelector('[data-fortschritt]');
const werteZeile = document.querySelector('[data-werte]');
const wahl = [...document.querySelectorAll('.wahl [data-v]')];
const zeiger = document.querySelector('[data-zeiger]');
// Werte: Halten 1200 ms linear, Rücklauf mit 3-facher Geschwindigkeit (Steckbrief),
// Schwelle 250 ms vor dem Start (Unseen, gemessen)
const HALTEN = 1200, ZURUECK = 3, SCHWELLE = 250;
const WERTE = {
ring: 'halten 1200 ms linear · zurück ×3 = 400 ms · Knopf scale 1 → .94 · Schein blur 20 px, scale 1 → 2 · Erfolg 250 ms expo.out',
balken: 'halten 1200 ms linear · zurück ×3 = 400 ms · Füllung clip-path inset von links · scale 1 → .98 · Erfolg 250 ms expo.out',
schwelle: 'Schwelle 250 ms · Punkt scale 1 → 0, Ring und Kreis scale .65 → 1 in 200 ms expo.out · dann halten 1200 ms'
};
const TEXT = {
titel: 'Zum Annehmen gedrückt halten',
hinweis: hinweis.innerHTML
};
let variante = 'ring', p = 0, haelt = false, scharf = false, fertig = false;
let raf = 0, letzte = 0, schwelleUhr = 0, zuletztAngesagt = -1;
function setze(wert) {
p = wert;
aktion.style.setProperty('--p', p.toFixed(4));
const stufe = Math.round(p * 10) * 10;
if (stufe !== zuletztAngesagt) { zuletztAngesagt = stufe; fortschritt.setAttribute('aria-valuenow', stufe); }
}
function bild(t) {
const dt = Math.min(50, t - letzte); letzte = t;
if (haelt && scharf) setze(Math.min(1, p + dt / HALTEN));
else setze(Math.max(0, p - dt * ZURUECK / HALTEN)); // Rücklauf: 3× so schnell
if (p >= 1) { raf = 0; erfolg(); return; }
if (p <= 0 && !scharf) { raf = 0; return; }
raf = requestAnimationFrame(bild);
}
function laufen() { if (!raf) { letzte = performance.now(); raf = requestAnimationFrame(bild); } }
function druecken() {
if (fertig || haelt) return;
haelt = true;
if (variante === 'schwelle') {
// erst nach 250 ms gilt der Druck als Halten; ein kurzer Tipp löst nichts aus
schwelleUhr = setTimeout(() => { scharf = true; aktion.classList.add('scharf'); laufen(); }, SCHWELLE);
} else { scharf = true; laufen(); }
}
function loslassen() {
if (!haelt) return;
haelt = false; scharf = false;
clearTimeout(schwelleUhr);
aktion.classList.remove('scharf');
if (!fertig) laufen();
}
function texte(neuTitel, neuHinweis) {
if (reduziert()) { titel.textContent = neuTitel; hinweis.innerHTML = neuHinweis; return; }
textBox.classList.add('wechsel');
setTimeout(() => { titel.textContent = neuTitel; hinweis.innerHTML = neuHinweis; textBox.classList.remove('wechsel'); }, 160);
}
function erfolg() {
fertig = true; haelt = false; scharf = false;
aktion.classList.remove('scharf'); aktion.classList.add('erledigt');
const jetzt = new Date();
const zeit = jetzt.toLocaleDateString('de-AT', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' um ' + jetzt.toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' });
texte('Angenommen. Danke, Frau Ebner.', `<span class="zahl">Bestätigt am ${zeit}.</span> Ich melde mich morgen mit Terminen für das Kennenlernen. <button type="button" class="leise" data-zurueck>Rückgängig</button>`);
ansage.textContent = 'Angebot angenommen.';
if (navigator.vibrate && !html.classList.contains('vorfuehrung')) { try { navigator.vibrate(12); } catch (e) {} }
}
function zuruecksetzen() {
cancelAnimationFrame(raf); raf = 0; clearTimeout(schwelleUhr);
haelt = scharf = fertig = false;
aktion.classList.remove('scharf', 'erledigt');
setze(0);
titel.textContent = TEXT.titel; hinweis.innerHTML = TEXT.hinweis; textBox.classList.remove('wechsel');
ansage.textContent = '';
}
function waehle(v) {
variante = v;
zuruecksetzen();
aktion.dataset.variante = v;
wahl.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
werteZeile.textContent = WERTE[v];
}
// Zeiger, Finger, Tastatur
knoepfe.forEach(k => {
k.addEventListener('pointerdown', e => {
if (e.button !== 0) return;
if (e.isTrusted && html.classList.contains('vorfuehrung')) beendeVorfuehrung();
druecken();
});
['pointerup', 'pointerleave', 'pointercancel', 'blur'].forEach(n => k.addEventListener(n, loslassen));
k.addEventListener('contextmenu', e => e.preventDefault());
k.addEventListener('keydown', e => {
if (e.key !== ' ' && e.key !== 'Enter') return;
e.preventDefault();
if (e.repeat) return; // gedrückte Taste wiederholt sich, das ist kein neuer Druck
druecken();
});
k.addEventListener('keyup', e => { if (e.key === ' ' || e.key === 'Enter') loslassen(); });
});
aktion.addEventListener('click', e => {
if (e.target.closest('[data-zurueck]')) { zuruecksetzen(); knoepfe.find(k => k.offsetParent)?.focus(); }
if (e.target.closest('a[href="#mail"]')) e.preventDefault();
});
wahl.forEach(b => b.addEventListener('click', () => { beendeVorfuehrung(); waehle(b.dataset.v); }));
// Vorführung: ein gedachter Zeiger setzt zweimal an. Erst zu kurz (Rücklauf sichtbar), dann bis zum Ende.
const REIHE = ['ring', 'balken', 'schwelle'];
let uhren = [], runde = 0;
function spaeter(ms, fn) { uhren.push(setTimeout(fn, ms)); }
function ziel() {
const k = knoepfe.find(x => x.offsetParent) || knoepfe[0];
const r = k.getBoundingClientRect();
return variante === 'balken' ? { x: r.left + r.width * .62, y: r.top + r.height * .55 } : { x: r.left + r.width * .58, y: r.top + r.height * .6 };
}
function zeigerNach(x, y, sofort) {
zeiger.style.transition = sofort ? 'none' : '';
zeiger.style.transform = `translate(${x - 3}px, ${y - 2}px)`;
}
function eineRunde() {
waehle(REIHE[runde++ % REIHE.length]);
const z = ziel(), vorlauf = variante === 'schwelle' ? SCHWELLE : 0;
zeigerNach(z.x + 150, z.y + 110, true);
spaeter(60, () => zeigerNach(z.x, z.y));
spaeter(600, () => { zeiger.classList.add('drueckt'); druecken(); });
spaeter(1300 + vorlauf, () => { zeiger.classList.remove('drueckt'); loslassen(); });
spaeter(1900 + vorlauf, () => { zeiger.classList.add('drueckt'); druecken(); });
spaeter(1900 + vorlauf * 2 + HALTEN + 200, () => { zeiger.classList.remove('drueckt'); loslassen(); });
spaeter(1900 + vorlauf * 2 + HALTEN + 1900, () => zeigerNach(z.x + 150, z.y + 110));
spaeter(1900 + vorlauf * 2 + HALTEN + 2700, eineRunde);
}
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
eineRunde();
}
function pause() { uhren.forEach(clearTimeout); uhren = []; cancelAnimationFrame(raf); raf = 0; }
function beendeVorfuehrung() { if (!html.classList.contains('vorfuehrung')) return; pause(); html.classList.remove('vorfuehrung'); zeiger.classList.remove('drueckt'); }
function neustart() { beendeVorfuehrung(); runde = 0; waehle('ring'); }
function weiter() { if (html.classList.contains('vorfuehrung') && !uhren.length) { runde = Math.max(0, runde - 1); vorfuehren(); } }
waehle('ring');
})();
</script>
</body>
</html>