Zum Inhalt
← Cursor

Masken- und Spotlight-Reveal am Cursor

Ein Kreis am Zeiger wächst über der Überschrift von 40 auf 400 px und legt eine zweite, ehrlichere Ebene frei.

Studio- oder Agentur-Startseite mit einem Haltungssatz, der eine ehrlichere zweite Fassung versteckt.

Zwei deckungsgleiche Ebenen liegen übereinander. Unten die sichtbare Basis (Text oder Bild), oben eine zweite Fassung (ehrlicher Text, Raster, Röntgenbild). Die obere Ebene trägt eine mask-image mit radial-gradient, deren Mittelpunkt dem Zeiger folgt. Abseits des Ziels ist der Kreis klein (Ø 40 px) und wirkt wie ein Cursorpunkt. Über dem Ziel wächst er auf Ø 400 px und legt die zweite Ebene frei, mit Überschwinger (back.out, 500 ms). Die Spotlight-Variante dreht das Prinzip um: Eine dunkle Fläche liegt über allem, nur am Zeiger ist es hell.

Neugier. Der Nutzer entdeckt eine zweite Bedeutungsebene, ohne dass sich das Layout verändert. Das erzeugt Doppelbödigkeit und eine kleine Pointe. Gut für Wortspiele und Persönlichkeit. Sehr erinnerbar, wenn der Inhalt den Effekt begründet.

Wann einsetzen

Hero-Statements und Über-uns-Seiten von Portfolios mit Pointe. Vorher/Nachher und Produkt-Innenleben (Röntgenblick). Kampagnen mit Doppelbotschaft. Dunkle Hero-Sektionen und 404-Seiten mit Taschenlampe.

Wann nicht

Wenn die verborgene Ebene wichtige Information trägt: Touch- und Tastaturnutzer sähen sie sonst nie. Bei langen Fließtexten. Auf Touch-first-Seiten ohne Ersatz wie Gedrückt-halten oder Umschalter.

Varianten

  • Zwei Fassungen eines Textes: Der Kreis wächst über der Überschrift von Ø 40 auf 400 px (Olivier Larose nach minhpham.design)
  • Röntgenblick auf ein Bild: Die Maske zeigt Raster, Skizze oder Innenleben
  • Taschenlampe: dunkle Fläche mit radialem Lichtkegel am Zeiger (radial-gradient an --x/--y)
  • Pinsel: eine Canvas-Maske sammelt Pinselstriche, die nach 1,4 s verblassen; darunter das fertige Foto, darüber die Skizze
  • Vorher/Nachher-Scharfzeichnen: Basis unscharf, die Maske zeigt das scharfe Bild
  • Spotlight mit per @property registrierten Größen und Zeigerposition in Prozent (Chrome-Anleitung)
  • Malen und Radieren mit Canvas destination-out, die Spur bleibt stehen (Olivier Larose, Paint Reveal)
  • WebGL: Spotlight-Shader oder Fluid-Simulation als Maske für eine zweite 3D-Szene (Codrops 03/2026)

Umsetzung

CSS: @property --r { syntax: '<length>'; inherits: false; initial-value: 20px; } registrieren, sonst animiert transition den Radius nicht. Obere Ebene: mask-image: radial-gradient(circle var(--r) at var(--x) var(--y), #000 calc(100% - 1.5px), transparent 100%), dazu -webkit-mask-image. transition: --r .5s cubic-bezier(0.34, 1.56, 0.64, 1). Über dem Ziel --r auf 200px setzen (Ø 400 px). JS: pointermove setzt das Ziel, ein rAF-Loop lerpt x/y mit 0,12 pro Bild und schreibt --x/--y in Pixeln. Alternative nach Larose: SVG-Kreis als mask-image, mask-size und mask-position folgen dem Zeiger. Spotlight: Overlay mit radial-gradient(circle var(--r) at var(--x) var(--y), transparent, rgba(0,0,0,.7) 78 %, rgba(0,0,0,.96)), transition: --r 200ms ease-in-out. Fallstricke: obere Ebene pointer-events: none, damit Links darunter klickbar bleiben. Radius in Pixeln statt Prozent, sonst wird der Kreis zur Ellipse. Beide Ebenen gleich aufbauen (Breite, Schrift, Abstände), sonst springen die Zeilen.

Robust umsetzen

Telefon. Gedrückt halten zeigt die Maske groß unter dem Finger, Ziehen bewegt sie. Dazu ein Umschalter, der die zweite Ebene ganz zeigt. Alternativ wandert die Maske einmal selbst. Die Paint-Variante funktioniert mit Touch gut.

Ohne Bewegung. Kein Nachlauf, die Maske sitzt direkt am Zeiger. Die Größe springt ohne Animation. Alternativ zeigt ein Umschalter beide Ebenen nacheinander statisch.

Barrierefrei. Die Basis trägt den vollständigen Inhalt. Die verborgene Ebene ist echter Text, aber aria-hidden, wenn sie nur Pointe ist. Wichtige Aussagen gehören in die zugängliche Ebene. Ein Umschalter (button mit aria-pressed) macht die zweite Ebene für Tastatur und Touch sichtbar.

Code

<!DOCTYPE html>
<!--
  Masken- und Spotlight-Reveal am Cursor
  Fundus · Cursor · https://cecodesign.de/lab/fundus/cursor/cursor-masken-reveal/
  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>Masken- und Spotlight-Reveal am 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'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-style: italic; font-weight: 400; font-display: swap; }

/* Maskenradius als echte Länge registrieren, damit transition ihn animiert */
@property --r { syntax: '<length>'; inherits: false; initial-value: 20px; }

:root {
  --papier: #eeebe4; --tinte: #141413; --leise: rgba(20, 20, 19, .64); --linie: rgba(20, 20, 19, .16);
  --akzent: #e4502f;                       /* zweite Ebene „Text“ */
  --blau: #1d39b0;                         /* zweite Ebene „Röntgen“ */
  --back: cubic-bezier(0.34, 1.56, 0.64, 1);   /* back.out, 500 ms: Maske wächst mit Überschwinger */
  --inout: cubic-bezier(0.42, 0, 0.58, 1);     /* ease-in-out, 200 ms: Lichtkegel (Chrome-Anleitung) */
  --grund: var(--papier); --text: var(--tinte);
}
body[data-variante="spot"] { --grund: #0c0b0a; --text: #efe8da; --leise: rgba(239, 232, 218, .55); --linie: rgba(239, 232, 218, .14); }
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body {
  min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto;
  padding: 0 clamp(20px, 4vw, 56px); background: var(--grund); color: var(--text);
  transition: background-color .15s linear, color .15s linear;
}
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.leise { color: var(--leise); }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; }

/* Kopf und Fuß */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 22px 0; border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: baseline; gap: 14px; }
.marke b { font-size: 16px; font-weight: 600; letter-spacing: -.01em; }
.wahl { display: flex; flex-wrap: wrap; gap: 6px; }
.wahl button, .umschalten {
  font: inherit; font-family: 'Geist Mono', ui-monospace, monospace; font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
  color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; padding: 7px 12px; cursor: pointer;
  transition: color .15s linear, background-color .15s linear, border-color .15s linear;
}
.wahl button:hover, .umschalten:hover { color: var(--text); border-color: var(--leise); }
.wahl button[aria-pressed="true"] { background: var(--text); border-color: var(--text); color: var(--grund); }
.wahl button:focus-visible, .umschalten:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.fuss { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding: 16px 0 20px; border-top: 1px solid var(--linie); }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; color: var(--leise); }
.werte b { color: var(--text); font-weight: 500; }
.rechts { display: flex; align-items: center; gap: 16px; }
.umschalten[aria-pressed="true"] { color: var(--text); border-color: var(--text); }

/* Bühne: alle Szenen in derselben Zelle, nur die aktive ist sichtbar */
.buehne { display: grid; }
.szene { grid-area: 1 / 1; position: relative; display: none; }
.szene.an { display: grid; }
.ebene { display: grid; align-content: center; padding: clamp(28px, 6vh, 72px) 0; }

/* Die zweite Ebene liegt deckungsgleich darüber. Nur der Kreis an --x/--y ist sichtbar. */
.geheim {
  position: absolute; inset: 0; pointer-events: none; user-select: none;
  --r: 20px;                                         /* Ø 40 px: wirkt wie ein Cursorpunkt */
  -webkit-mask-image: radial-gradient(circle var(--r) at var(--x, -100px) var(--y, -100px), #000 calc(100% - 1.5px), transparent 100%);
          mask-image: radial-gradient(circle var(--r) at var(--x, -100px) var(--y, -100px), #000 calc(100% - 1.5px), transparent 100%);
  transition: --r .5s var(--back);
}
.buehne.gross .geheim { --r: min(200px, 42vw); }     /* Ø 400 px über dem Ziel */
.buehne.offen .geheim { --r: 150vmax; transition-duration: .9s; }

/* Variante 1: zwei Fassungen desselben Satzes */
.satz { font-size: clamp(46px, 7.4vw, 116px); font-weight: 500; line-height: .95; letter-spacing: -.045em; max-width: 12.6em; margin: 22px 0 0; text-wrap: balance; }
.satz em { font-size: 1.07em; line-height: .8; }
.unter { display: grid; grid-template-columns: minmax(0, 34em) auto; justify-content: space-between; align-items: end; gap: 20px; margin-top: clamp(28px, 6vh, 64px); }
.unter p:first-child { font-size: 17px; line-height: 1.5; max-width: 36ch; }
.text .geheim { background: var(--akzent); color: var(--tinte); }
.text .geheim .leise { color: rgba(20, 20, 19, .95); }

/* Variante 2: Röntgenblick auf das Raster */
.zwei { grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr); align-items: center; gap: clamp(24px, 4.5vw, 72px); }
.titel { font-size: clamp(34px, 4.6vw, 70px); font-weight: 500; line-height: 1; letter-spacing: -.035em; margin: 18px 0 20px; text-wrap: balance; }
.spalte > p:last-child { font-size: 16px; line-height: 1.55; max-width: 34ch; }
.rahmen { position: relative; aspect-ratio: 3 / 2; border-radius: 4px; overflow: hidden; background: #d9d6cf; touch-action: none; }
.rahmen img { display: block; width: 100%; height: 100%; object-fit: cover; }
.rahmen svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.roentgen .geheim { background: var(--blau); color: #eef0ff; }
.roentgen .geheim .leise { color: rgba(238, 240, 255, .74); }
.roentgen .geheim .rahmen { background: var(--blau); }
.roentgen .geheim img { filter: grayscale(1) invert(1) contrast(1.25); mix-blend-mode: screen; opacity: .9; }

/* Variante 3: Taschenlampe auf dunkler Bühne */
.spot { --rand: clamp(20px, 4vw, 56px); }
.spot .zwei { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); }
.spot .titel { font-size: clamp(54px, 7.4vw, 118px); letter-spacing: -.05em; line-height: .9; }
.spot .titel em { font-size: 1.08em; color: #d9b46a; }
.spot .rahmen { background: #1b1712; box-shadow: 0 30px 80px -30px rgba(0, 0, 0, .8); }
/* Bühnenboden: feines Punktraster, damit der Lichtkegel auch auf leerer Fläche sichtbar ist */
.spot::before { content: ''; position: absolute; inset: 0 calc(-1 * var(--rand)); background: #16130f radial-gradient(rgba(239, 232, 218, .16) 1px, transparent 1.4px) 0 0 / 22px 22px; }
.spot .ebene { position: relative; }
.dunkel {
  position: absolute; inset: 0 calc(-1 * var(--rand)); pointer-events: none;
  --r: min(160px, 36vw);
  --mx: calc(var(--x, 70%) + var(--rand));
  background:
    radial-gradient(circle var(--r) at var(--mx) var(--y, 50%), rgba(255, 210, 140, .1), transparent 75%),
    radial-gradient(circle var(--r) at var(--mx) var(--y, 50%), rgba(12, 11, 10, 0) 0%, rgba(12, 11, 10, .08) 40%, rgba(12, 11, 10, .7) 78%, rgba(12, 11, 10, .96) 100%);
  transition: --r .2s var(--inout);
}
.buehne.gross .dunkel { --r: min(260px, 48vw); }
.buehne.offen .dunkel { --r: 150vmax; transition-duration: .6s; }
@media (hover: hover) { body[data-variante="spot"] .szene.an { cursor: none; } }

/* Variante 4: Pinsel. Oben die Skizze, darunter das Foto; die Maske ist ein Canvas, der Striche sammelt und verblassen lässt */
.pinsel .titel em { color: #9a3a1f; }
.pinsel .rahmen { background: #eeebe4; box-shadow: 0 0 0 1px var(--linie); cursor: crosshair; }
.pinsel .rahmen canvas, .pinsel .rahmen .voll { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.pinsel .skizze-ersatz { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; filter: grayscale(1) contrast(1.35) brightness(1.18) sepia(.18); mix-blend-mode: multiply; opacity: .85; display: none; }
.pinsel .rahmen.ohne-skizze .skizze-ersatz { display: block; }
.pinsel .rahmen .voll { object-fit: cover; opacity: 0; transition: opacity .6s var(--inout); }
.buehne.offen .pinsel .rahmen .voll { opacity: 1; }
.pinsel .legende { display: flex; justify-content: space-between; gap: 12px; margin-top: 10px; }

/* Gedachter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
.vorfuehrung body[data-variante="spot"] .zeiger { display: none; }

.vorschau body { overflow: hidden; }
.vorschau .wahl, .vorschau .rechts { display: none; }

/* Reduzierte Bewegung: kein Nachlaufen, Größe springt sofort */
.reduziert .geheim, .reduziert .dunkel { transition: none !important; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

/* Touch: kein Hover. Gedrückt halten zeigt die Maske unter dem Finger, der Knopf zeigt alles. */
@media (hover: none) {
  .zeiger { display: none !important; }
  .satz { touch-action: none; }
}
@media (max-width: 760px) {
  .kopf { flex-direction: column; align-items: flex-start; gap: 14px; }
  .unter { grid-template-columns: 1fr; }
  .zwei, .spot .zwei { grid-template-columns: 1fr; gap: 28px; }
  .titel { margin-bottom: 12px; }
}
@media (max-width: 420px) { .marke .leise { display: none; } .wahl button { padding: 7px 10px; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="text">
<header class="kopf">
  <p class="marke"><b>Studio Fernlicht</b><span class="mono leise">Gestaltung · Köln-Ehrenfeld</span></p>
  <div class="wahl" role="group" aria-label="Variante">
    <button type="button" data-wahl="text" aria-pressed="true">Text</button>
    <button type="button" data-wahl="roentgen" aria-pressed="false">Röntgen</button>
    <button type="button" data-wahl="spot" aria-pressed="false">Spotlight</button>
    <button type="button" data-wahl="pinsel" aria-pressed="false">Pinsel</button>
  </div>
</header>

<main class="buehne" data-buehne>
  <!-- 1 · Zwei Fassungen desselben Satzes -->
  <section class="szene text an" data-szene="text">
    <div class="ebene">
      <p class="mono leise">(01) Haltung</p>
      <h1 class="satz" data-ziel>Wir gestalten Marken, die <em>leise</em> überzeugen.</h1>
      <div class="unter">
        <p>Erscheinungsbilder, Websites und Editorial für Handwerk, Kultur und Gastronomie. Klein im Team, groß im Detail.</p>
        <p class="mono leise">Seit 2014 · 4 Menschen</p>
      </div>
    </div>
    <div class="ebene geheim" aria-hidden="true">
      <p class="mono leise">(01) Ehrlich gesagt</p>
      <p class="satz">Wir verwerfen zwanzig Skizzen, bis <em>eine</em> sitzt.</p>
      <div class="unter">
        <p>Und wir streiten über Zeilenabstände, als ginge es um Leben und Tod. Der Kaffee hilft. Drei Kannen am Tag.</p>
        <p class="mono leise">Seit 2014 · 11.000 Tassen</p>
      </div>
    </div>
  </section>

  <!-- 2 · Röntgenblick: unter dem Layout liegt das Raster -->
  <section class="szene roentgen" data-szene="roentgen">
    <div class="ebene zwei">
      <div class="spalte">
        <p class="mono leise">(02) Editorial · Gefüge</p>
        <h2 class="titel">Unter jedem Layout liegt ein Raster.</h2>
        <p>Gefüge, ein Heft über Dinge, die bleiben. Jede Seite folgt demselben Satzspiegel, und genau deshalb wirkt sie frei.</p>
      </div>
      <figure class="rahmen" data-ziel><img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin Gefüge: links ein Keramik-Stillleben auf Orange, rechts die Überschrift Die Ruhe der Dinge" width="1200" height="800"></figure>
    </div>
    <div class="ebene zwei geheim" aria-hidden="true">
      <div class="spalte">
        <p class="mono leise">(02) Röntgenblick</p>
        <p class="titel">12 Spalten, eine Grundlinie, kein Zufall.</p>
        <p>Bundsteg 18 mm, Kopfsteg 14 mm, Spaltenabstand 4 mm. Die Überschriften springen nie, sie rasten ein.</p>
      </div>
      <figure class="rahmen"><img src="bilder/magazin-doppelseite.jpg" alt="" width="1200" height="800"><svg data-raster viewBox="0 0 1200 800" preserveAspectRatio="xMidYMid slice"></svg></figure>
    </div>
  </section>

  <!-- 3 · Taschenlampe auf dunkler Bühne -->
  <section class="szene spot" data-szene="spot">
    <div class="ebene zwei">
      <div class="spalte">
        <p class="mono leise">(03) Marke · Buchhandlung</p>
        <h2 class="titel"><em>Lesen</em> im Dunkeln.</h2>
        <p>Wortmarke, Signet und Plakate für Eselsohr, Buchladen und Lesecafé. Zur Lesenacht geht das Licht aus. Sehen kann nur, wer hinleuchtet.</p>
      </div>
      <figure class="rahmen" data-ziel><img src="bilder/marke-eselsohr.jpg" alt="Wortbildmarke Eselsohr, Buchhandlung und Lesecafé: Blatt mit rosa Eselsohr neben kursiver Schrift auf Anthrazit" width="1200" height="800"></figure>
    </div>
    <div class="dunkel" aria-hidden="true"></div>
  </section>

  <!-- 4 · Pinsel: Striche legen das Foto unter der Skizze frei und verblassen -->
  <section class="szene pinsel" data-szene="pinsel">
    <div class="ebene zwei">
      <div class="spalte">
        <p class="mono leise">(04) Bildsprache · Keramik Tonfeld</p>
        <h2 class="titel">Erst die Skizze, dann das <em>Licht.</em></h2>
        <p>Jede Aufnahme für die Keramikwerkstatt zeichnen wir vor, bevor das Licht gesetzt wird. Malen Sie über die Skizze: Darunter liegt das fertige Foto.</p>
      </div>
      <figure class="rahmen" data-ziel data-pinsel>
        <img class="skizze-ersatz" src="bilder/keramik-stillleben.jpg" alt="" width="1200" height="800">
        <canvas data-skizze aria-hidden="true"></canvas>
        <canvas data-farbe aria-hidden="true"></canvas>
        <img class="voll" src="bilder/keramik-stillleben.jpg" alt="Keramik-Stillleben: helle Flaschenvase, schwarzes Kugelgefäß und kleine Schalen auf Gipssockeln vor Terrakotta" width="1200" height="800">
      </figure>
    </div>
  </section>
</main>

<footer class="fuss">
  <p class="werte mono" data-werte aria-live="polite"></p>
  <div class="rechts">
    <p class="mono leise" data-hinweis></p>
    <button type="button" class="umschalten" data-umschalten aria-pressed="false">Andere Ebene zeigen</button>
  </div>
</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="#141413" stroke="#eeebe4" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const touch = matchMedia('(hover: none)').matches;
  const buehne = document.querySelector('[data-buehne]');
  const werte = document.querySelector('[data-werte]');
  const hinweis = document.querySelector('[data-hinweis]');
  const knopf = document.querySelector('[data-umschalten]');
  const zeiger = document.querySelector('[data-zeiger]');
  const wahl = [...document.querySelectorAll('[data-wahl]')];

  // Werte pro Variante (Anzeige und Token)
  const VARIANTEN = {
    text:     { werte: '<span>mask-image <b>radial-gradient</b></span><span>Ø <b>40 → 400 px</b></span><span><b>back.out</b> 500 ms</span><span>lerp <b>0,12</b></span>',
                ziel: 'den Satz', knopf: ['Andere Ebene zeigen', 'Zurück'], park: [0.9, 0.16] },
    roentgen: { werte: '<span>mask-image <b>radial-gradient</b></span><span>Ø <b>40 → 400 px</b></span><span><b>back.out</b> 500 ms</span><span>filter <b>invert + grayscale</b></span>',
                ziel: 'das Bild', knopf: ['Raster zeigen', 'Zurück'], park: [0.3, 0.86] },
    spot:     { werte: '<span>Overlay <b>radial-gradient</b></span><span>r <b>160 → 260 px</b></span><span><b>ease-in-out</b> 200 ms</span><span>lerp <b>0,12</b></span>',
                ziel: 'das Plakat', knopf: ['Licht an', 'Licht aus'], park: [0.28, 0.42] },
    pinsel:   { werte: '<span>Maske <b>Canvas · source-in</b></span><span>Pinsel Ø <b>84 → 46 px</b> nach Tempo</span><span>halten <b>1,4 s</b> · verblassen <b>1,6 s</b></span><span>Stempel <b>alle 0,18 Ø</b></span>',
                ziel: 'die Skizze', knopf: ['Ganzes Foto zeigen', 'Zurück zur Skizze'], park: [0.24, 0.9] }
  };
  const LERP = 0.12;                 // pro Bild bei 60 Hz, auf die echte Bildrate umgerechnet
  let variante = 'text', offen = false, halten = false, laeuft = false, letzte = 0;
  let ziel = { x: 0, y: 0 }, ist = { x: 0, y: 0 }, drin = false;

  const szene = () => buehne.querySelector(`[data-szene="${variante}"]`);
  const zielElement = () => szene().querySelector('[data-ziel]');

  // Raster für den Röntgenblick (12 Spalten, Grundlinien, Bemaßung)
  (function raster() {
    const svg = document.querySelector('[data-raster]');
    const L = 96, R = 1104, steg = 16, sp = (R - L - 11 * steg) / 12;
    let s = '';
    for (let y = 40; y < 800; y += 20) s += `<line x1="0" x2="1200" y1="${y}" y2="${y}" stroke="#eef0ff" stroke-opacity=".14" stroke-width="1"/>`;
    for (let i = 0; i < 12; i++) s += `<rect x="${L + i * (sp + steg)}" y="70" width="${sp}" height="660" fill="#eef0ff" fill-opacity=".07" stroke="#eef0ff" stroke-opacity=".38" stroke-width="1"/>`;
    s += `<line x1="600" x2="600" y1="40" y2="760" stroke="#ffd36b" stroke-width="1.5" stroke-dasharray="6 6"/>`;
    s += `<g font-family="Geist Mono, monospace" font-size="15" letter-spacing="2" fill="#eef0ff">`;
    s += `<text x="${L}" y="56">SATZSPIEGEL · 12 SP.</text><text x="${R}" y="56" text-anchor="end">STEG 4 MM</text>`;
    s += `<text x="612" y="752" fill="#ffd36b">BUNDSTEG 18 MM</text><text x="${L}" y="752">GRUNDLINIE 4,2 MM</text></g>`;
    s += `<path d="M${L} 30v20M${R} 30v20M${L} 40H${R}" stroke="#eef0ff" stroke-opacity=".7"/>`;
    svg.innerHTML = s;
  })();

  /* ---------- Pinsel: Canvas-Maske sammelt Striche, jeder Stempel hält 1,4 s und verblasst in 1,6 s ---------- */
  const pinsel = (() => {
    const fig = document.querySelector('[data-pinsel]');
    const skizze = fig.querySelector('[data-skizze]'), farbe = fig.querySelector('[data-farbe]'), foto = fig.querySelector('.voll');
    const maske = document.createElement('canvas');
    const P = { d: 84, anteil: 0.12, dMin: 0.55, tempoMax: 1.6, abstand: 0.18, halten: 1400, verblassen: 1600, max: 900 };
    let striche = [], b = 0, h = 0, dpr = 1, alt = null, laeuft = false, quelle = null;

    // Pinselstempel: 34 Borsten in Strichrichtung, zum Rand hin kürzer und blasser
    const stempel = document.createElement('canvas'); stempel.width = stempel.height = 128;
    (() => {
      const g = stempel.getContext('2d'), z = (s => () => (s = (s * 16807) % 2147483647) / 2147483647)(11);
      g.lineCap = 'round'; g.strokeStyle = '#000';
      for (let i = 0; i < 34; i++) {
        const q = i / 33 * 2 - 1, y = 64 + q * 56, l = 52 * Math.sqrt(1 - q * q * 0.86) * (0.7 + 0.3 * z());
        g.globalAlpha = (0.45 + 0.55 * z()) * (1 - Math.pow(Math.abs(q), 3) * 0.6);
        g.lineWidth = 2 + z() * 3.4;
        const v = (z() - 0.5) * 14;
        g.beginPath(); g.moveTo(64 - l + v, y); g.lineTo(64 + l + v, y + (z() - 0.5) * 3); g.stroke();
      }
    })();

    function cover(iw, ih, w, hh) { const s = Math.max(w / iw, hh / ih); return [(w - iw * s) / 2, (hh - ih * s) / 2, iw * s, ih * s]; }
    // Skizze aus dem Foto: Graustufe, Weichzeichnen, Sobel-Kanten als Graphit, dunkle Flächen schraffiert
    function skizzeRechnen() {
      if (quelle || !foto.complete || !foto.naturalWidth) return;
      const sw = 900, sh = Math.round(900 * foto.naturalHeight / foto.naturalWidth);
      const c = document.createElement('canvas'); c.width = sw; c.height = sh;
      const g = c.getContext('2d', { willReadFrequently: true });
      g.drawImage(foto, 0, 0, sw, sh);
      let d;
      try { d = g.getImageData(0, 0, sw, sh); } catch (e) { fig.classList.add('ohne-skizze'); quelle = 'ersatz'; return; }   // file:// ohne Server
      const n = sw * sh, gr = new Float32Array(n), bl = new Float32Array(n), px = d.data;
      for (let i = 0; i < n; i++) gr[i] = (px[i * 4] * 0.3 + px[i * 4 + 1] * 0.59 + px[i * 4 + 2] * 0.11) / 255;
      for (let y = 1; y < sh - 1; y++) for (let x = 1; x < sw - 1; x++) {
        let s = 0; for (let j = -1; j <= 1; j++) for (let k = -1; k <= 1; k++) s += gr[(y + j) * sw + x + k];
        bl[y * sw + x] = s / 9;
      }
      for (let y = 0; y < sh; y++) for (let x = 0; x < sw; x++) {
        const i = y * sw + x, o = i * 4;
        let kante = 0;
        if (x > 1 && y > 1 && x < sw - 2 && y < sh - 2) {
          const a = k => bl[i + k];
          const gx = a(-sw + 1) + 2 * a(1) + a(sw + 1) - a(-sw - 1) - 2 * a(-1) - a(sw - 1);
          const gy = a(sw - 1) + 2 * a(sw) + a(sw + 1) - a(-sw - 1) - 2 * a(-sw) - a(-sw + 1);
          kante = Math.min(1, Math.max(0, (Math.hypot(gx, gy) - 0.07) * 3.2));
        }
        const ton = gr[i], schraffur = ton < 0.42 && (x + y) % 7 === 0 ? 0.32 * (1 - ton / 0.42) : 0;
        const tinte = Math.max(0, (1 - 0.9 * kante) * (1 - 0.16 * (1 - ton)) - schraffur);
        px[o] = 40 + (238 - 40) * tinte; px[o + 1] = 38 + (235 - 38) * tinte; px[o + 2] = 35 + (228 - 35) * tinte; px[o + 3] = 255;
      }
      g.putImageData(d, 0, 0);
      quelle = c;
    }
    function groesse() {
      const r = fig.getBoundingClientRect();
      if (r.width < 2) return false;
      if (Math.round(r.width) === b && Math.round(r.height) === h) return true;
      b = Math.round(r.width); h = Math.round(r.height); dpr = Math.min(devicePixelRatio || 1, 2);
      [skizze, farbe, maske].forEach(c => { c.width = Math.round(b * dpr); c.height = Math.round(h * dpr); });
      skizzeRechnen();
      if (quelle && quelle !== 'ersatz') {
        const g = skizze.getContext('2d'), [x, y, w, hh] = cover(quelle.width, quelle.height, skizze.width, skizze.height);
        g.drawImage(quelle, x, y, w, hh);
      }
      zeichne(performance.now());
      return true;
    }
    foto.complete ? requestAnimationFrame(() => { b = 0; groesse(); }) : foto.addEventListener('load', () => { b = 0; groesse(); }, { once: true });
    const durchmesser = () => Math.min(P.d, b * P.anteil);
    // Punkt in Rahmen-Koordinaten: Stempel entlang der Strecke vom letzten Punkt, Ø nach Tempo
    function punkt(x, y, t) {
      if (!alt) { alt = { x, y, t, rest: 0 }; return; }
      const dx = x - alt.x, dy = y - alt.y, l = Math.hypot(dx, dy);
      if (l < 0.5) return;
      const tempo = l / Math.max(1, t - alt.t);
      const d = durchmesser() * (1 - (1 - P.dMin) * Math.min(1, tempo / P.tempoMax)), w = Math.atan2(dy, dx), schritt = Math.max(2, d * P.abstand);
      let s = alt.rest;
      for (; s < l; s += schritt) striche.push({ x: alt.x + dx * s / l, y: alt.y + dy * s / l, d, w, t });
      if (striche.length > P.max) striche.splice(0, striche.length - P.max);
      alt = { x, y, t, rest: s - l };
      wecken();
    }
    function abheben() { alt = null; }
    function zeichne(t) {
      if (!b) return;
      const red = reduziert();
      if (!red) striche = striche.filter(s => t - s.t < P.halten + P.verblassen);
      const m = maske.getContext('2d');
      m.setTransform(1, 0, 0, 1, 0, 0); m.globalAlpha = 1; m.clearRect(0, 0, maske.width, maske.height);
      for (const s of striche) {
        const alter = t - s.t, q = Math.min(1, Math.max(0, (alter - P.halten) / P.verblassen));
        m.globalAlpha = red ? 1 : 1 - q * q * (3 - 2 * q);
        m.setTransform(dpr, 0, 0, dpr, s.x * dpr, s.y * dpr); m.rotate(s.w);
        m.drawImage(stempel, -s.d * 0.5, -s.d * 0.5, s.d, s.d);
      }
      const f = farbe.getContext('2d');
      f.globalCompositeOperation = 'source-over'; f.clearRect(0, 0, farbe.width, farbe.height);
      if (!striche.length || !foto.naturalWidth) return;
      f.drawImage(maske, 0, 0);
      f.globalCompositeOperation = 'source-in';
      const [x, y, w, hh] = cover(foto.naturalWidth, foto.naturalHeight, farbe.width, farbe.height);
      f.drawImage(foto, x, y, w, hh);
      f.globalCompositeOperation = 'source-over';
    }
    function lauf(t) {
      zeichne(t);
      if (striche.length && !reduziert()) requestAnimationFrame(lauf); else laeuft = false;
    }
    function wecken() { if (reduziert()) { zeichne(performance.now()); return; } if (!laeuft) { laeuft = true; requestAnimationFrame(lauf); } }
    function leeren() { striche = []; alt = null; zeichne(performance.now()); }
    addEventListener('resize', () => { if (variante === 'pinsel') groesse(); });
    return { fig, groesse, punkt, abheben, leeren, P };
  })();
  // Aus dem Mal-Zustand der Bildschleife: Stift unten, solange der Punkt im Rahmen liegt
  function pinselSchritt() {
    if (variante !== 'pinsel' || offen) return;
    const s = szene().getBoundingClientRect(), f = pinsel.fig.getBoundingClientRect();
    const x = ist.x + s.left - f.left, y = ist.y + s.top - f.top;
    const unten = (uhr || halten || mausDa) && x >= 0 && y >= 0 && x <= f.width && y <= f.height;
    if (unten) pinsel.punkt(x, y, performance.now()); else pinsel.abheben();
  }
  let mausDa = false;

  function parken() {
    const s = szene().getBoundingClientRect(), p = VARIANTEN[variante].park;
    ziel.x = ist.x = s.width * p[0]; ziel.y = ist.y = s.height * p[1];
    malen();
  }
  function malen() {
    buehne.style.setProperty('--x', ist.x.toFixed(1) + 'px');
    buehne.style.setProperty('--y', ist.y.toFixed(1) + 'px');
  }
  // Groß, solange der Zeiger über dem Ziel liegt (oder der Finger gedrückt hält)
  function pruefeGross() {
    const s = szene().getBoundingClientRect(), z = zielElement().getBoundingClientRect();
    const x = ziel.x + s.left, y = ziel.y + s.top;
    drin = x > z.left - 8 && x < z.right + 8 && y > z.top - 8 && y < z.bottom + 8;
    buehne.classList.toggle('gross', halten || drin);
  }
  function bild(t) {
    const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
    const k = reduziert() || halten ? 1 : 1 - Math.pow(1 - LERP, dt);
    ist.x += (ziel.x - ist.x) * k; ist.y += (ziel.y - ist.y) * k;
    malen(); pinselSchritt();
    if (Math.abs(ziel.x - ist.x) < 0.1 && Math.abs(ziel.y - ist.y) < 0.1 && !uhr) { laeuft = false; return; }
    requestAnimationFrame(bild);
  }
  function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
  function zielAus(cx, cy) {
    const s = szene().getBoundingClientRect();
    ziel.x = cx - s.left; ziel.y = cy - s.top;
    pruefeGross(); starten();
  }

  // Maus und Finger
  buehne.addEventListener('pointermove', e => {
    if (uhr) return;
    if (e.pointerType === 'mouse') mausDa = true;
    if (e.pointerType === 'mouse' || halten) zielAus(e.clientX, e.clientY);
  });
  buehne.addEventListener('pointerleave', e => { mausDa = false; pinsel.abheben(); if (!uhr && e.pointerType === 'mouse') buehne.classList.remove('gross'); });
  buehne.addEventListener('pointerdown', e => {
    if (e.pointerType === 'mouse') return;
    if (variante === 'pinsel') { pause(); html.classList.remove('vorfuehrung'); pinsel.abheben(); }   // Striche bleiben, der Finger malt weiter
    else neustart();
    halten = true; zielAus(e.clientX, e.clientY);
    ist.x = ziel.x; ist.y = ziel.y; malen();
  });
  const loslassen = () => { if (halten) { halten = false; pinsel.abheben(); pruefeGross(); } };
  addEventListener('pointerup', loslassen);
  addEventListener('pointercancel', loslassen);
  buehne.addEventListener('contextmenu', e => { if (touch) e.preventDefault(); });

  // Knopf: zweite Ebene ganz zeigen (Tastatur, Touch, reduzierte Bewegung)
  function setzeOffen(an) {
    offen = an;
    buehne.classList.toggle('offen', an);
    knopf.setAttribute('aria-pressed', String(an));
    knopf.textContent = VARIANTEN[variante].knopf[an ? 1 : 0];
  }
  knopf.addEventListener('click', () => { pause(); html.classList.remove('vorfuehrung'); setzeOffen(!offen); });

  function setzeVariante(v) {
    variante = v;
    document.body.dataset.variante = v;
    buehne.querySelectorAll('.szene').forEach(s => s.classList.toggle('an', s.dataset.szene === v));
    wahl.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.wahl === v)));
    werte.innerHTML = VARIANTEN[v].werte + (reduziert() ? '<span><b>reduziert</b>: ohne Nachlauf</span>' : '');
    hinweis.textContent = touch ? (v === 'pinsel' ? 'Mit dem Finger malen' : 'Gedrückt halten') : `Zeiger über ${VARIANTEN[v].ziel}`;
    zeiger.querySelector('path').setAttribute('fill', v === 'roentgen' ? '#1d39b0' : '#141413');
    buehne.classList.remove('gross');
    setzeOffen(false);
    parken();
    pinsel.leeren();
    if (v === 'pinsel') pinsel.groesse();
  }
  // Variante in der URL (?v=), andere Parameter bleiben erhalten
  function urlSetzen(v) {
    const u = new URL(location.href);
    v === 'text' ? u.searchParams.delete('v') : u.searchParams.set('v', v);
    try { history.replaceState(null, '', u); } catch (e) { /* about:srcdoc o. ä. */ }
  }
  wahl.forEach(b => b.addEventListener('click', () => {
    const lief = !!uhr;
    setzeVariante(b.dataset.wahl); urlSetzen(b.dataset.wahl);
    if (lief) vorfuehren();
  }));

  // Vorführung: ein gedachter Zeiger fährt vom Parkplatz über das Ziel und zurück
  const PFAD = [   // [Zeit 0–1, x, y]; x/y in Anteilen des Ziels, 'p' = Parkplatz
    [0, 'p'], [0.1, 'p'], [0.24, 0.1, 0.42], [0.38, 0.52, 0.2], [0.52, 0.86, 0.66],
    [0.64, 0.4, 0.82], [0.76, 0.62, 0.42], [0.9, 'p'], [1, 'p']
  ];
  const glatt = q => q * q * (3 - 2 * q);
  let uhr = null, start = 0, einmal = false;
  function punkt(p) {
    const s = szene().getBoundingClientRect(), z = zielElement().getBoundingClientRect(), pk = VARIANTEN[variante].park;
    const pos = k => k[1] === 'p' ? [s.width * pk[0], s.height * pk[1]] : [z.left - s.left + z.width * k[1], z.top - s.top + z.height * k[2]];
    let i = 0; while (i < PFAD.length - 2 && p > PFAD[i + 1][0]) i++;
    const a = PFAD[i], b = PFAD[i + 1], q = glatt(Math.min(1, Math.max(0, (p - a[0]) / (b[0] - a[0]))));
    const A = pos(a), B = pos(b);
    return [A[0] + (B[0] - A[0]) * q, A[1] + (B[1] - A[1]) * q, s];
  }
  function vorfuehren(nurEinmal) {
    pause();
    einmal = nurEinmal === true;
    halten = false; setzeOffen(false);
    html.classList.add('vorfuehrung');
    start = performance.now();
    if (reduziert()) {           // statt Bewegung: beide Ebenen nacheinander, ohne Übergang
      const takt = () => { setzeOffen(!offen); uhr = setTimeout(takt, 2600); };
      uhr = setTimeout(takt, 1200);
      return;
    }
    const schritt = t => {
      const roh = (t - start) / 8000;          // 8 s pro Runde
      if (einmal && roh >= 1) { neustart(); return; }
      const [x, y, s] = punkt(roh % 1);
      ziel.x = x; ziel.y = y; pruefeGross();
      zeiger.style.transform = `translate(${s.left + x - 2}px, ${s.top + y - 1}px)`;
      uhr = requestAnimationFrame(schritt);
      starten();
    };
    uhr = requestAnimationFrame(schritt);
  }
  function pause() { cancelAnimationFrame(uhr); clearTimeout(uhr); uhr = null; }
  function weiter() { if (!uhr && html.classList.contains('vorfuehrung')) vorfuehren(einmal); }
  function neustart() {
    pause(); html.classList.remove('vorfuehrung');
    halten = false; buehne.classList.remove('gross'); setzeOffen(false); parken(); pinsel.leeren();
  }

  const vStart = new URLSearchParams(location.search).get('v');
  setzeVariante(VARIANTEN[vStart] ? vStart : 'text');
  addEventListener('resize', () => { if (!uhr) parken(); });
  // Touch ohne Zeiger: einmal selbst vorführen, danach Gedrückt-halten
  if (touch && !reduziert() && !html.classList.contains('vorschau') && !new URLSearchParams(location.search).has('vorschau')) {
    document.fonts.ready.then(() => setTimeout(() => vorfuehren(true), 400));
  }

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