Zum Inhalt
← Animationsstile

Animationsstil „Federnd verspielt“

Stickerkarten landen mit Überschwingen, Reiter, Schalter und Knöpfe federn nach; gezogene Karten schwingen per Federphysik zurück.

Elemente schießen leicht über ihr Ziel hinaus und schwingen aus, statt weich abzubremsen. Karten landen mit Nachschwingen, Hover skaliert mit Überschwinger, gezogene Elemente kehren elastisch zurück. In CSS über cubic-bezier mit Werten über 1 oder linear()-Federkurven, in JS über Federphysik mit Steifigkeit und Dämpfung. Dauern kurz halten, sonst wirkt es albern.

Federn fühlen sich physisch an. Die Oberfläche wirkt lebendig, die Marke nahbar und spielerisch. Federn reagieren natürlich auf Unterbrechungen. Sie sind das Bewegungsvokabular moderner UIs wie iOS, Linear oder Raycast. Mit linear() geht das inzwischen auch ohne JavaScript.

Wann einsetzen

Verspielte Marken, Kreativstudios, Consumer-Apps. Überall, wo etwas ankommt: Karten, Sticker, Badges, Popovers, Toggles, Reiter-Indikatoren, Drück-Feedback und Drag-Interaktionen.

Wann nicht

Nie auf Opazität, das Überschwingen flackert. Nicht bei großen Flächen oder langen Wegen über den Bildschirm, das macht seekrank und wirkt kindlich. In seriösen Kontexten wie Finanzen, Recht oder Gesundheit höchstens sehr gedämpft.

Varianten

  • Überschwinger: cubic-bezier(0.34, 1.56, 0.64, 1) bzw. back.out(1.7), einmal über das Ziel, 150–600 ms
  • Elastisch: elastic.out(1, 0.75), weich und länger, etwa 0,5–1 s; lebhafter mit elastic.out(1.2, 1)
  • linear()-Feder: ca. 50 Stützpunkte, Spitze 1,585, 0,8 s (Chrome-Beispiel); grobe Kurzform linear(0, 1.1, 0.95, 1.02, 1)
  • JS-Feder: Steifigkeit 120, Dämpfung 14, für Ziehen und geschwindigkeitsabhängige Effekte
  • Extrem: cubic-bezier(.34, 4.56, .64, 1) für einzelne Hover-Effekte, 600–700 ms

Umsetzung

CSS-Token anlegen: --ease-back: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: linear(…) aus einem Generator (Motion spring oder Online-Generator). Ohne linear()-Unterstützung per @supports not (transition-timing-function: linear(0, 1)) auf cubic-bezier(0.34, 1.56, 0.64, 1) mit 400 ms zurückfallen. Nur transform, translate, scale und rotate federn lassen; Opazität, Farbe und Schatten bekommen eine eigene Kurve ohne Überschwingen. GSAP: 'back.out(1.7)', 'elastic.out(1, 0.75)'. JS-Feder: a = −k·x − d·v mit k 120, d 14, Masse 1, Zeitschritt höchstens 1/30 s (besser Teilschritte). Drück-Feedback: scale 0,95 in 150 ms mit dem Überschwinger. Stagger 40–60 ms.

Robust umsetzen

Telefon. Gut für Touch-Feedback. Amplitude etwas reduzieren, zum Beispiel kürzerer Landeweg und kleinere Skalierung. Hover-Effekte durch Antippen ersetzen.

Ohne Bewegung. Überschwingen entfernen. Entweder transition: none oder ease-out mit etwa 200 ms ohne Überschwingen. Wege weglassen, nur kurz einblenden.

Barrierefrei. Keine endlos wippenden Elemente, Aufmerksamkeitseffekte nur einmal. Überschwinger dezent halten, weil starke Schwingungen vestibulär belasten. Nie auf Opazität. Ziehbare Elemente zusätzlich per Klick oder Tastatur bedienbar machen.

Code

<!DOCTYPE html>
<!--
  Animationsstil „Federnd verspielt“
  Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-federnd-verspielt/
  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>Animationsstil „Federnd verspielt“ · 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: 'Bricolage Grotesque'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bricolage-grotesque@latest/latin-800-normal.woff2') format('woff2'); font-weight: 800; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }

:root {
  --papier: #f3ede2; --tinte: #1d1a16; --leise: rgba(29, 26, 22, .64); --linie: rgba(29, 26, 22, .14);
  --tomate: #ff5b3a; --butter: #ffd23f; --minze: #8fd9bd;
  --tomate-schrift: #e45133;   /* Tomate als große Schrift auf Papier: 3,2:1 */
  /* Drück-Feedback (Raycast): immer gleich, unabhängig von der Variante */
  --drueck: cubic-bezier(0.34, 1.56, 0.64, 1);
  --expo: cubic-bezier(0.16, 1, 0.3, 1);
}
/* Die drei Varianten: Kurve, Dauer für Karten, Dauer für UI-Teile */
body, body[data-variante="back"] { --feder: cubic-bezier(0.34, 1.56, 0.64, 1); --dauer: 500ms; --ui: 400ms; }
body[data-variante="feder"] {
  --feder: linear(0, 0.016 0.5%, 0.06 1%, 0.226 2%, 1.116 5.4%, 1.375 6.6%, 1.527 7.7%, 1.565 8.2%, 1.585 8.8%, 1.581 9.3%, 1.559 9.8%, 1.458 10.9%, 0.937 14.3%, 0.784 15.5%, 0.693 16.6%, 0.67 17.1%, 0.657 17.7%, 0.671 18.7%, 0.729 19.8%, 1.042 23.3%, 1.13 24.5%, 1.182 25.6%, 1.201 26.7%, 1.192 27.7%, 1.156 28.8%, 0.977 32.2%, 0.925 33.4%, 0.894 34.5%, 0.882 35.6%, 0.887 36.6%, 0.907 37.7%, 1.045 42.4%, 1.069 44.5%, 1.059 46.3%, 0.979 50.9%, 0.96 53.4%, 0.966 55.3%, 1.013 59.9%, 1.024 62.3%, 0.986 71.2%, 1.008 79.9%, 0.995 88.9%, 1);
  --dauer: 800ms; --ui: 800ms;
}
body[data-variante="elastisch"] {
  /* elastic.out(1, 0.75), gesampelt */
  --feder: linear(0, 0.142 2%, 0.284 4%, 0.422 6%, 0.55 8%, 0.665 10%, 0.767 12%, 0.853 14%, 0.925 16%, 0.982 18%, 1.026 20%, 1.059 22%, 1.081 24%, 1.094 26%, 1.1 28%, 1.101 30%, 1.086 35%, 1.061 40%, 1.036 45%, 1.016 50%, 1.002 55%, 0.995 60%, 0.993 65%, 0.993 70%, 0.994 75%, 0.996 80%, 0.998 85%, 0.999 90%, 1 95%, 1);
  --dauer: 1000ms; --ui: 600ms;
}
/* Browser ohne linear(): Überschwinger als Ersatz */
@supports not (transition-timing-function: linear(0, 1)) {
  body[data-variante] { --feder: cubic-bezier(0.34, 1.56, 0.64, 1); }
}
body { --versatz: 50ms; --hub: 80px; --schweb: 1.08; }

* { box-sizing: border-box; margin: 0; }
html { overflow-x: clip; 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.2vw, 40px) clamp(16px, 4vw, 52px); display: grid; grid-template-rows: auto 1fr auto; gap: clamp(18px, 3vw, 32px); overflow-x: clip; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; }
:focus-visible { outline: 2px solid var(--tomate); outline-offset: 3px; }

/* Kopf */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.logo { font: 800 26px/1 'Bricolage Grotesque', sans-serif; letter-spacing: -.04em; display: flex; align-items: center; gap: 8px; }
.logo i { width: 14px; height: 14px; border-radius: 50%; background: var(--tomate); display: inline-block; translate: 0 -6px; }
.nav { display: flex; gap: clamp(14px, 2.4vw, 32px); list-style: none; padding: 0; }
.nav a { color: inherit; text-decoration: none; }
.nav a:hover { color: var(--tinte); }

/* Inhalt */
.inhalt { display: grid; grid-template-columns: 1.08fr 1fr; align-items: center; gap: clamp(24px, 4vw, 64px); }
.text { display: grid; gap: clamp(16px, 2.2vw, 26px); justify-items: start; }
h1 { font: 800 clamp(44px, 6.6vw, 96px)/.9 'Bricolage Grotesque', sans-serif; letter-spacing: -.045em; }
h1 em { font: italic 400 1.08em/1 'Instrument Serif', serif; letter-spacing: -.02em; color: var(--tomate-schrift); padding-right: .04em; }
.lead { font-size: clamp(15px, 1.35vw, 18px); line-height: 1.5; max-width: 38ch; color: rgba(29, 26, 22, .78); }
.knoepfe { display: flex; gap: 10px; flex-wrap: wrap; }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 22px; border-radius: 999px; font-weight: 600; font-size: 15px;
  transition: scale 150ms var(--drueck), translate var(--ui) var(--feder), background-color 150ms linear; }
.knopf.voll { background: var(--tinte); color: var(--papier); }
.knopf.voll span { display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--tomate); color: var(--tinte); font-size: 13px; transition: rotate var(--ui) var(--feder); }
.knopf.rand { box-shadow: inset 0 0 0 1.5px var(--tinte); }
@media (hover: hover) { .knopf:hover { translate: 0 -3px; } .knopf.voll:hover span { rotate: -45deg; } }
.knopf:active, .knopf.gedrueckt { scale: .95; }

/* Kleine UI-Teile: Reiter mit Indikator, Schalter */
.tisch { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px; padding-top: 6px; }
.reiter { position: relative; display: flex; padding: 4px; border-radius: 999px; background: rgba(29, 26, 22, .07); }
.reiter button { position: relative; z-index: 1; height: 34px; padding: 0 16px; border-radius: 999px; font-size: 14px; font-weight: 600; color: var(--leise); transition: color 150ms linear; }
.reiter button[aria-selected="true"] { color: var(--tinte); }
.indikator { position: absolute; left: 0; top: 4px; height: 34px; border-radius: 999px; background: #fff; box-shadow: 0 1px 2px rgba(29,26,22,.12), 0 4px 12px rgba(29,26,22,.08);
  transition: translate var(--ui) var(--feder), width var(--ui) var(--feder); }
.schalter { display: inline-flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 500; }
.schalter .bahn { width: 46px; height: 28px; border-radius: 999px; background: rgba(29, 26, 22, .16); padding: 3px; transition: background-color 150ms linear; }
.schalter .knauf { display: block; width: 22px; height: 22px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(29,26,22,.25); transition: translate var(--ui) var(--feder), scale 150ms var(--drueck); }
.schalter[aria-checked="true"] .bahn { background: var(--minze); }
.schalter[aria-checked="true"] .knauf { translate: 18px 0; }
.schalter:active .knauf { scale: 1.12 .9; }

/* Bühne mit Stickerkarten */
.buehne { position: relative; width: min(100%, 58svh); aspect-ratio: 1 / 1.16; justify-self: center; }
.karte { position: absolute; left: var(--x); top: var(--y); width: 43%; z-index: var(--z, var(--z0, 1)); touch-action: none; user-select: none; -webkit-user-select: none;
  translate: 0 0; scale: 1; rotate: var(--r); opacity: 1;
  transition: translate var(--dauer) var(--feder) var(--warte, 0ms), scale var(--dauer) var(--feder) var(--warte, 0ms), rotate var(--dauer) var(--feder) var(--warte, 0ms), opacity 200ms linear var(--warte, 0ms); }
.blatt { display: block; background: #fffdf8; padding: 7px 7px 0; border-radius: 16px; box-shadow: 0 1px 1px rgba(29,26,22,.06), 0 10px 30px -8px rgba(29,26,22,.28);
  transition: scale var(--dauer) var(--feder), rotate var(--dauer) var(--feder), box-shadow var(--ui) var(--expo); }
.blatt img { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 10px; pointer-events: none; -webkit-user-drag: none; }
.blatt b { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding: 10px 4px 11px; font-size: clamp(12px, 1.2vw, 14px); font-weight: 600; text-align: left; }
.blatt b .mono { font-size: 9.5px; letter-spacing: .12em; }
@media (hover: hover) { .karte:hover .blatt { scale: var(--schweb); rotate: calc(var(--r) * -.6); box-shadow: 0 1px 1px rgba(29,26,22,.06), 0 22px 44px -12px rgba(29,26,22,.36); } }
.karte.schwebt .blatt { scale: var(--schweb); rotate: calc(var(--r) * -.6); box-shadow: 0 1px 1px rgba(29,26,22,.06), 0 22px 44px -12px rgba(29,26,22,.36); }
.karte.vorn { translate: 0 -14px; scale: 1.04; rotate: calc(var(--r) * .3); }
.karte.zieht { cursor: grabbing; }
.karte.zieht .blatt { scale: 1.05; }

.plakette { position: absolute; left: 0; top: 0; z-index: 20; width: clamp(84px, 22%, 118px); aspect-ratio: 1; border-radius: 50%; background: var(--butter); display: grid; place-items: center; text-align: center;
  font: 800 clamp(13px, 1.6vw, 17px)/1 'Bricolage Grotesque', sans-serif; letter-spacing: -.02em; box-shadow: 0 8px 22px -8px rgba(29,26,22,.35);
  rotate: -12deg; scale: 1; transition: scale var(--dauer) var(--feder) var(--warte, 0ms), rotate var(--dauer) var(--feder) var(--warte, 0ms), opacity 200ms linear var(--warte, 0ms); }
.plakette small { display: block; font: 500 9px/1.2 'Geist Mono', monospace; letter-spacing: .12em; text-transform: uppercase; margin-top: 4px; }

/* Ausgangszustand vor dem Landen (ohne Übergang) */
.buehne.weg .karte, .buehne.weg .plakette { transition: none; opacity: 0; }
.buehne.weg .karte { translate: 0 var(--hub); scale: .6; rotate: 0deg; }
.buehne.weg .plakette { scale: 0; rotate: -40deg; }
.buehne.kommt .karte, .buehne.kommt .plakette { --warte: calc(var(--i) * var(--versatz)); }

/* Werte und Varianten */
.fuss { display: flex; justify-content: space-between; align-items: end; gap: 14px 28px; flex-wrap: wrap; border-top: 1px solid var(--linie); padding-top: 14px; }
.varianten { display: flex; gap: 4px; flex-wrap: wrap; }
.varianten button { height: 28px; padding: 0 12px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--linie); transition: background-color 150ms linear, color 150ms linear, scale 150ms var(--drueck); }
.varianten button:active { scale: .95; }
.varianten button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); box-shadow: none; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 22px; }
.werte div { display: flex; gap: 8px; }
.werte dt { color: var(--leise); }
.werte dd { color: var(--tinte); }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 50; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .7s var(--expo), scale 150ms var(--drueck); }
.vorfuehrung .zeiger { display: block; }
.zeiger.drueckt { scale: .8; }

/* Vorschau: nur die Bühne, kein Menü */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .nav, .vorschau .werte div:nth-child(n+4) { display: none; }

/* Telefon und schmale Fenster */
@media (max-width: 760px) {
  .inhalt { grid-template-columns: 1fr; }
  .nav { display: none; }
  .buehne { width: min(100%, 420px); order: -1; }
  body { --hub: 48px; }
}
/* Touch: kein Hover; Antippen holt die Karte nach vorn, kleinere Ausschläge */
@media (hover: none) { body { --schweb: 1.05; --hub: 48px; } }

/* Reduzierte Bewegung: kein Überschwingen, kein Weg, nur kurzes Ausblenden (200 ms) */
.reduziert body, .reduziert body[data-variante] { --feder: cubic-bezier(0, 0, 0.58, 1); --dauer: 200ms; --ui: 200ms; --versatz: 0ms; --hub: 0px; --schweb: 1; }
.reduziert .buehne.weg .karte { scale: 1; rotate: var(--r); }
.reduziert .buehne.weg .plakette { scale: 1; rotate: -12deg; }
.reduziert .karte.vorn { translate: none; scale: 1; rotate: var(--r); }
.reduziert .karte:hover .blatt, .reduziert .karte.schwebt .blatt { rotate: 0deg; }
.reduziert .knopf, .reduziert .schalter .knauf, .reduziert .varianten button { transition-timing-function: cubic-bezier(0, 0, 0.58, 1); }
.reduziert .knopf:active, .reduziert .knopf.gedrueckt { scale: 1; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="back">
<header class="kopf">
  <a class="logo" href="#" aria-label="Studio Hüpf, Startseite" style="color:inherit;text-decoration:none">hüpf<i aria-hidden="true"></i></a>
  <ul class="nav mono">
    <li><a href="#">Arbeiten</a></li><li><a href="#">Studio</a></li><li><a href="#">Laden</a></li><li><a href="#">Kontakt</a></li>
  </ul>
</header>

<main class="inhalt">
  <div class="text">
    <p class="mono">Gestaltungsstudio · Leipzig-Plagwitz</p>
    <h1>Marken, die <em>mitspielen.</em></h1>
    <p class="lead">Wir entwickeln Erscheinungsbilder, Verpackungen und Websites für Cafés, Läden und kleine Manufakturen. Ernsthaft gearbeitet, fröhlich ausgeliefert.</p>
    <div class="knoepfe">
      <button class="knopf voll" data-knopf type="button">Projekt anfragen <span aria-hidden="true">→</span></button>
      <button class="knopf rand" type="button">Arbeiten ansehen</button>
    </div>
    <div class="tisch">
      <div class="reiter" role="tablist" aria-label="Arbeiten filtern" data-reiter>
        <span class="indikator" data-indikator aria-hidden="true"></span>
        <button role="tab" aria-selected="true" data-karte="0" type="button">Marke</button>
        <button role="tab" aria-selected="false" data-karte="2" type="button">Web</button>
        <button role="tab" aria-selected="false" data-karte="1" type="button">Print</button>
      </div>
      <button class="schalter" role="switch" aria-checked="false" data-schalter type="button"><span class="bahn"><span class="knauf"></span></span>Sticker-Post, monatlich</button>
    </div>
  </div>

  <div class="buehne" data-buehne>
    <button class="karte" type="button" style="--x:3%;--y:5%;--r:-8deg;--i:0;--z0:4" aria-label="Krume nach vorn holen"><span class="blatt"><img src="bilder/marke-krume-hoch.jpg" alt=""><b>Krume Backstube <span class="mono">Marke</span></b></span></button>
    <button class="karte" type="button" style="--x:53%;--y:0%;--r:6deg;--i:1;--z0:3" aria-label="Röstkontor nach vorn holen"><span class="blatt"><img src="bilder/marke-roestkontor-hoch.jpg" alt=""><b>Röstkontor <span class="mono">Print</span></b></span></button>
    <button class="karte" type="button" style="--x:10%;--y:50%;--r:5deg;--i:2;--z0:1" aria-label="Eselsohr nach vorn holen"><span class="blatt"><img src="bilder/marke-eselsohr-hoch.jpg" alt=""><b>Eselsohr <span class="mono">Web</span></b></span></button>
    <button class="karte" type="button" style="--x:52%;--y:46%;--r:-7deg;--i:3;--z0:2" aria-label="Plakat Licht nach vorn holen"><span class="blatt"><img src="bilder/typo-plakat-hoch.jpg" alt=""><b>Licht <span class="mono">Plakat</span></b></span></button>
    <span class="plakette" style="--i:5" aria-hidden="true"><span>Neu im<br>Laden!<small>Sticker</small></span></span>
  </div>
</main>

<footer class="fuss">
  <div class="varianten mono" role="group" aria-label="Variante" data-varianten>
    <button type="button" data-v="back" aria-pressed="true">Überschwinger</button>
    <button type="button" data-v="feder" aria-pressed="false">Feder</button>
    <button type="button" data-v="elastisch" aria-pressed="false">Elastisch</button>
  </div>
  <dl class="werte mono" data-werte></dl>
</footer>

<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#1d1a16" stroke="#f3ede2" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement, body = document.body;
  const reduziert = () => html.classList.contains('reduziert');
  const buehne = document.querySelector('[data-buehne]');
  const karten = [...buehne.querySelectorAll('.karte')];
  const reiter = document.querySelector('[data-reiter]');
  const tabs = [...reiter.querySelectorAll('[role="tab"]')];
  const indikator = document.querySelector('[data-indikator]');
  const schalter = document.querySelector('[data-schalter]');
  const knopf = document.querySelector('[data-knopf]');
  const zeiger = document.querySelector('[data-zeiger]');
  const werteEl = document.querySelector('[data-werte]');
  const variantenKnoepfe = [...document.querySelectorAll('[data-v]')];

  const VARIANTEN = {
    back:      { kurve: 'back.out · cubic-bezier(0.34, 1.56, 0.64, 1)', karten: 500, ui: 400 },
    feder:     { kurve: 'linear()-Feder · Spitze 1,585', karten: 800, ui: 800 },
    elastisch: { kurve: 'elastic.out(1, 0.75) als linear()', karten: 1000, ui: 600 }
  };
  const FEDER = { k: 120, d: 14, m: 1 };   // Ziehen: Steifigkeit, Dämpfung, Masse
  let variante = 'back';

  function zeigeWerte() {
    const v = VARIANTEN[variante], r = reduziert();
    const zeilen = r
      ? [['Kurve', 'ease-out (reduziert)'], ['Dauer', '200 ms'], ['Versatz', '0 ms'], ['Hover', 'Schatten'], ['Drücken', 'aus'], ['Ziehen', 'zurück 200 ms']]
      : [['Kurve', v.kurve], ['Karten', v.karten + ' ms'], ['UI', v.ui + ' ms'], ['Versatz', '50 ms'], ['Hover', 'scale 1,08'], ['Drücken', 'scale 0,95 · 150 ms'], ['Ziehen', 'k 120 · d 14']];
    werteEl.innerHTML = zeilen.map(([a, b]) => `<div><dt>${a}</dt><dd>${b}</dd></div>`).join('');
  }
  function setzeVariante(name) {
    variante = name;
    body.dataset.variante = name;
    variantenKnoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.v === name));
    zeigeWerte();
  }

  // Karten landen: Ausgangszustand ohne Übergang, dann gestaffelt mit der Federkurve
  let landeUhr = null, zOben = 10;
  function landen() {
    clearTimeout(landeUhr);
    karten.forEach(k => { k.classList.remove('vorn', 'schwebt'); k.style.removeProperty('--z'); });
    buehne.classList.remove('kommt');
    buehne.classList.add('weg');
    void buehne.offsetWidth;
    buehne.classList.add('kommt');
    buehne.classList.remove('weg');
    const v = reduziert() ? 200 : VARIANTEN[variante].karten + 6 * 50;
    landeUhr = setTimeout(() => buehne.classList.remove('kommt'), v + 50);
  }

  function nachVorn(i) {
    karten.forEach((k, j) => k.classList.toggle('vorn', j === i));
    if (i >= 0) karten[i].style.setProperty('--z', ++zOben);
  }

  // Reiter mit gleitendem Indikator
  function waehleTab(t) {
    tabs.forEach(b => b.setAttribute('aria-selected', b === t));
    indikator.style.width = t.offsetWidth + 'px';
    indikator.style.translate = t.offsetLeft + 'px 0';
    nachVorn(+t.dataset.karte);
  }
  tabs.forEach(t => t.addEventListener('click', () => waehleTab(t)));
  schalter.addEventListener('click', () => schalter.setAttribute('aria-checked', schalter.getAttribute('aria-checked') !== 'true'));
  variantenKnoepfe.forEach(b => b.addEventListener('click', () => { setzeVariante(b.dataset.v); landen(); }));

  // Ziehen: Karte folgt dem Zeiger und federt beim Loslassen zurück (a = −k·x − d·v)
  const zustand = karten.map(() => ({ x: 0, y: 0, vx: 0, vy: 0, zieht: false }));
  let laeuft = false, letzte = 0;
  function malen(k, s) {
    const kipp = Math.max(-12, Math.min(12, s.vx * 0.012));
    k.style.transform = (s.x || s.y) ? `translate(${s.x}px, ${s.y}px) rotate(${kipp}deg)` : '';
  }
  function schritt(t) {
    let dt = Math.min(0.064, (t - letzte) / 1000); letzte = t;
    let aktiv = false;
    karten.forEach((k, i) => {
      const s = zustand[i];
      if (s.zieht) { aktiv = true; malen(k, s); return; }
      if (!s.x && !s.y && !s.vx && !s.vy) return;
      let rest = dt;
      while (rest > 0) {                      // Teilschritte von höchstens 1/120 s
        const h = Math.min(rest, 1 / 120); rest -= h;
        const ax = (-FEDER.k * s.x - FEDER.d * s.vx) / FEDER.m, ay = (-FEDER.k * s.y - FEDER.d * s.vy) / FEDER.m;
        s.vx += ax * h; s.vy += ay * h; s.x += s.vx * h; s.y += s.vy * h;
      }
      if (Math.abs(s.x) < .3 && Math.abs(s.y) < .3 && Math.abs(s.vx) < 3 && Math.abs(s.vy) < 3) { s.x = s.y = s.vx = s.vy = 0; }
      else aktiv = true;
      malen(k, s);
    });
    if (aktiv) requestAnimationFrame(schritt); else laeuft = false;
  }
  function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(schritt); } }
  function loslassen(i) {
    const s = zustand[i], k = karten[i];
    s.zieht = false; k.classList.remove('zieht');
    if (reduziert()) {                        // ohne Feder: kurz zurückgleiten
      s.x = s.y = s.vx = s.vy = 0;
      k.style.transition = 'transform 200ms cubic-bezier(0, 0, 0.58, 1)'; k.style.transform = '';
      setTimeout(() => k.style.removeProperty('transition'), 220);
    } else starten();
  }

  karten.forEach((k, i) => {
    let start = null, weg = 0, letzt = null;
    k.addEventListener('pointerdown', e => {
      if (e.button) return;
      start = { px: e.clientX, py: e.clientY, x: zustand[i].x, y: zustand[i].y }; weg = 0; letzt = { x: e.clientX, t: e.timeStamp };
      k.setPointerCapture(e.pointerId);
    });
    k.addEventListener('pointermove', e => {
      if (!start) return;
      const s = zustand[i], dx = e.clientX - start.px, dy = e.clientY - start.py;
      weg = Math.max(weg, Math.hypot(dx, dy));
      if (weg < 6) return;
      if (!s.zieht) { s.zieht = true; k.classList.add('zieht'); k.style.setProperty('--z', ++zOben); starten(); }
      const dtp = Math.max(1, e.timeStamp - letzt.t);
      s.vx = (e.clientX - letzt.x) / dtp * 1000; s.vy = 0; letzt = { x: e.clientX, t: e.timeStamp };
      s.x = start.x + dx; s.y = start.y + dy;
      if (reduziert()) malen(k, { ...s, vx: 0 });
    });
    const ende = () => {
      if (!start) return;
      start = null;
      if (zustand[i].zieht) { zustand[i].vy = 0; loslassen(i); }
    };
    k.addEventListener('pointerup', ende);
    k.addEventListener('pointercancel', ende);
    k.addEventListener('click', e => { if (weg >= 6) { e.preventDefault(); return; } nachVorn(i); });
  });

  // Vorführung: gedachter Zeiger spielt Landen, Hover, Reiter, Schalter, Drücken und Ziehen durch
  let uhren = [], runde = 0;
  const spaeter = (ms, f) => uhren.push(setTimeout(f, ms));
  function zeigerZu(el, fx = .5, fy = .5) {
    const r = el.getBoundingClientRect();
    zeiger.style.transform = `translate(${r.left + r.width * fx - 2}px, ${r.top + r.height * fy - 1}px)`;
  }
  function drueck(el, ms = 160) {
    zeiger.classList.add('drueckt'); el && el.classList.add('gedrueckt');
    spaeter(ms, () => { zeiger.classList.remove('drueckt'); el && el.classList.remove('gedrueckt'); });
  }
  function ziehVor(i, zx, zy, ms) {
    const s = zustand[i], k = karten[i], t0 = performance.now();
    s.zieht = true; k.classList.add('zieht'); k.style.setProperty('--z', ++zOben); starten();
    const r = k.getBoundingClientRect();
    const lauf = t => {
      const p = Math.min(1, (t - t0) / ms), e = 1 - Math.pow(1 - p, 2);   // power2.out
      const nx = zx * e, ny = zy * e;
      s.vx = (nx - s.x) * 60; s.x = nx; s.y = ny;
      zeiger.style.transition = 'none';
      zeiger.style.transform = `translate(${r.left + r.width * .5 + nx - 2}px, ${r.top + r.height * .45 + ny - 1}px)`;
      if (p < 1) uhren.push(requestAnimationFrame(lauf));
      else { zeiger.style.removeProperty('transition'); s.vx = 0; }
    };
    requestAnimationFrame(lauf);
  }
  function rundeSpielen() {
    const namen = Object.keys(VARIANTEN);
    setzeVariante(namen[runde % namen.length]); runde++;
    waehleTab(tabs[0]); nachVorn(-1);
    schalter.setAttribute('aria-checked', 'false');
    zeigerZu(document.querySelector(`[data-v="${variante}"]`));
    drueck(null);
    landen();
    const T = reduziert() ? 600 : VARIANTEN[variante].karten + 450;
    spaeter(T,        () => { zeigerZu(karten[1], .55, .4); karten[1].classList.add('schwebt'); });
    spaeter(T + 1000, () => { karten[1].classList.remove('schwebt'); zeigerZu(tabs[1]); });
    spaeter(T + 1600, () => { drueck(null); waehleTab(tabs[1]); });
    spaeter(T + 2500, () => zeigerZu(schalter, .12));
    spaeter(T + 3100, () => { drueck(null); schalter.setAttribute('aria-checked', 'true'); });
    spaeter(T + 3700, () => zeigerZu(knopf, .4));
    spaeter(T + 4300, () => drueck(knopf, 180));
    spaeter(T + 4900, () => zeigerZu(karten[3], .5, .45));
    spaeter(T + 5500, () => { drueck(null, 60); ziehVor(3, -150, -60, 420); });
    spaeter(T + 6050, () => { loslassen(3); zeigerZu(karten[3], .9, 1.1); });
    spaeter(T + 7300, rundeSpielen);
  }
  function vorfuehren() { pause(); html.classList.add('vorfuehrung'); rundeSpielen(); }
  function pause() { uhren.forEach(u => { clearTimeout(u); cancelAnimationFrame(u); }); uhren = []; }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung');
    karten.forEach((k, i) => { Object.assign(zustand[i], { x: 0, y: 0, vx: 0, vy: 0, zieht: false }); k.classList.remove('zieht', 'gedrueckt'); malen(k, zustand[i]); });
    schalter.setAttribute('aria-checked', 'false');
    waehleTab(tabs[0]); nachVorn(-1);
    zeigeWerte();
    landen();
  }

  addEventListener('resize', () => { const t = tabs.find(b => b.getAttribute('aria-selected') === 'true'); indikator.style.transition = 'none'; waehleTab(t); nachVorn(-1); void indikator.offsetWidth; indikator.style.removeProperty('transition'); });
  document.fonts && document.fonts.ready.then(() => { indikator.style.transition = 'none'; waehleTab(tabs.find(b => b.getAttribute('aria-selected') === 'true')); nachVorn(-1); void indikator.offsetWidth; indikator.style.removeProperty('transition'); });
  new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });
  neustart();

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