Zum Inhalt
← Schrift-Animationen

Echo-/Stapel-Typografie

Ein Festivalname liegt vierfach in Spartenfarben unter sich selbst und fächert beim Hover und beim Scrollen zu Schichten auf: als text-shadow, als Kontur-Kopien oder als Tunnel.

Festival, Musik, Mode: ein kurzes Wort groß, die Kopien fächern bei Hover und Scroll auf, umschaltbar zwischen Text-Shadow, Kontur-Kopien und Tunnel.

  1. Ein Wort liegt mehrfach übereinander, als text-shadow-Duplikate oder als geklonte Ebenen. Jede Kopie ist leicht versetzt, skaliert oder hat eine andere Farbe oder Kontur.
  2. Bei Hover oder Scroll fahren die Kopien auseinander (Fächer, Treppe, Tunnel) und wieder zusammen.
  3. Kontur-Varianten mit -webkit-text-stroke und transparenter Füllung verstärken den grafischen Charakter.

Grafisch und plakativ, mit Retro- oder Y2K-Anmutung. Die Kopien erzeugen Tiefe ohne 3D.

Wann einsetzen

Event-, Festival-, Musik- und Mode-Seiten, Kampagnen-Heros, Footer. Gut, wenn ein kurzes Wort groß stehen darf.

Wann nicht

Bei ruhigen, sachlichen Marken und bei kleinen Schriftgraden. Nicht für Fließtext oder lange Überschriften.

Varianten

  • Text-Shadow-Duplikate (Osmo „Duplicate Text with Text Shadow“)
  • Kontur-Kopien mit Versatz bei Hover oder Scroll
  • Tunnel: Kopien skalieren in die Tiefe
  • Ghost Trails: 3 bis 4 Kopien bleiben beim Scrollen verzögert zurück und blassen aus, je Kopie ein eigener animation-range auf einer view()-Timeline, nur transform und opacity

Umsetzung

text-shadow-Liste mit einer registrierten Variable: @property --f { syntax: '<number>'; inherits: true; initial-value: 0 } und text-shadow: 0 calc(var(--f) * .22em) 0 Farbe1, 0 calc(var(--f) * .44em) 0 Farbe2, … Die Registrierung macht --f animierbar (transition: --f 450ms). Auslöser per Hover, per Klasse oder per Scroll (animation-timeline: view() oder ein Scroll-Listener). Für Kontur und Tunnel geklonte Ebenen in derselben Grid-Zelle (grid-area: 1 / 1) mit translate oder scale je Index. Platz für die volle Spreizung unter dem Wort reservieren, sonst springt das Layout.

Robust umsetzen

Telefon. Unverändert, aber weniger Kopien. Ohne Hover per Tippen oder Scroll auslösen.

Ohne Bewegung. Statischer Stapel mit fester Spreizung.

Barrierefrei. text-shadow wird nicht vorgelesen. Geklonte Ebenen sind aria-hidden, die Überschrift trägt das Wort im aria-label. Farbe nie als einzige Information.

Code

<!DOCTYPE html>
<!--
  Echo-/Stapel-Typografie
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/echo-stacked-type/
  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>Echo-/Stapel-Typografie · 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: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; 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; }

/* Spreizung des Stapels: 0 = deckungsgleich, 1 = voll aufgefächert. Registriert, damit sie animierbar ist. */
@property --f { syntax: '<number>'; inherits: true; initial-value: .27; }
@property --g { syntax: '<number>'; inherits: true; initial-value: 0; }

:root {
  --text: #0b0b0b; --grund: #efede9; --leise: #55524d; --linie: #0b0b0b;
  --musik: #c4421a; --tanz: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;
  --kurve: cubic-bezier(0.215, 0.61, 0.355, 1);   /* easeOutCubic, Rezept kultur-programm-haltung */
  --dauer: 450ms;
  --kopie: .22em;                                 /* Versatz je Kopie bei voller Spreizung */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
a { color: inherit; }
.mono { font: 500 .8125rem/1.4 'IBM Plex Mono', ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
:focus-visible { outline: 3px solid var(--tanz); outline-offset: 3px; }

/* ---------- Sticky-Navigation (Rezept: 78 px, 1 px Linie) ---------- */
.navi { position: sticky; top: 0; z-index: 10; height: 78px; display: flex; align-items: center; justify-content: space-between; gap: 24px;
  padding: 0 clamp(18px, 3vw, 40px); background: var(--grund); border-bottom: 1px solid var(--linie); }
.logo { font: 900 1.5rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; text-decoration: none; }
.logo span { font-weight: 500; }
.navi ul { display: flex; align-items: center; gap: clamp(14px, 2vw, 28px); list-style: none; padding: 0; font: 600 1rem/1 'Schibsted Grotesk', sans-serif; }
.navi ul a { text-decoration: none; }
.navi ul a:hover { text-decoration: underline; text-underline-offset: 5px; }
.navi .hilfe { display: inline-flex; align-items: center; gap: 6px; font-weight: 500; }
.ticket { display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 18px; background: var(--text); color: var(--grund); border: 1px solid var(--text);
  font: 700 1rem/1 'Schibsted Grotesk', sans-serif; text-decoration: none; transition: background-color 200ms var(--kurve), color 200ms var(--kurve); }
.ticket:hover { background: var(--grund); color: var(--text); }

/* ---------- Hero ---------- */
.hero { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr); gap: 24px clamp(24px, 4vw, 64px); align-items: end;
  padding: clamp(28px, 5vh, 56px) clamp(18px, 3vw, 40px) clamp(28px, 5vh, 56px); border-bottom: 1px solid var(--linie); }
.hero__kopf { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; }

/* Der Stapel: alle Ebenen liegen in derselben Grid-Zelle */
.stapel { position: relative; display: grid; font: 900 min(22vw, 40vh) / .8 'Schibsted Grotesk', sans-serif; letter-spacing: -.045em; text-transform: uppercase;
  padding-top: .14em; padding-bottom: calc(4 * var(--kopie)); cursor: default; user-select: none;
  --fe: min(1, calc(var(--f) + var(--s, 0) * .8)); transition: --f var(--dauer) var(--kurve); }
.stapel.offen { --f: 1; }
.stapel > span { grid-area: 1 / 1; white-space: nowrap; transition: opacity 300ms var(--kurve); }
.wort { position: relative; z-index: 5; }
.kopie { color: var(--c); }
.kopie:nth-of-type(2) { --i: 1; --c: var(--musik); z-index: 4; }
.kopie:nth-of-type(3) { --i: 2; --c: var(--tanz); z-index: 3; }
.kopie:nth-of-type(4) { --i: 3; --c: var(--kunst); z-index: 2; }
.kopie:nth-of-type(5) { --i: 4; --c: var(--film); z-index: 1; }

/* Variante A: Duplikate per text-shadow (eine Ebene, nichts wird vorgelesen) */
[data-variante='schatten'] .kopie { opacity: 0; }
[data-variante='schatten'] .wort { text-shadow:
  0 calc(var(--fe) * var(--kopie) * 1) 0 var(--musik),
  0 calc(var(--fe) * var(--kopie) * 2) 0 var(--tanz),
  0 calc(var(--fe) * var(--kopie) * 3) 0 var(--kunst),
  0 calc(var(--fe) * var(--kopie) * 4) 0 var(--film); }
/* Variante B: Kontur-Kopien, die nach unten auffächern */
[data-variante='kontur'] .kopie { color: transparent; -webkit-text-stroke: max(1.5px, .012em) var(--c); translate: 0 calc(var(--fe) * var(--kopie) * var(--i)); }
/* Variante C: Tunnel, die Kopien schrumpfen in die Tiefe */
[data-variante='tunnel'] .stapel > span { color: transparent; -webkit-text-stroke: max(1.5px, .012em) var(--c, var(--text)); transform-origin: 50% 62%; }
[data-variante='tunnel'] .kopie { scale: calc(1 - var(--fe) * .085 * var(--i)); translate: 0 calc(var(--fe) * -.035em * var(--i)); }
[data-variante='tunnel'] .wort { -webkit-text-stroke-width: max(2px, .016em); }
[data-variante='tunnel'] .stapel { padding-bottom: .12em; }

.hero__text { display: grid; gap: 22px; padding-bottom: calc(4 * var(--kopie) * .2); }
.untertitel { font: 800 clamp(1.375rem, 2.2vw, 2rem)/1.1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; }
.datum { font: 500 clamp(1.25rem, 2vw, 1.75rem)/1.2 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
.hero__text p.lauf { font-size: 1.125rem; line-height: 1.6; max-width: 40ch; }
.knoepfe { display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: center; }
.leiser-link { font: 700 1rem/1 'Schibsted Grotesk', sans-serif; text-underline-offset: 5px; }

/* Varianten-Umschalter und Werte (Demo-Bedienung, dezent) */
.modi { display: flex; gap: 4px; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid rgba(11, 11, 11, .25); border-radius: 999px;
  height: 30px; padding: 0 12px; cursor: pointer; transition: color 150ms var(--kurve), background-color 150ms var(--kurve), border-color 150ms var(--kurve); }
.modi button:hover { color: var(--text); border-color: var(--text); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.werte { font: 500 10.5px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.werte b { color: var(--text); font-weight: 600; }


/* Variante „ghost-trails“: Kopien bleiben beim Scrollen verzögert zurück und blassen aus.
   Eine view()-Timeline am Wort, je Kopie ein eigener animation-range; nur translate und opacity, kein Blur. */
[data-variante='ghost'] .stapel { view-timeline: --spur block; view-timeline-inset: 78px 0px; padding-bottom: .16em; }
[data-variante='ghost'] .wort { text-shadow: none; }
.hero[data-variante='ghost'] { overflow: clip; }   /* die Spur verschwindet an der Kante des Heros */
/* Rückfall und reduzierte Bewegung: statischer Stapel */
[data-variante='ghost'] .kopie { color: var(--c); translate: 0 calc(.6 * var(--kopie) * var(--i)); }
[data-variante='ghost'] .stapel { padding-bottom: calc(.16em + 4 * .6 * var(--kopie)); }
@supports (animation-timeline: view()) {
  html:not(.reduziert) [data-variante='ghost'] .stapel { padding-bottom: .16em; }
  html:not(.reduziert) [data-variante='ghost'] .kopie { translate: none; animation: geist linear both; animation-timeline: --spur; }
  html:not(.reduziert) [data-variante='ghost'] .kopie:nth-of-type(2) { animation-range: exit -40% exit 30%; }
  html:not(.reduziert) [data-variante='ghost'] .kopie:nth-of-type(3) { animation-range: exit -34% exit 50%; }
  html:not(.reduziert) [data-variante='ghost'] .kopie:nth-of-type(4) { animation-range: exit -28% exit 70%; }
  html:not(.reduziert) [data-variante='ghost'] .kopie:nth-of-type(5) { animation-range: exit -22% exit 90%; }
}
@keyframes geist {
  0% { translate: 0 0; opacity: 1; }
  55% { opacity: 1; }
  100% { translate: 0 calc(.2em * var(--i)); opacity: 0; }
}
.modi .trenner { width: 1px; margin: 4px 4px; background: rgba(11, 11, 11, .25); }
.vorschau:has([data-variante='ghost']) .programm { display: block; }
.vorschau:has([data-variante='ghost']) body { overflow: hidden; }

/* ---------- Programm ---------- */
.programm { padding: clamp(48px, 9vh, 96px) clamp(18px, 3vw, 40px) 64px; }
.programm h2 { font: 800 clamp(2rem, 4vw, 3.5rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; margin-bottom: 28px; }
.liste { list-style: none; padding: 0; border-top: 1px solid var(--linie); }
.zeile { display: grid; grid-template-columns: minmax(0, 15rem) minmax(0, 1fr) auto; gap: 12px 32px; align-items: center; padding: 22px 0; border-bottom: 1px solid var(--linie); }
.sparte { display: inline-block; padding: 5px 8px 4px; background: var(--farbe); color: #fff; }
.zeile .wann { display: grid; gap: 10px; justify-items: start; }
.titel { font: 800 clamp(1.75rem, 3.2vw, 3rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -.02em; text-decoration: none; display: inline-block; padding-bottom: .2em; margin-bottom: -.2em;
  text-shadow: 0 calc(var(--g) * .07em) 0 var(--farbe), 0 calc(var(--g) * .14em) 0 color-mix(in srgb, var(--farbe) 50%, var(--grund));
  transition: --g 250ms var(--kurve); }
.zeile:hover .titel, .titel:focus-visible { --g: 1; }
.titel:focus-visible { outline-offset: 6px; }
.zeile .ort { font-size: 1.125rem; line-height: 1.5; color: var(--leise); margin-top: 8px; }
.zeile .ticket { height: 40px; font-size: .9375rem; }
.fussnote { margin-top: 28px; font-size: 1rem; line-height: 1.6; color: var(--leise); max-width: 68ch; }

/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .modi, .vorschau .werte, .vorschau .programm { display: none; }
.vorschau .navi { position: static; }
.vorschau .hero { min-height: calc(100vh - 78px); border-bottom: 0; align-content: center; }

/* ---------- Telefon: drei Kopien ---------- */
@media (max-width: 760px) {
  .navi ul li:not(:last-child) { display: none; }
  .hero { grid-template-columns: minmax(0, 1fr); }
  .stapel { font-size: 31vw; padding-bottom: calc(3 * var(--kopie)); }
  .kopie:nth-of-type(5) { display: none; }
  [data-variante='schatten'] .wort { text-shadow:
    0 calc(var(--fe) * var(--kopie) * 1) 0 var(--musik),
    0 calc(var(--fe) * var(--kopie) * 2) 0 var(--tanz),
    0 calc(var(--fe) * var(--kopie) * 3) 0 var(--kunst); }
  .zeile { grid-template-columns: minmax(0, 1fr); gap: 12px; }
  .zeile .ticket { justify-self: start; }
}
/* Touch: kein Hover; Tippen auf den Stapel schaltet die Spreizung, Programmtitel zeigen ein ruhiges Echo */
@media (hover: none) {
  .titel { --g: .55; }
}

/* ---------- Reduzierte Bewegung: statischer Stapel ---------- */
.reduziert .stapel, .reduziert .stapel.offen { --f: .6; --s: 0 !important; transition: none; }
.reduziert .titel { transition: none; }
.reduziert .stapel > span { transition: none; }
</style>
</head>
<body>
<header class="navi">
  <a class="logo" href="#" aria-label="Flöz 27, Startseite">FLÖZ <span>27</span></a>
  <nav aria-label="Hauptnavigation">
    <ul>
      <li><a href="#">Programm</a></li>
      <li><a href="#">Spielorte</a></li>
      <li><a href="#">Besuch &amp; Barrierefreiheit</a></li>
      <li><a href="#" class="hilfe"><svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z" fill="currentColor"/><path d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>Vorlesen</a></li>
      <li><a href="#">Leichte Sprache</a></li>
      <li><a class="ticket" href="#">Tickets</a></li>
    </ul>
  </nav>
</header>

<main>
  <section class="hero" data-variante="schatten" aria-labelledby="festival">
    <div class="hero__kopf">
      <p class="mono">Festival für Gegenwart · Dortmunder Hafen</p>
      <div class="modi" role="group" aria-label="Variante des Stapels">
        <button type="button" data-modus="schatten" aria-pressed="true">Text-Shadow</button>
        <button type="button" data-modus="kontur" aria-pressed="false">Kontur-Kopien</button>
        <button type="button" data-modus="tunnel" aria-pressed="false">Tunnel</button>
        <span class="trenner" aria-hidden="true"></span>
        <button type="button" data-modus="ghost" aria-pressed="false">Ghost Trails</button>
      </div>
    </div>

    <h1 class="stapel" id="festival" aria-label="Flöz">
      <span class="wort" aria-hidden="true">Flöz</span>
      <span class="kopie" aria-hidden="true">Flöz</span>
      <span class="kopie" aria-hidden="true">Flöz</span>
      <span class="kopie" aria-hidden="true">Flöz</span>
      <span class="kopie" aria-hidden="true">Flöz</span>
    </h1>

    <div class="hero__text">
      <p class="untertitel">Zehn Tage Musik, Tanz, Kunst und Film. Vier Orte, eine Schicht nach der anderen.</p>
      <p class="datum">12.–21.03.2027</p>
      <p class="lauf">Das Festival zieht in Speicher, Turbinenhallen und ein Hafenkino. Jede Sparte hat ihre Farbe, jede Veranstaltung ihren Termin.</p>
      <div class="knoepfe"><a class="ticket" href="#">Tickets ab 1. Dezember</a><a class="leiser-link" href="#">Ganzes Programm</a></div>
      <p class="werte" data-werte aria-live="polite"></p>
    </div>
  </section>

  <section class="programm" aria-labelledby="programm">
    <h2 id="programm">Programm</h2>
    <ul class="liste">
      <li class="zeile" style="--farbe: var(--musik)">
        <div class="wann"><span class="sparte mono">Musik</span><span class="mono">Fr 12.03. · 20:00</span></div>
        <div><a class="titel" href="#">Basslinien für Stahl</a><p class="ort">Konzert zur Eröffnung, Speicher 7 am Hafen</p></div>
        <a class="ticket" href="#">Tickets 18 €</a>
      </li>
      <li class="zeile" style="--farbe: var(--tanz)">
        <div class="wann"><span class="sparte mono">Tanz</span><span class="mono">Sa 13.03. · 19:30</span></div>
        <div><a class="titel" href="#">Acht Stunden</a><p class="ort">Tanzstück über Schichtarbeit, Turbinenhaus Huckarde</p></div>
        <a class="ticket" href="#">Tickets 22 €</a>
      </li>
      <li class="zeile" style="--farbe: var(--kunst)">
        <div class="wann"><span class="sparte mono">Kunst</span><span class="mono">12.–21.03. · 11–20 Uhr</span></div>
        <div><a class="titel" href="#">Sediment</a><p class="ort">Ausstellung, Halle am Schleusenweg, Eintritt frei</p></div>
        <a class="ticket" href="#">Zeitfenster buchen</a>
      </li>
      <li class="zeile" style="--farbe: var(--film)">
        <div class="wann"><span class="sparte mono">Film</span><span class="mono">Do 18.03. · 21:00</span></div>
        <div><a class="titel" href="#">Nachtschicht</a><p class="ort">Kurzfilmprogramm, Kino im Speicher, mit Untertiteln</p></div>
        <a class="ticket" href="#">Tickets 9 €</a>
      </li>
    </ul>
    <p class="fussnote">Alle Spielorte sind stufenlos erreichbar. Zu jeder Tanz- und Filmvorstellung gibt es Plätze für Rollstuhlnutzende und eine Induktionsschleife. Ein Festival des Kulturwerk Hafen Dortmund e. V.</p>
  </section>
</main>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const hero = document.querySelector('.hero');
  const stapel = document.querySelector('.stapel');
  const werte = document.querySelector('[data-werte]');
  let variante = 'schatten';

  const NAMEN = { schatten: 'text-shadow, 4 Schatten', kontur: '4 Ebenen, -webkit-text-stroke', tunnel: '4 Ebenen, scale 1 − 0,085 × i', ghost: 'Ghost Trails, 4 Ebenen' };
  const sdaOk = CSS.supports('animation-timeline', 'view()');
  function anzeigen() {
    if (variante === 'ghost') {
      werte.innerHTML = reduziert() || !sdaOk
        ? `${reduziert() ? 'Reduziert' : 'Ohne animation-timeline'} · <b>statischer Stapel</b>, Versatz 0,13em je Kopie`
        : `view()-Timeline am Wort · animation-range je Kopie <b>exit −40 % → 30 %</b> bis <b>exit −22 % → 90 %</b><br>translate 0 → <b>0,2em × i</b> · opacity <b>1 → 1 (55 %) → 0</b> · linear, kein Blur`;
      return;
    }
    werte.innerHTML = reduziert()
      ? `Reduziert · <b>statischer Stapel</b>, Spreizung 0,6 · ${NAMEN[variante]}`
      : `Versatz je Kopie <b>0,06 → 0,22em</b> · <b>450 ms</b> · cubic-bezier(0.215, 0.61, 0.355, 1)<br>${NAMEN[variante]} · Scroll addiert bis <b>+0,8</b>`;
  }
  function setzen(v, url) {
    if (url) { const q = new URLSearchParams(location.search); q.set('v', v === 'ghost' ? 'ghost-trails' : 'basis'); history.replaceState(null, '', location.pathname + '?' + q.toString().replace(/=(?=&|$)/g, '') + location.hash); }
    if (v === 'ghost') stapel.classList.remove('offen');
    variante = v; hero.dataset.variante = v;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === v)));
    anzeigen();
  }
  document.querySelectorAll('[data-modus]').forEach(b => b.addEventListener('click', () => { pause(); setzen(b.dataset.modus, true); }));

  // Hover fächert auf, Verlassen schiebt zusammen; Touch: Tippen schaltet um
  const hover = matchMedia('(hover: hover)');
  stapel.addEventListener('pointerenter', e => { if (e.pointerType !== 'touch' && hover.matches) { pause(); stapel.classList.add('offen'); } });
  stapel.addEventListener('pointerleave', e => { if (e.pointerType !== 'touch') stapel.classList.remove('offen'); });
  stapel.addEventListener('click', () => { if (!hover.matches) { pause(); stapel.classList.toggle('offen'); } });

  // Scroll: der Stapel öffnet sich, während der Hero aus dem Bild läuft
  let bild = 0;
  function scrollen() {
    bild = 0;
    if (reduziert()) return;
    const s = Math.min(1, Math.max(0, scrollY / (innerHeight * .7)));
    stapel.style.setProperty('--s', s.toFixed(3));
  }
  addEventListener('scroll', () => { if (!bild) bild = requestAnimationFrame(scrollen); }, { passive: true });

  // Vorführung: gedachter Hover im Takt, Varianten wechseln je Runde (Vorschau)
  const MODI = ['schatten', 'kontur', 'tunnel'];
  let uhren = [], runde = 0, laeuft = false;
  const spaeter = (f, ms) => uhren.push(setTimeout(f, ms));
  // Ghost Trails: die Seite scrollt selbst ein Stück hinunter und wieder hinauf (Kosinus, 5 s je Runde)
  let rollUhr = null, rollStart = 0;
  function rollen(t) {
    const max = Math.min(innerHeight * .5, document.documentElement.scrollHeight - innerHeight);
    const p = ((t - rollStart) % 5000) / 5000;
    scrollTo(0, Math.round(max * (0.5 - 0.5 * Math.cos(p * 2 * Math.PI))));
    rollUhr = requestAnimationFrame(rollen);
  }
  function eineRunde() {
    if (variante === 'ghost') {
      if (reduziert()) return;               // statischer Stapel, nichts zu zeigen
      rollStart = performance.now(); rollUhr = requestAnimationFrame(rollen); return;
    }
    if (html.classList.contains('vorschau')) setzen(MODI[runde % 3]);
    runde++;
    spaeter(() => stapel.classList.add('offen'), 300);
    spaeter(() => stapel.classList.remove('offen'), 2700);
    spaeter(() => { if (laeuft) eineRunde(); }, 3700);
  }
  function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; cancelAnimationFrame(rollUhr); rollUhr = null; }
  function neustart() { pause(); stapel.classList.remove('offen'); eineRunde(); }
  function vorfuehren() { pause(); stapel.classList.remove('offen'); laeuft = true; runde = 0; eineRunde(); }
  function weiter() { if (!laeuft) { laeuft = true; eineRunde(); } }

  // Regel 13: ?v=basis (drei Stapel-Arten) oder ?v=ghost-trails
  if (new URLSearchParams(location.search).get('v') === 'ghost-trails') setzen('ghost'); else anzeigen();
  scrollen();

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