Zum Inhalt
← Hover

Bild-Spur hinter dem Cursor (Image Trail)

Wer die Maus bewegt, legt eine Spur aus Projektbildern über den Hero: drei gemessene Varianten von Drehen bis Kreismaske.

Ein Pool von 8 bis 20 Bildern liegt unsichtbar über einer Fläche (position: absolute, pointer-events: none, opacity: 0). Bei jeder Mausbewegung misst ein Skript die Strecke seit dem letzten Bild. Überschreitet sie einen Schwellenwert, erscheint das nächste Bild aus dem Pool an der Mausposition, mit dem höchsten z-index. Kurz danach verschwindet es wieder: es schrumpft, dreht sich weg, fällt oder schließt sich in einer Maske. So entsteht eine fächerartige Bildspur entlang der Bewegung.

Ein Archiv wirkt in Sekunden voll. Jede Bewegung wird sofort belohnt, ganz ohne Klick. Der Nutzer malt mit der Bildmenge und hat dabei das Gefühl von Kontrolle und Fülle. Die Seite zeigt viel Arbeit in kurzer Zeit, ohne Raster.

Wann einsetzen

Hero- und Intro-Flächen von Fotografen, Studios und Kreativportfolios. Archiv- und Index-Seiten mit vielen Bildern, Kampagnen- und Festival-Seiten, 404-Seiten.

Wann nicht

Auf inhaltsschweren Seiten, auf denen die Bilder vom Lesen ablenken. Bei wenigen oder uneinheitlichen Bildern. Bei ruhigen Marken. Wenn sich die Bilder nicht vorladen lassen, flackert die Spur.

Varianten

  • Drehen (Huge): Bilder springen gedreht auf und bleiben liegen. Nach kurzem Stillstand drehen sich alle gemeinsam weg.
  • Gleiten (Codrops Demo 1): Bild startet an der nachlaufenden Position, gleitet zur Maus, schrumpft und blendet aus.
  • Maske (Makemepulse): Bild öffnet sich in einer runden Maske, Drehung nach Mausgeschwindigkeit.
  • Hochskalieren, Fallen oder Quetschen beim Verschwinden (Codrops Demos 2 bis 4).
  • Geschwindigkeitsabhängig: Bildgröße oder Drehung folgt dem Tempo (Motion „Cursor trail velocity“).
  • Pixel- oder Kachelspur statt Bildern (siehe pixel-raster-hover).
  • Trail als Intro-Übergang zwischen zwei Layouts (Codrops 2022, GSAP Flip).
  • Canvas- oder WebGL-Spur: Bilder als Texturen statt DOM-Elemente.

Umsetzung

Bilder vorab laden (img.decode()) und mit fester Größe anlegen (aspect-ratio), damit pro Frame nichts gemessen werden muss. Ein Frame-Takt führt eine träge Position nach (Lerp 0,1) und prüft die Strecke seit dem letzten Bild mit Math.hypot. Liegt sie über dem Schwellenwert, kommt das nächste Bild aus dem Ringpuffer: gsap.killTweensOf(img), dann eine Timeline mit set (opacity 1, zIndex ++z, Position) und den Tweens der Variante. Nur transform, opacity und clip-path animieren. Sind alle Bilder unsichtbar, den z-index-Zähler zurücksetzen. Pool groß genug für schnelle Bewegungen wählen (12 bis 16). Effekt nur bei (hover: hover) an die Maus binden; Huge aktiviert ihn erst ab 1024 px Breite und nur, solange die Fläche im Viewport ist.

Robust umsetzen

Telefon. Ohne Maus gibt es keine Spur zum Malen. Besser: eine automatische, langsame Spur im Hero, die nur läuft, solange er sichtbar ist. Alternativ eine statische Collage. Wer Wischen zulässt, begrenzt es auf die Fläche und lässt senkrechtes Scrollen frei (touch-action: pan-y).

Ohne Bewegung. Keine Spur. Stattdessen eine statische Collage oder eine ruhige Diashow mit Überblendung.

Barrierefrei. Rein dekorativ: Bilder mit alt="" und die Spur mit aria-hidden. Keine Information nur in der Spur. Die Projekte müssen zusätzlich als normale Liste mit Links erreichbar sein.

Code

<!DOCTYPE html>
<!--
  Bild-Spur hinter dem Cursor (Image Trail)
  Fundus · Hover · https://cecodesign.de/lab/fundus/hover/bild-trail-cursor/
  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>Bild-Spur hinter dem Cursor · 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 {
  --grund: #131211; --text: #efece4; --leise: rgba(239, 236, 228, .52); --linie: rgba(239, 236, 228, .14);
  --akzent: #e3c9a0;
  --ui: cubic-bezier(0.16, 1, 0.3, 1);   /* Bedienelemente: expo.out, 150 ms */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
a { color: inherit; }

/* ---------- Bühne: die ganze Hero-Fläche ---------- */
.buehne {
  position: relative; height: 100svh; min-height: 560px; overflow: hidden;
  display: grid; grid-template-rows: auto 1fr auto; padding: clamp(18px, 3vw, 36px);
  cursor: crosshair; isolation: isolate;
}
.kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; position: relative; z-index: 2; }
.marke { font-weight: 600; font-size: 15px; letter-spacing: -.01em; }
.marke span { color: var(--leise); font-weight: 400; }
.nav { display: flex; gap: 22px; list-style: none; padding: 0; }
.nav a { text-decoration: none; }

.mitte { align-self: center; position: relative; z-index: 0; pointer-events: none; }
.titel {
  font-size: clamp(46px, 9.4vw, 152px); font-weight: 500; letter-spacing: -.055em; line-height: .9;
  max-width: 11ch;
}
.titel em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.02em; color: var(--akzent); }
.unter { margin-top: clamp(18px, 2.4vw, 30px); max-width: 34ch; font-size: clamp(15px, 1.25vw, 18px); line-height: 1.45; color: var(--leise); }

.fuss { display: flex; justify-content: space-between; align-items: flex-end; gap: 20px 32px; flex-wrap: wrap; position: relative; z-index: 2; }
.zaehler { display: flex; gap: 18px; align-items: baseline; }
.zaehler b { font-weight: 500; color: var(--text); }
.steuer { display: grid; justify-items: end; gap: 10px; }
.umschalter { display: flex; border: 1px solid var(--linie); border-radius: 999px; padding: 3px; background: rgba(19, 18, 17, .6); backdrop-filter: blur(6px); }
.umschalter button {
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise);
  background: none; border: 0; border-radius: 999px; padding: 7px 13px; cursor: pointer;
  transition: color .15s var(--ui), background-color .15s var(--ui);
}
.umschalter button:hover { color: var(--text); }
.umschalter button[aria-pressed="true"] { background: var(--text); color: var(--grund); }
.umschalter button:focus-visible { outline: 1px solid var(--akzent); outline-offset: 2px; }
.werte { text-align: right; max-width: 62ch; color: rgba(239, 236, 228, .54); font-size: 10px; }

/* ---------- Der Pool: 16 Bilder, liegen unsichtbar über der Fläche ---------- */
.spur { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.spur img {
  position: absolute; left: 0; top: 0; display: block; object-fit: cover; opacity: 0;
  border-radius: 3px; will-change: transform, opacity; backface-visibility: hidden;
  box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .7);
}
.spur .hoch { width: clamp(118px, 12.5vw, 196px); aspect-ratio: 4 / 5; }
.spur .quer { width: clamp(150px, 17vw, 268px); aspect-ratio: 3 / 2; }

/* Gedachter Zeiger für die Vorführung */
.zeiger { position: absolute; left: 0; top: 0; z-index: 3; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
.hinweis { position: absolute; z-index: 2; left: 50%; bottom: clamp(18px, 3vw, 36px); translate: -50% 0; display: flex; align-items: center; gap: 10px; transition: opacity .6s var(--ui); }
.hinweis::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--akzent); animation: puls 1.8s var(--ui) infinite; }
@keyframes puls { 0% { box-shadow: 0 0 0 0 rgba(227, 201, 160, .5); } 100% { box-shadow: 0 0 0 10px rgba(227, 201, 160, 0); } }
.buehne.bewegt .hinweis, .vorfuehrung .hinweis { opacity: 0; }

/* Ersatz bei reduzierter Bewegung: ruhige Collage */
.collage { display: none; position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.collage img { position: absolute; object-fit: cover; border-radius: 3px; box-shadow: 0 18px 40px -18px rgba(0, 0, 0, .7); }

/* ---------- Projekte als normale Liste (für Tastatur und Screenreader) ---------- */
.projekte { padding: clamp(48px, 7vw, 96px) clamp(18px, 3vw, 36px) clamp(56px, 8vw, 110px); border-top: 1px solid var(--linie); }
.projekte .kopfzeile { display: flex; justify-content: space-between; margin-bottom: 18px; }
.projekte ol { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(2, 1fr); column-gap: clamp(24px, 4vw, 64px); }
.projekte li a { display: grid; grid-template-columns: 2.6em 1fr auto; align-items: baseline; gap: 12px; padding: 16px 0; border-top: 1px solid var(--linie); text-decoration: none; }
.projekte .name { font-size: clamp(20px, 2.2vw, 30px); letter-spacing: -.03em; font-weight: 500; }
.projekte li a:hover .name, .projekte li a:focus-visible .name { color: var(--akzent); }
.projekte li a:focus-visible { outline: none; }
.projekte li a:focus-visible .name { text-decoration: underline; text-underline-offset: .15em; }

/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .projekte { display: none; }
.vorschau .buehne { height: 100vh; min-height: 0; }

/* ---------- Touch: automatische Spur, Wischen malt mit ---------- */
@media (hover: none) {
  .buehne { cursor: auto; touch-action: pan-y; height: 88svh; }
  .hinweis { display: none; }
  .nav li:not(:last-child) { display: none; }
}
@media (max-width: 700px) {
  .fuss { flex-direction: column; align-items: flex-start; }
  .steuer { justify-items: start; }
  .werte { text-align: left; }
  .zaehler .ort { display: none; }
  .projekte ol { grid-template-columns: 1fr; }
}

/* ---------- Reduzierte Bewegung: keine Spur, ruhige Collage ---------- */
.reduziert .spur, .reduziert .hinweis, .reduziert .umschalter, .reduziert .zeiger { display: none; }
.reduziert .collage { display: block; }
.reduziert .buehne { cursor: auto; }
.reduziert * { transition-duration: .01ms !important; animation: none !important; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="buehne" data-buehne>
  <div class="kopf">
    <p class="marke">Studio Hallstein <span>· Gestaltung, Köln</span></p>
    <ul class="nav mono"><li><a href="#projekte">Arbeiten</a></li><li><a href="#projekte">Studio</a></li><li><a href="#projekte">Kontakt</a></li></ul>
  </div>

  <div class="mitte">
    <h1 class="titel">Wir zeigen lieber, <em>als zu erzählen.</em></h1>
    <p class="unter">Marken, Räume und Drucksachen für Cafés, Handwerk und Kultur. Zehn Jahre Arbeit, eine Fläche.</p>
  </div>

  <div class="fuss">
    <p class="mono zaehler"><span>Archiv <b data-nr>00</b> / 16</span><span class="ort">2016 – 2026</span><span data-titel aria-hidden="true">&nbsp;</span></p>
    <div class="steuer">
      <div class="umschalter mono" role="group" aria-label="Variante">
        <button type="button" data-var="drehen" aria-pressed="true">Drehen</button>
        <button type="button" data-var="gleiten" aria-pressed="false">Gleiten</button>
        <button type="button" data-var="maske" aria-pressed="false">Maske</button>
      </div>
      <p class="mono werte" data-werte></p>
    </div>
  </div>

  <p class="mono hinweis" aria-hidden="true">Maus bewegen</p>

  <div class="spur" data-spur aria-hidden="true">
    <img class="hoch" src="bilder/marke-eselsohr-hoch.jpg" alt="" data-t="Eselsohr">
    <img class="quer" src="bilder/magazin-doppelseite.jpg" alt="" data-t="Die Ruhe der Dinge">
    <img class="hoch" src="bilder/marke-krume-hoch.jpg" alt="" data-t="Krume">
    <img class="quer" src="bilder/beton-treppe.jpg" alt="" data-t="Haus Ostergrund">
    <img class="hoch" src="bilder/typo-plakat-hoch.jpg" alt="" data-t="Licht">
    <img class="quer" src="bilder/zitronen-tisch.jpg" alt="" data-t="Trattoria Limone">
    <img class="hoch" src="bilder/marke-roestkontor-hoch.jpg" alt="" data-t="Röstkontor">
    <img class="quer" src="bilder/keramik-stillleben.jpg" alt="" data-t="Stillleben &amp; Räume">
    <img class="hoch" src="bilder/flakon-hoch.jpg" alt="" data-t="Stillleben &amp; Räume">
    <img class="quer" src="bilder/fenster-licht.jpg" alt="" data-t="Stillleben &amp; Räume">
    <img class="hoch" src="bilder/stoff-stapel-hoch.jpg" alt="" data-t="Stillleben &amp; Räume">
    <img class="quer" src="bilder/huegel-nebel.jpg" alt="" data-t="Stillleben &amp; Räume">
    <img class="hoch" src="bilder/magazin-doppelseite-hoch.jpg" alt="" data-t="Die Ruhe der Dinge">
    <img class="quer" src="bilder/marke-roestkontor.jpg" alt="" data-t="Röstkontor">
    <img class="hoch" src="bilder/beton-treppe-hoch.jpg" alt="" data-t="Haus Ostergrund">
    <img class="quer" src="bilder/typo-plakat.jpg" alt="" data-t="Licht">
  </div>

  <div class="collage" aria-hidden="true">
    <img src="bilder/marke-eselsohr-hoch.jpg" alt="" style="width:13vw;aspect-ratio:4/5;right:6%;top:10%;rotate:-6deg">
    <img src="bilder/magazin-doppelseite.jpg" alt="" style="width:17vw;aspect-ratio:3/2;right:24%;top:58%;rotate:4deg">
    <img src="bilder/marke-krume-hoch.jpg" alt="" style="width:12vw;aspect-ratio:4/5;right:5%;top:46%;rotate:3deg">
  </div>

  <svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2.5 1.8 17 10.2l-6.6 1.4-3.2 6.1Z" fill="#efece4" stroke="#131211" stroke-width="1.3" stroke-linejoin="round"/></svg>
</header>

<section class="projekte" id="projekte">
  <div class="kopfzeile"><h2 class="mono">Ausgewählte Projekte</h2><p class="mono">(08)</p></div>
  <ol>
    <li><a href="#projekte"><span class="mono">01</span><span class="name">Eselsohr</span><span class="mono">2023</span></a></li>
    <li><a href="#projekte"><span class="mono">02</span><span class="name">Krume</span><span class="mono">2025</span></a></li>
    <li><a href="#projekte"><span class="mono">03</span><span class="name">Röstkontor</span><span class="mono">2024</span></a></li>
    <li><a href="#projekte"><span class="mono">04</span><span class="name">Licht</span><span class="mono">2026</span></a></li>
    <li><a href="#projekte"><span class="mono">05</span><span class="name">Die Ruhe der Dinge</span><span class="mono">2021</span></a></li>
    <li><a href="#projekte"><span class="mono">06</span><span class="name">Haus Ostergrund</span><span class="mono">2020</span></a></li>
    <li><a href="#projekte"><span class="mono">07</span><span class="name">Trattoria Limone</span><span class="mono">2019</span></a></li>
    <li><a href="#projekte"><span class="mono">08</span><span class="name">Stillleben &amp; Räume</span><span class="mono">2016 – 2026</span></a></li>
  </ol>
</section>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js"></script>
<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  gsap.registerPlugin(CustomEase);
  CustomEase.create('maske', '0.22,0.14,0.36,1');   // Makemepulse

  const buehne = document.querySelector('[data-buehne]');
  const pool = [...document.querySelectorAll('[data-spur] img')];
  const zeiger = document.querySelector('[data-zeiger]');
  const nrEl = document.querySelector('[data-nr]'), titelEl = document.querySelector('[data-titel]');
  const werteEl = document.querySelector('[data-werte]');
  const knoepfe = [...document.querySelectorAll('[data-var]')];
  const MAX = 16;                       // Trail-Länge (Huge: max. 16)
  const ROT = [-12, 0, 12, 0];          // Huge: Zielrotation reihum

  // Drei Varianten, je mit den Werten aus den Messungen
  const VARIANTEN = {
    drehen: {   // Huge
      schwelle: () => Math.max(44, buehne.clientWidth / 20),   // unter 880 px Breite mindestens 44 px
      text: 'Schwelle 5 % Breite, mind. 44 px · Ein: scale 0→1, rotate −60°→±12°, 300 ms power1.out · Aus nach 420 ms Ruhe: scale 0, rotate 240°, 700 ms power2.in',
    },
    gleiten: {  // Codrops Demo 1
      schwelle: () => 100,
      text: 'Schwelle 100 px · Lerp 0,1 · gleitet 900 ms expo.out · ab 400 ms: opacity 0 (1000 ms power1.out), scale 0,2 (1000 ms quint.out)',
    },
    maske: {    // Makemepulse
      schwelle: () => 65,
      text: 'Schwelle 65 px · Trägheit 0,1 · Kreismaske 0→150 %, 800 ms cubic-bezier(.22,.14,.36,1) · Drehung nach Tempo, max. ±12°',
    },
  };
  let variante = 'drehen';

  // Zeigerzustand in Bühnen-Koordinaten
  const maus = { x: 0, y: 0 }, zuletzt = { x: 0, y: 0 }, traege = { x: 0, y: 0 };
  let nr = 0, z = 1, sichtbar = [], letzteBewegung = 0, aktiv = false, erster = true, vx = 0;

  pool.forEach(img => { img.decode?.().catch(() => {}); gsap.set(img, { xPercent: -50, yPercent: -50, opacity: 0, transformOrigin: '50% 50%' }); });

  function setzeVariante(v) {
    variante = v;
    knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.var === v));
    werteEl.textContent = reduziert() ? 'Reduzierte Bewegung · keine Spur, ruhige Collage' : VARIANTEN[v].text;
    leeren(true);
  }
  function leeren(sofort) {
    gsap.killTweensOf(pool);
    if (sofort) gsap.set(pool, { opacity: 0, clipPath: 'none' });
    sichtbar = []; z = 1; erster = true;
  }

  function zeigeTitel(img) {
    nr = nr % MAX + 1;
    nrEl.textContent = String(nr).padStart(2, '0');
    titelEl.textContent = img.dataset.t;
  }

  let i = 0;
  function spawn() {
    const img = pool[i % pool.length], n = i++;
    gsap.killTweensOf(img);
    sichtbar = sichtbar.filter(x => x !== img); sichtbar.push(img);
    if (sichtbar.length > MAX) sichtbar.shift();
    zeigeTitel(img);

    if (variante === 'drehen') {
      gsap.set(img, { x: maus.x, y: maus.y, zIndex: ++z, opacity: 1, clipPath: 'none' });
      gsap.fromTo(img, { scale: 0, rotation: -60 }, { scale: 1, rotation: ROT[n % 4], duration: 0.3, ease: 'power1.out' });
    } else if (variante === 'gleiten') {
      gsap.timeline()
        .set(img, { opacity: 1, scale: 1, rotation: 0, zIndex: ++z, x: traege.x, y: traege.y, clipPath: 'none' })
        .to(img, { x: maus.x, y: maus.y, duration: 0.9, ease: 'expo.out' }, 0)
        .to(img, { opacity: 0, duration: 1, ease: 'power1.out' }, 0.4)
        .to(img, { scale: 0.2, duration: 1, ease: 'quint.out' }, 0.4);
    } else {
      const rot = gsap.utils.clamp(-12, 12, vx * 0.6);
      gsap.timeline()
        .set(img, { opacity: 1, scale: 1, zIndex: ++z, x: traege.x, y: traege.y, rotation: rot, clipPath: 'circle(0% at 50% 50%)' })
        .to(img, { x: maus.x, y: maus.y, duration: 0.8, ease: 'maske' }, 0)
        .to(img, { clipPath: 'circle(75% at 50% 50%)', duration: 0.8, ease: 'maske' }, 0)
        .to(img, { clipPath: 'circle(0% at 50% 50%)', duration: 0.6, ease: 'power2.in' }, 0.9)
        .set(img, { opacity: 0 });
    }
  }

  // Ruhe erkannt (Huge): alle sichtbaren Bilder gehen gemeinsam
  function ausblendenAlle() {
    if (!sichtbar.length) return;
    const weg = sichtbar; sichtbar = [];
    gsap.to(weg, { opacity: 0, scale: 0, rotation: 240, duration: 0.7, ease: 'power2.in', stagger: 0.03, overwrite: true });
  }

  // Ein Takt pro Frame: Trägheit nachführen, Schwelle prüfen, Ruhe erkennen
  gsap.ticker.add((t, dtMs) => {
    if (reduziert()) return;
    const k = 1 - Math.pow(1 - 0.1, Math.min(64, dtMs) / 16.667);   // Lerp 0,1 bei 60 Bildern/s
    const px = traege.x;
    traege.x += (maus.x - traege.x) * k; traege.y += (maus.y - traege.y) * k;
    vx += ((traege.x - px) - vx) * 0.2;
    if (aktiv && Math.hypot(maus.x - zuletzt.x, maus.y - zuletzt.y) > VARIANTEN[variante].schwelle()) {
      spawn(); zuletzt.x = maus.x; zuletzt.y = maus.y;
    }
    if (variante === 'drehen' && performance.now() - letzteBewegung > 420) ausblendenAlle();
    if (variante !== 'drehen' && sichtbar.length && pool.every(p => +gsap.getProperty(p, 'opacity') === 0)) { sichtbar = []; z = 1; }
  });

  function bewege(x, y) {
    maus.x = x; maus.y = y; letzteBewegung = performance.now();
    if (erster) { traege.x = zuletzt.x = x; traege.y = zuletzt.y = y; erster = false; aktiv = true; }
  }
  function ausBuehne(e) { const r = buehne.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }

  buehne.addEventListener('pointermove', e => {
    if (html.classList.contains('vorfuehrung') || reduziert()) return;
    if (e.target.closest('.umschalter, .kopf a')) return;
    buehne.classList.add('bewegt');
    bewege(...ausBuehne(e));
  });
  buehne.addEventListener('pointerleave', () => { if (!html.classList.contains('vorfuehrung')) { aktiv = false; erster = true; } });
  knoepfe.forEach(b => b.addEventListener('click', () => setzeVariante(b.dataset.var)));

  // Vorführung: ein gedachter Zeiger zeichnet eine liegende Acht, hält kurz an, dann nächste Variante
  const RUNDE_VOR = 5200, RUNDE_AUTO = 8000, HALT = 1500, FOLGE = ['drehen', 'gleiten', 'maske'];
  let uhr = null, start = 0, runde = 0, auto = false;
  function bahn(q) {
    const w = buehne.clientWidth, h = buehne.clientHeight;
    const a = q * Math.PI * 2;
    return [w * (0.5 + 0.34 * Math.sin(a + 0.4)), h * (0.5 + 0.22 * Math.sin(2 * a + 0.8))];
  }
  function takt(t) {
    const p = t - start, RUNDE = auto ? RUNDE_AUTO : RUNDE_VOR;
    if (p > RUNDE + HALT) {
      start = t; runde++;
      if (!auto) setzeVariante(FOLGE[runde % 3]);
    } else if (p <= RUNDE) {
      const q = 0.5 - 0.5 * Math.cos(Math.PI * p / RUNDE);     // weich anfahren und abbremsen
      const [x, y] = bahn(q);
      bewege(x, y);
      zeiger.style.transform = `translate(${x - 3}px, ${y - 2}px)`;
    }
    uhr = requestAnimationFrame(takt);
  }
  function vorfuehren() {
    pause(); gsap.globalTimeline.resume();
    if (reduziert()) return;
    html.classList.add('vorfuehrung'); auto = false;
    runde = 0; setzeVariante(FOLGE[0]);
    start = performance.now(); uhr = requestAnimationFrame(takt);
  }
  function pause() { cancelAnimationFrame(uhr); uhr = null; gsap.globalTimeline.pause(); }
  function weiter() { gsap.globalTimeline.resume(); if (!uhr && (html.classList.contains('vorfuehrung') || auto)) { start = performance.now(); uhr = requestAnimationFrame(takt); } }
  function neustart() {
    cancelAnimationFrame(uhr); uhr = null; gsap.globalTimeline.resume();
    html.classList.remove('vorfuehrung'); buehne.classList.remove('bewegt');
    aktiv = false; nr = 0; nrEl.textContent = '00'; titelEl.innerHTML = '&nbsp;';
    setzeVariante(variante);
    if (touch && !reduziert()) autoStart();
  }

  // Touch: keine Maus, also zeichnet die Bühne die Spur selbst, solange sie im Blick ist
  const touch = matchMedia('(hover: none)').matches;
  function autoStart() { if (uhr || reduziert()) return; auto = true; start = performance.now(); uhr = requestAnimationFrame(takt); }
  if (touch) {
    new IntersectionObserver(([e]) => {
      if (html.classList.contains('vorfuehrung')) return;
      if (e.isIntersecting) autoStart(); else { cancelAnimationFrame(uhr); uhr = null; }
    }).observe(buehne);
  }

  setzeVariante('drehen');
  // Der Schalter der Detailseite setzt .reduziert erst nachträglich
  let warReduziert = reduziert();
  new MutationObserver(() => { if (reduziert() !== warReduziert) { warReduziert = reduziert(); setzeVariante(variante); } }).observe(html, { attributes: true, attributeFilter: ['class'] });

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