Zum Inhalt
← Hover

3D-Kippen von Karten (Tilt)

Eine Geschenkkarte kippt zum Zeiger, mit Parallaxe-Ebenen, wanderndem Kupferglanz und drei gemessenen Varianten (Zentry 3°, Attio 18°, vanilla-tilt 15°).

Eine Karte kippt in 3D zum Zeiger. Die Zeigerposition relativ zur Kartenmitte wird in rotateX und rotateY übersetzt, mit perspective von 500 bis 1500 px. Innere Ebenen liegen per translateZ auf verschiedenen Höhen und verschieben sich dadurch gegeneinander (Parallaxe). Ein Glanzverlauf folgt der Neigung. Beim Verlassen kehrt die Karte weich in die Ruhelage zurück.

Die Karte bekommt Gewicht und Material. Sie wirkt wie ein Gegenstand, den man in der Hand dreht. Mit Glanz oder Prägung entsteht ein Sammelkarten- und Wertpapiergefühl.

Wann einsetzen

Für einzelne Hero-Objekte: Geschenk- oder Kreditkarte, Ticket, Album-Cover, Badge, Mitgliedskarte. Außerdem für Sammel- und Spieloberflächen.

Wann nicht

Nicht auf Rastern gleichförmiger Feature- oder Produktkarten. Dort wirkt der Effekt 2026 nach Template und lenkt von der Ware ab. Nicht auf Karten mit viel Lesetext, denn gedrehter Text wird unscharf.

Varianten

  • Dezent: 3° bei perspective 500 px, gedämpft, leichtes Verkleinern auf 0,95 (Zentry)
  • Deutlich: 18° bei perspective 800 px mit Federphysik und scale 1,02; die Kante unter dem Zeiger sinkt ein (Attio)
  • Klassisch mit Glanz (vanilla-tilt): Kante hebt sich zum Zeiger, Transition mit cubic-bezier(.03,.98,.52,.99); umgekehrte Richtung per data-tilt-reverse
  • Tilt mit Pixel-Glitch-Ebenen (Codrops GlitchPerspective)
  • Holo- oder Folien-Effekt: Farbverlauf mit mix-blend-mode color-dodge folgt der Neigung (eigener Eintrag holo-irisierende-folie)
  • Geräteneigung per DeviceOrientation auf Mobilgeräten

Umsetzung

pointermove auf einem stabilen Wrapper: px = x / breite − 0,5, py = y / höhe − 0,5. Daraus rotateX = −py · 2 · max und rotateY = px · 2 · max. Die Karte bekommt transform: perspective(800px) rotateX() rotateY() scale() und transform-style: preserve-3d; die Ebenen darin translateZ (zum Beispiel 18, 30, 44 px). Glättung entweder per CSS-Transition (vanilla-tilt: cubic-bezier(.03,.98,.52,.99), beim Bewegen kurz, beim Verlassen lang), per Feder (Attio: stiffness 80, damping 20, mass 0,1) oder per Dämpfung (Zentry: λ 4). Glanz als Ebene mit radialem Verlauf am Zeigerpunkt, Deckkraft = Abstand zur Mitte × Maximum. Fallstricke: overflow: hidden auf der Karte selbst macht die 3D-Ebenen flach, deshalb nur auf der Grundebene. Kleine Winkel halten Text lesbar. Die Hitbox nicht mitrotieren lassen.

Robust umsetzen

Telefon. Meist ohne Kippen. Ersatz: ein Glanzstreifen beim Tippen. DeviceOrientation ist möglich, braucht auf iOS aber eine Erlaubnisabfrage.

Ohne Bewegung. Keine Rotation und kein Skalieren. Der Glanz bleibt statisch, beim Darüberfahren wird nur der Schatten tiefer.

Barrierefrei. Der Effekt ist rein dekorativ. Inhalt und Bedienung dürfen nicht vom Kippen abhängen. Den Fokusring auf den Wrapper legen, damit er nicht mitrotiert. Die Karte als Bild mit Alternativtext auszeichnen, wenn sie Informationen trägt (zum Beispiel den Wert).

Code

<!DOCTYPE html>
<!--
  3D-Kippen von Karten (Tilt)
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/kippen-3d-karten/
  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>3D-Kippen von Karten (Tilt) · 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: 'Zalando Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/zalando-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }

:root {
  --grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --meta: #69645c;
  --karte-hell: #5a2620; --karte-dunkel: #2a1210;
  --folie: linear-gradient(105deg, #6e3f26 0%, #b9815a 22%, #f6dcbc 40%, #fff3e2 48%, #c8916a 62%, #7c4a2e 80%, #d9ab83 100%);
  --ui: cubic-bezier(0.25, 1, 0.5, 1);       /* Rezept: Bild und UI, ohne Überschwingen */
  --tilt: cubic-bezier(0.03, 0.98, 0.52, 0.99); /* vanilla-tilt-Kurve (Variante Klassisch) */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.mono { font: 400 11px/1.35 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--meta); }
.ziffern { font-variant-numeric: tabular-nums; }

/* Kopfzeile: Wortmarke klein, Navigation knapp */
.kopfzeile { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px clamp(16px, 3.4vw, 48px); border-bottom: 1px solid var(--linie); }
.marke { font-size: 15px; font-weight: 400; font-stretch: 125%; letter-spacing: .32em; text-decoration: none; color: inherit; }
.marke small { font: inherit; letter-spacing: .06em; font-stretch: 100%; color: var(--meta); margin-left: .9em; font-size: 12px; }
.nav { display: flex; gap: 28px; list-style: none; padding: 0; }
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a[aria-current] { text-decoration: underline; text-underline-offset: 5px; text-decoration-thickness: 1px; }

main { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); gap: clamp(24px, 4vw, 64px); padding: clamp(16px, 2.4vw, 32px) clamp(16px, 3.4vw, 48px) 48px; align-items: start; }

/* Bühne = stabile Hitbox. Die Karte reagiert auf den Zeiger irgendwo auf der Fläche. */
.buehne { position: relative; background: var(--flaeche); height: min(76svh, 680px); min-height: 460px; display: grid; place-items: center; overflow: hidden; border-radius: 2px; touch-action: manipulation; }
.ecke { position: absolute; left: 20px; top: 18px; }
.ecke-u { position: absolute; left: 20px; bottom: 16px; }
.rahmen { position: relative; width: min(460px, 70%); aspect-ratio: 1.586; container-type: inline-size; translate: 0 -14px; }
.schatten { position: absolute; inset: 8% 6% -4%; border-radius: 18px; background: rgba(42, 18, 16, .55); filter: blur(28px); opacity: .55; transform: translate3d(0, 22px, 0) scale(.92); pointer-events: none; }

.karte { position: absolute; inset: 0; transform-style: preserve-3d; will-change: transform; }
.ebene { position: absolute; pointer-events: none; }
.grund { inset: 0; border-radius: 4.2cqw; overflow: hidden;
  background:
    radial-gradient(120% 90% at 20% 0%, var(--karte-hell) 0%, transparent 62%),
    linear-gradient(160deg, #431b16 0%, var(--karte-dunkel) 100%);
  box-shadow: inset 0 0 0 1px rgba(255, 228, 200, .08), inset 0 1px 0 rgba(255, 228, 200, .14);
}
/* Papierstruktur: Rauschen als Inline-SVG */
.grund::before { content: ""; position: absolute; inset: 0; opacity: .22; mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .9 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E"); }
/* geprägte Innenlinie */
.grund::after { content: ""; position: absolute; inset: 3.4cqw; border-radius: 2.2cqw; border: 1px solid rgba(232, 190, 150, .32); }
/* Glanz: Lichtfleck folgt dem Zeiger, Deckkraft = Abstand zur Mitte × Maximum */
.glanz { position: absolute; inset: 0; opacity: var(--go, 0); mix-blend-mode: screen;
  background: radial-gradient(70% 90% at var(--gx, 50%) var(--gy, 50%), rgba(255, 226, 196, .75) 0%, rgba(255, 210, 170, .18) 38%, rgba(255, 210, 170, 0) 70%); }

.folie { background: var(--folie); background-size: 260% 100%; background-position: calc(50% + var(--fx, 0) * 42%) 50%; -webkit-background-clip: text; background-clip: text; color: transparent; }
.kopf { left: 7.6cqw; top: 7.4cqw; right: 7.6cqw; display: flex; justify-content: space-between; align-items: flex-start; transform: translateZ(30px); }
.kopf b { display: block; font-weight: 300; font-stretch: 125%; font-size: 5.6cqw; letter-spacing: .34em; line-height: 1; }
.kopf .klein { font: 400 max(7px, 2.1cqw)/1.5 'Fragment Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; color: rgba(240, 206, 172, .62); }
.kopf .klein.r { text-align: right; }
.zweig { right: 5.5cqw; bottom: 10cqw; width: 40cqw; transform: translateZ(18px); overflow: visible; }
.wert { left: 7.6cqw; bottom: 7cqw; transform: translateZ(44px); }
.wert strong { display: block; font-weight: 300; font-stretch: 125%; font-size: 13.4cqw; line-height: .9; letter-spacing: -.01em; transition: opacity .2s var(--ui); }
.wert strong.wechsel { opacity: 0; }
.wert .eur { font-size: .4em; letter-spacing: 0; margin-left: .18em; vertical-align: .95em; }
.wert .klein { display: block; margin-top: 2.4cqw; font: 400 max(7px, 2.1cqw)/1.4 'Fragment Mono', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; color: rgba(240, 206, 172, .62); }

/* Variante Klassisch: Transform per CSS-Transition (wie vanilla-tilt) */
.buehne.v-klassisch .karte { transition: transform 3000ms var(--tilt); }
.buehne.v-klassisch .schatten { transition: transform 3000ms var(--tilt), opacity 3000ms var(--tilt); }

/* Steuerung unten auf der Bühne */
/* Ecken: oben links Edition, oben rechts Varianten, unten links Hinweis, unten rechts Werte */
.steuer { display: contents; }
.varianten { position: absolute; right: 11px; top: 10px; display: flex; gap: 2px; align-items: center; padding-right: 22px; }
.varianten .mono:first-child { margin-right: 10px; }
.varianten button { white-space: nowrap; font: 400 11px/1 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--meta); padding: 7px 9px; border-radius: 2px; transition: color .2s var(--ui), background-color .2s var(--ui); }
.varianten button:hover { color: var(--text); }
.varianten button[aria-pressed="true"] { color: var(--text); background: rgba(18, 16, 16, .07); }
.varianten button:focus-visible, .werte button:focus-visible, .cta:focus-visible { outline: 1px solid var(--text); outline-offset: 2px; }
.werte-anzeige { position: absolute; right: 12px; bottom: 16px; text-align: right; padding: 6px 22px 0 0; }
.werte-anzeige .klein-gr { display: inline; text-transform: none; }
.werte-anzeige span { display: block; white-space: nowrap; }
.werte-anzeige .live { color: var(--text); }
.hinweis-touch { display: none; }

/* Kaufbox */
.kauf { padding-top: clamp(4px, 2vw, 28px); max-width: 520px; }
.pfad { margin-bottom: 22px; }
h1 { font-weight: 400; font-stretch: 125%; font-size: clamp(2rem, 3.5vw, 3.3rem); letter-spacing: -.01em; line-height: 1.05; }
.formel { margin-top: 14px; font-size: 12px; color: var(--text); }
.formel span { color: var(--meta); white-space: nowrap; }
.formel i { font-style: normal; }
.text { margin-top: 22px; font-size: 15px; line-height: 1.55; max-width: 44ch; text-wrap: pretty; }
.wahl { margin-top: 28px; }
.werte { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 10px; }
.werte button { border: 1px solid var(--linie); padding: 13px 0; font-size: 15px; font-weight: 500; font-variant-numeric: tabular-nums; border-radius: 2px; transition: border-color .2s var(--ui), background-color .2s var(--ui); }
.werte button:hover { border-color: var(--text); }
.werte button[aria-checked="true"] { border-color: var(--text); background: var(--text); color: var(--grund); }
.cta { display: flex; justify-content: space-between; width: 100%; margin-top: 12px; padding: 17px 20px; background: var(--text); color: var(--grund); font-size: 15px; font-weight: 500; border-radius: 2px; transition: background-color .2s var(--ui); }
.cta:hover { background: #3a2a26; }
.daten { list-style: none; padding: 0; margin-top: 30px; border-top: 1px solid var(--linie); }
.daten li { display: grid; grid-template-columns: 9.5em 1fr; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--linie); font-size: 14px; }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: absolute; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .kopfzeile, .vorschau .kauf { display: none; }
.vorschau main { display: block; padding: 0; }
.vorschau .buehne { height: 100svh; min-height: 0; border-radius: 0; }
.vorschau .rahmen { width: min(560px, 62vw); translate: 0 -10px; }

/* Touch: kein Kippen. Tippen lässt den Glanz einmal über die Karte laufen. */
@keyframes glanzlauf { from { background-position: 160% 0; opacity: 1; } to { background-position: -60% 0; opacity: 1; } }
.lauf { position: absolute; inset: 0; opacity: 0; mix-blend-mode: screen; background: linear-gradient(105deg, transparent 35%, rgba(255, 226, 196, .55) 50%, transparent 65%); background-size: 250% 100%; }
.lauf.an { animation: glanzlauf 1000ms var(--ui) both; }
@media (hover: none) {
  .steuer { display: none; }
  .hinweis-touch { display: block; position: absolute; left: 20px; right: 20px; bottom: 16px; text-align: center; }
  .ecke-u { display: none; }
}

/* Reduzierte Bewegung: keine Rotation, Glanz steht still, beim Darüberfahren wird nur der Schatten tiefer */
.reduziert .karte, .reduziert .schatten { transition: none !important; }
.reduziert .glanz { opacity: .5 !important; background: linear-gradient(125deg, rgba(255, 226, 196, .22) 0%, rgba(255, 226, 196, 0) 38%) !important; }
.reduziert .schatten { transition: opacity .15s linear !important; }
.reduziert .buehne.an .schatten { opacity: .85; }
.reduziert .lauf.an { animation: none; }
.reduziert .wert strong { transition: none; }

@media (min-width: 901px) and (max-width: 1180px) { :root:not(.vorschau) .ecke { display: none; } }
@media (max-width: 900px) {
  main { grid-template-columns: 1fr; }
  .nav { display: none; }
  .kauf { max-width: none; }
}
@media (max-width: 560px) {
  .kopfzeile { padding-block: 14px; }
  .marke small { display: none; }
  .buehne { height: 380px; min-height: 0; }
  .formel span { display: block; margin-top: 4px; }
  .formel i { display: none; }
  .rahmen { width: 84%; translate: 0 -8px; }
  .daten li { grid-template-columns: 8em 1fr; }
}
@media (forced-colors: active) {
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
  .folie, .folie * { background: none; color: CanvasText; -webkit-text-fill-color: CanvasText; }
  [role=radio][aria-checked=true] { outline: 2px solid Highlight; outline-offset: 2px; }
}
</style>
</head>
<body>
<header class="kopfzeile">
  <a class="marke" href="#">LEUTRA<small>Hautpflege aus Jena</small></a>
  <ul class="nav">
    <li><a href="#">Pflege</a></li><li><a href="#">Rituale</a></li><li><a href="#" aria-current="page">Geschenke</a></li><li><a href="#">Atelier</a></li>
  </ul>
  <span class="mono ziffern">Tasche (0)</span>
</header>

<main>
  <section class="buehne v-deutlich" data-zone aria-label="Geschenkkarte, Ansicht">
    <p class="ecke mono">Edition Hagebutte</p>
    <div class="rahmen">
      <div class="schatten" data-schatten></div>
      <div class="karte" data-karte role="img" aria-label="Geschenkkarte von Leutra im Wert von 75 Euro, oxblutrot mit Kupferprägung und einem Hagebuttenzweig">
        <div class="ebene grund">
          <div class="glanz" data-glanz></div>
          <div class="lauf" data-lauf></div>
        </div>
        <svg class="ebene zweig" viewBox="0 0 200 130" aria-hidden="true">
          <defs>
            <linearGradient id="kupfer" x1="0" y1="0" x2="1" y2="1">
              <stop offset="0" stop-color="#8a5634"/><stop offset=".45" stop-color="#f3d2ae"/><stop offset=".6" stop-color="#c38b62"/><stop offset="1" stop-color="#7c4a2e"/>
            </linearGradient>
          </defs>
          <g fill="none" stroke="url(#kupfer)" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">
            <path d="M6 124 C46 110 82 84 112 56 S168 14 196 6"/>
            <path d="M60 99 C66 88 70 80 72 68"/>
            <path d="M112 56 C124 58 134 66 140 78"/>
            <path d="M150 30 C150 22 154 14 160 9"/>
            <!-- Fiederblätter -->
            <path d="M72 68 c-6 -4 -12 -3 -16 1 c5 3 11 3 16 -1Z"/>
            <path d="M72 68 c2 -7 7 -11 13 -11 c-2 6 -7 10 -13 11Z"/>
            <path d="M68 80 c-6 -2 -11 0 -14 5 c6 2 11 0 14 -5Z"/>
            <path d="M69 79 c4 -5 9 -7 14 -5 c-4 5 -9 6 -14 5Z"/>
            <path d="M160 9 c-7 -1 -12 2 -14 7 c7 1 12 -2 14 -7Z"/>
            <path d="M160 9 c5 -5 11 -6 16 -3 c-5 5 -11 5 -16 3Z"/>
            <path d="M152 24 c-6 0 -10 3 -12 8 c6 0 10 -3 12 -8Z"/>
            <path d="M152 24 c5 -4 10 -5 15 -2 c-5 4 -10 4 -15 2Z"/>
            <path d="M30 116 c-2 -7 1 -13 7 -16 c1 7 -2 13 -7 16Z"/>
            <path d="M92 76 c0 -7 4 -12 10 -14 c0 7 -4 12 -10 14Z"/>
            <!-- Hagebutten -->
            <ellipse cx="143" cy="92" rx="8.5" ry="12" transform="rotate(-18 143 92)"/>
            <path d="M139 103 l-4 7 M143 104 l0 8 M147 103 l4 6"/>
            <ellipse cx="121" cy="84" rx="6.5" ry="9.5" transform="rotate(12 121 84)"/>
            <path d="M119 93 l-3 6 M122 94 l1 6 M125 92 l3 5"/>
            <path d="M121 75 C119 68 116 62 112 56"/>
          </g>
          <g fill="url(#kupfer)"><circle cx="186" cy="34" r="1.2"/><circle cx="178" cy="44" r=".9"/><circle cx="44" cy="96" r=".9"/></g>
        </svg>
        <div class="ebene kopf">
          <div><b class="folie">LEUTRA</b><span class="klein">Hautpflege · Jena</span></div>
          <span class="klein r ziffern">Geschenkkarte<br>№ 0412</span>
        </div>
        <div class="ebene wert">
          <strong class="folie ziffern" data-wert>75<span class="eur">€</span></strong>
          <span class="klein ziffern">Einlösbar bis 11 · 2029</span>
        </div>
      </div>
    </div>

    <div class="steuer">
      <div class="varianten" role="group" aria-label="Variante">
        <span class="mono">Variante</span>
        <button type="button" data-variante="dezent" aria-pressed="false">Dezent 3°</button>
        <button type="button" data-variante="deutlich" aria-pressed="true">Deutlich 18°</button>
        <button type="button" data-variante="klassisch" aria-pressed="false">Klassisch 15°</button>
      </div>
      <p class="ecke-u mono">Zeiger über die Fläche</p>
      <p class="werte-anzeige mono ziffern"><span data-werte></span><span class="live" data-live>rX 0,0° · rY 0,0°</span></p>
    </div>
    <p class="hinweis-touch mono">Tippen für den Glanz</p>
    <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="#121010" stroke="#f6f3ee" stroke-width="1.2" stroke-linejoin="round"/></svg>
  </section>

  <section class="kauf" aria-labelledby="titel">
    <p class="pfad mono">Geschenke / Geschenkkarte</p>
    <h1 id="titel">Geschenkkarte</h1>
    <p class="formel mono ziffern">Baumwollpapier 600 g + Kupferprägung <span><i>· </i>ab 50,00 €</span></p>
    <p class="text">Einlösbar für jede Pflege aus unserem Labor an der Saale und für Behandlungen im Atelier in der Jenaer Innenstadt. Sie kommt im Leinenkuvert, auf Wunsch mit einer handgeschriebenen Zeile von uns.</p>
    <div class="wahl">
      <p class="mono">Wert</p>
      <div class="werte" role="radiogroup" aria-label="Wert der Geschenkkarte">
        <button type="button" role="radio" aria-checked="false" data-betrag="50">50 €</button>
        <button type="button" role="radio" aria-checked="true" data-betrag="75">75 €</button>
        <button type="button" role="radio" aria-checked="false" data-betrag="120">120 €</button>
        <button type="button" role="radio" aria-checked="false" data-betrag="200">200 €</button>
      </div>
      <button type="button" class="cta"><span>In die Tasche</span><span class="ziffern" data-preis>75,00 €</span></button>
    </div>
    <ul class="daten">
      <li><span class="mono">Gültigkeit</span><span>3 Jahre ab Kauf</span></li>
      <li><span class="mono">Versand</span><span>1–2 Werktage, kostenfrei</span></li>
      <li><span class="mono">Abholung</span><span>Atelier Jena, Dienstag bis Samstag</span></li>
    </ul>
  </section>
</main>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const ohneHover = matchMedia('(hover: none)');
  const zone = document.querySelector('[data-zone]');
  const karte = document.querySelector('[data-karte]');
  const schatten = document.querySelector('[data-schatten]');
  const glanz = document.querySelector('[data-glanz]');
  const lauf = document.querySelector('[data-lauf]');
  const zeiger = document.querySelector('[data-zeiger]');
  const anzeigeWerte = document.querySelector('[data-werte]');
  const anzeigeLive = document.querySelector('[data-live]');

  // Drei gemessene Varianten (meta.json → messwerte, gesehen_bei)
  const VARIANTEN = {
    // Zentry: perspective 500 px, max 3°, Dämpfung λ 4, scale .95 über 1 s power4.out, Rückkehr zur Mitte nach 0,5 s
    dezent:    { max: 3,  persp: 500,  modus: 'daempfung', lambda: 4, scale: 0.95, scaleMs: 1000, rueckMs: 500, richtung: 1, glanz: 0.5,
                 text: ['perspective 500 px · max 3° · Dämpfung <span class="klein-gr">λ</span> 4', 'scale 0,95 · 1000 ms power4.out · Mitte nach 500 ms'] },
    // Attio: perspective 800 px, max 18°, Feder stiffness 80 / damping 20 / mass 0,1, scale 1,02; Kante unter dem Zeiger sinkt ein
    deutlich:  { max: 18, persp: 800,  modus: 'feder', k: 80, d: 20, m: 0.1, scale: 1.02, richtung: 1, glanz: 0.5,
                 text: ['perspective 800 px · max 18° · scale 1,02', 'Feder stiffness 80 · damping 20 · mass 0,1'] },
    // vanilla-tilt mit Codrops-Werten: perspective 1500 px, max 15°, Transition 3000 ms cubic-bezier(.03,.98,.52,.99), scale .95; Kante hebt sich zum Zeiger
    klassisch: { max: 15, persp: 1500, modus: 'css', ms: 3000, scale: 0.95, richtung: -1, glanz: 0.5,
                 text: ['perspective 1500 px · max 15° · scale 0,95', 'transition 3000 ms cubic-bezier(.03,.98,.52,.99)'] }
  };
  let v = VARIANTEN.deutlich, name = 'deutlich';

  const ist = { rx: 0, ry: 0, s: 1, vrx: 0, vry: 0, vs: 0 };
  const ziel = { rx: 0, ry: 0, s: 1, px: 0, py: 0 };
  let drin = false, verlassenUm = 0, scaleTween = null, laeuft = false, letzte = 0;
  const zahl = n => (Math.abs(n) < 0.05 ? 0 : n).toFixed(1).replace('.', ',').replace(/^-/, '−');

  function variante(n) {
    name = n; v = VARIANTEN[n];
    zone.classList.remove('v-dezent', 'v-deutlich', 'v-klassisch');
    zone.classList.add('v-' + n);
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === n)));
    anzeigeWerte.innerHTML = v.text.map(z => '<span>' + z + '</span>').join('');
    Object.assign(ist, { rx: 0, ry: 0, s: 1, vrx: 0, vry: 0, vs: 0 });
    Object.assign(ziel, { rx: 0, ry: 0, s: 1 });
    drin = false; scaleTween = null;
    malen(); glanzSetzen(0, 0, 0);
  }

  // Zeigerposition (Bildschirm) → px/py in −0,5 … 0,5 relativ zur Bühne
  function zielSetzen(x, y) {
    const r = zone.getBoundingClientRect();
    const px = Math.max(-0.5, Math.min(0.5, (x - r.left) / r.width - 0.5));
    const py = Math.max(-0.5, Math.min(0.5, (y - r.top) / r.height - 0.5));
    if (!drin) betreten();
    ziel.px = px; ziel.py = py;
    if (reduziert()) return;
    ziel.rx = -py * 2 * v.max * v.richtung;
    ziel.ry = px * 2 * v.max * v.richtung;
    glanzSetzen(px, py, Math.min(1, Math.hypot(px, py) * 2) * v.glanz);
    starten();
  }
  function betreten() {
    drin = true; zone.classList.add('an');
    if (reduziert()) return;
    if (v.modus === 'daempfung') scaleTween = { von: ist.s, nach: v.scale, t0: performance.now(), ms: v.scaleMs };
    else ziel.s = v.scale;
    starten();
  }
  function verlassen() {
    if (!drin) return;
    drin = false; zone.classList.remove('an');
    if (reduziert()) return;
    verlassenUm = performance.now();
    if (v.modus === 'daempfung') scaleTween = { von: ist.s, nach: 1, t0: performance.now(), ms: v.scaleMs };
    else ziel.s = 1;
    if (v.modus !== 'daempfung') { ziel.rx = 0; ziel.ry = 0; }
    glanzSetzen(ziel.px, ziel.py, 0);
    starten();
  }
  function glanzSetzen(px, py, o) {
    glanz.style.setProperty('--gx', (50 + px * 110) + '%');
    glanz.style.setProperty('--gy', (50 + py * 130) + '%');
    glanz.style.setProperty('--go', o.toFixed(3));
    glanz.style.transition = o === 0 ? 'opacity .6s cubic-bezier(0.25, 1, 0.5, 1)' : 'none';
  }

  function malen() {
    if (reduziert()) { karte.style.transform = 'none'; schatten.style.transform = ''; anzeigeLive.textContent = 'Reduzierte Bewegung · keine Rotation'; return; }
    karte.style.transform = `perspective(${v.persp}px) rotateX(${ist.rx.toFixed(3)}deg) rotateY(${ist.ry.toFixed(3)}deg) scale(${ist.s.toFixed(4)})`;
    schatten.style.transform = `translate3d(${(-ist.ry * 1.4).toFixed(2)}px, ${(22 + ist.rx * 1.2).toFixed(2)}px, 0) scale(${(0.92 * (2 - ist.s)).toFixed(4)})`;
    karte.style.setProperty('--fx', (ist.ry / Math.max(v.max, 1)).toFixed(3));
    anzeigeLive.textContent = `rX ${zahl(ist.rx)}° · rY ${zahl(ist.ry)}° · scale ${ist.s.toFixed(3).replace('.', ',')}`;
  }

  function bild(t) {
    const dt = Math.min(0.064, (t - letzte) / 1000); letzte = t;
    if (v.modus === 'daempfung') {
      if (!drin && t - verlassenUm > v.rueckMs) { ziel.rx = 0; ziel.ry = 0; }
      const k = 1 - Math.exp(-v.lambda * dt);
      ist.rx += (ziel.rx - ist.rx) * k; ist.ry += (ziel.ry - ist.ry) * k;
      if (scaleTween) {
        const p = Math.min(1, (t - scaleTween.t0) / scaleTween.ms), e = 1 - Math.pow(1 - p, 4);  // power4.out
        ist.s = scaleTween.von + (scaleTween.nach - scaleTween.von) * e;
        if (p >= 1) scaleTween = null;
      }
    } else if (v.modus === 'feder') {
      // Feder wie Motion: a = (−k·(x − ziel) − d·v) / m, in 1-ms-Schritten
      const n = Math.max(1, Math.round(dt * 1000)), h = dt / n;
      for (let i = 0; i < n; i++) for (const [x, vx, z] of [['rx', 'vrx', 'rx'], ['ry', 'vry', 'ry'], ['s', 'vs', 's']]) {
        const a = (-v.k * (ist[x] - ziel[z]) - v.d * ist[vx]) / v.m;
        ist[vx] += a * h; ist[x] += ist[vx] * h;
      }
    } else {
      // CSS übernimmt die Bewegung (Transition auf .karte), JS setzt nur das Ziel
      ist.rx = ziel.rx; ist.ry = ziel.ry; ist.s = ziel.s;
    }
    malen();
    const ruhig = !drin && !scaleTween && Math.abs(ist.rx - ziel.rx) < 0.005 && Math.abs(ist.ry - ziel.ry) < 0.005
      && Math.abs(ist.s - ziel.s) < 0.0005 && Math.abs(ist.vrx) + Math.abs(ist.vry) < 0.01 && ziel.rx === 0 && ziel.ry === 0;
    if (ruhig) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }
  function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }

  // Maus und Stift; Touch bekommt den Glanzlauf
  zone.addEventListener('pointermove', e => { if (e.pointerType !== 'touch' && !html.classList.contains('vorfuehrung')) zielSetzen(e.clientX, e.clientY); });
  zone.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch' && !html.classList.contains('vorfuehrung')) verlassen(); });
  zone.addEventListener('click', e => {
    if (e.target.closest('button')) return;
    if (ohneHover.matches && !reduziert()) { lauf.classList.remove('an'); void lauf.offsetWidth; lauf.classList.add('an'); }
  });
  document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', e => { e.stopPropagation(); variante(b.dataset.variante); }));

  // Wertwahl: Karte und Preis ändern sich mit
  const wertEl = document.querySelector('[data-wert]'), preisEl = document.querySelector('[data-preis]');
  document.querySelectorAll('[data-betrag]').forEach(b => b.addEventListener('click', () => {
    document.querySelectorAll('[data-betrag]').forEach(x => x.setAttribute('aria-checked', String(x === b)));
    const betrag = b.dataset.betrag;
    preisEl.textContent = betrag + ',00 €';
    karte.setAttribute('aria-label', karte.getAttribute('aria-label').replace(/\d+ Euro/, betrag + ' Euro'));
    if (reduziert()) { wertEl.innerHTML = betrag + '<span class="eur">€</span>'; return; }
    wertEl.classList.add('wechsel');
    setTimeout(() => { wertEl.innerHTML = betrag + '<span class="eur">€</span>'; wertEl.classList.remove('wechsel'); }, 200);
  }));

  // Vorführung: ein gedachter Zeiger zeichnet eine liegende Acht über die Bühne
  let uhr = null, start = 0, versatz = 0;
  const RUNDE = 7000;
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    start = performance.now() - versatz;
    const schritt = t => {
      versatz = t - start;
      const p = (versatz / RUNDE) % 1;
      const r = zone.getBoundingClientRect();
      let x, y;
      if (p < 0.1) {            // kommt von unten rechts herein
        const q = p / 0.1, e = 1 - Math.pow(1 - q, 3);
        x = r.left + r.width * (1.04 - 0.24 * e); y = r.top + r.height * (1.04 - 0.34 * e);
      } else if (p < 0.82) {    // Acht über die Fläche
        const q = (p - 0.1) / 0.72, w = q * Math.PI * 2;
        x = r.left + r.width * (0.5 + 0.36 * Math.sin(w + 1.2));
        y = r.top + r.height * (0.5 + 0.28 * Math.sin(2 * w + 2.4));
      } else {                  // verlässt die Bühne, die Karte kommt zur Ruhe
        const q = (p - 0.82) / 0.18, e = Math.min(1, q * 3);
        const x0 = r.left + r.width * (0.5 + 0.36 * Math.sin(Math.PI * 2 + 1.2)), y0 = r.top + r.height * (0.5 + 0.28 * Math.sin(4 * Math.PI + 2.4));
        x = x0 + (r.right + 30 - x0) * e; y = y0 + (r.bottom + 30 - y0) * e;
      }
      const innen = x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
      zeiger.style.transform = `translate(${x - r.left - 2}px, ${y - r.top - 1}px)`;
      if (innen) zielSetzen(x, y); else verlassen();
      uhr = requestAnimationFrame(schritt);
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; }
  function weiter() { if (!uhr && html.classList.contains('vorfuehrung')) vorfuehren(); }
  function neustart() { pause(); versatz = 0; html.classList.remove('vorfuehrung'); verlassen(); variante(name); }

  variante('deutlich');

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