Zum Inhalt
← Animationsstile

Animationsstil „Editorial langsam“

Fallstudie eines Designstudios: Zeilen steigen aus Masken, das Bild deckt sich auf und sinkt von 1,1 auf 1, alles mit expo.out und langer, weicher Landung.

Bewegungssprache für magazinartige Seiten. Elemente starten schnell und landen lange und weich (expo.out). Text steigt zeilenweise aus Masken (yPercent 100 → 0). Bilder decken sich per clip-path auf und sinken dabei leicht von 1,1 auf 1. Keine Federn, kein Überschwingen. Großzügige Pausen und ein Versatz pro Zeile.

Die schnelle Anfangsbewegung verhindert ein träges Gefühl. Die lange Landephase wirkt souverän und lässt Zeit zum Lesen. Weil alles dieselbe Kurve nutzt, wirkt die Seite wie aus einem Guss.

Wann einsetzen

Portfolios, Magazine, Architektur, Kultur und Agenturseiten mit großer Typografie und viel Weißraum.

Wann nicht

Für UI-Feedback wie Buttons und Formulare ist es zu langsam. Bei sehr vielen gleichzeitigen Elementen wird es zäh.

Varianten

  • Maske: Zeilen aus overflow-hidden-Masken, Bild per clip-path von unten, Scale 1,1 → 1
  • Unschärfe: blur 10px → 0 mit y 24px und Opazität statt Maske
  • Kurve .19: cubic-bezier(0.19, 1, 0.22, 1), kürzere Haarlinien (700 ms) und dichterer Versatz (60 ms)

Umsetzung

CSS-Token: --ease-editorial: cubic-bezier(0.16, 1, 0.3, 1); --dur-reveal: 1.2s; --dur-reveal-lg: 1.6s; --stagger-line: 0.1s. Dauern-Skala 600 / 1000 / 1200 / 1600 / 2000 ms. GSAP: ease 'expo.out' (oder CustomEase mit denselben Werten), duration 1.2, stagger 0.08 bis 0.15. Zeilen per SplitText mit mask: 'lines' oder Wrapper mit overflow: hidden. ScrollTrigger mit start 'top 85%' und once. Für Raumwechsel expo.inOut.

Robust umsetzen

Telefon. Dauern um etwa 20 % kürzen, Distanzen halbieren.

Ohne Bewegung. Nur Opazität 0 → 1 in 300 ms, ohne Translation und ohne Versatz.

Barrierefrei. Ohne JS muss aller Text sichtbar sein. Den verborgenen Ausgangszustand deshalb nur über eine Klasse am html setzen. Geteilte Zeilen bekommen ein aria-label am Elternelement (SplitText aria: 'auto').

Code

<!DOCTYPE html>
<!--
  Animationsstil „Editorial langsam“
  Fundus · Animationsstile · https://cecodesign.de/lab/fundus/animationsstile/token-editorial-langsam/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animationsstil „Editorial langsam“ · 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: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
@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; }

:root {
  /* Die Token des Musters */
  --ease-editorial: cubic-bezier(0.16, 1, 0.3, 1);   /* expo.out */
  --dur-reveal: 1.2s;
  --dur-reveal-lg: 1.6s;
  --stagger-line: 0.1s;

  --grund: #eeebe4; --papier: #f6f4ef; --text: #17161a; --leise: rgba(23, 22, 26, .64); --linie: rgba(23, 22, 26, .16);
  --akzent: #b4462c;
}
* { 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 { background: var(--grund); }
img { display: block; max-width: 100%; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }

/* Bühne */
.buehne { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(20px, 3vw, 36px); padding: clamp(20px, 3.4vw, 44px); }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px 20px; flex-wrap: wrap; padding-bottom: 14px; position: relative; }
.kopf .marke { color: var(--text); }
.kopf .marke span { color: var(--leise); }
.haarlinie { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--linie); transform-origin: 0 50%; }

.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background-color .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: rgba(23, 22, 26, .36); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--papier); border-color: var(--text); }
.modi button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }

.satzspiegel { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, .85fr); gap: clamp(24px, 5vw, 72px); align-items: end; }
.spalte { display: grid; gap: clamp(18px, 2.4vw, 28px); align-content: end; }
.rubrik { display: flex; gap: 14px; align-items: center; }
.rubrik i { font-style: normal; color: var(--akzent); }
.titel { font: 400 clamp(46px, 7.4vw, 112px)/.94 'Instrument Serif', Georgia, serif; letter-spacing: -.025em; max-width: 10.5ch; }
.titel em { font-style: italic; color: var(--akzent); padding-right: .05em; }
.vorspann { font-size: clamp(15px, 1.35vw, 18px); line-height: 1.5; color: var(--leise); max-width: 42ch; }
.daten { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 6px 36px; padding-top: 16px; position: relative; }
.daten .haarlinie { top: 0; bottom: auto; }
.daten dt { color: var(--leise); }
.daten dd { color: var(--text); margin-top: 6px; }

.bild { position: relative; overflow: hidden; aspect-ratio: 4 / 5; max-height: min(64svh, 640px); width: 100%; justify-self: end; background: #dcd8cf; }
.bild img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--dur-reveal) var(--ease-editorial); }
.bild figcaption { position: absolute; left: 12px; bottom: 12px; color: rgba(255, 255, 255, .9); background: rgba(23, 22, 26, .55); padding: 6px 9px; border-radius: 2px; }
@media (hover: hover) { .bild:hover img { transform: scale(1.04) !important; } }

.fuss { display: flex; justify-content: flex-start; align-items: center; gap: 12px 24px; flex-wrap: wrap; }
.werte { flex: 1 1 320px; display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center; }
.werte b { color: var(--text); font-weight: 500; }
.kurvenbild { width: 54px; height: 32px; overflow: visible; flex: none; }
.kurvenbild path { fill: none; stroke: var(--text); stroke-width: 1.25; vector-effect: non-scaling-stroke; }
.kurvenbild .achse { stroke: var(--linie); }
.kurvenbild circle { fill: var(--akzent); }

/* Nur mit JS: vor dem Teilen unsichtbar, ohne JS bleibt alles sichtbar */
.js [data-reveal], .js [data-bild] { visibility: hidden; }
.zeile-mask { padding-block: .08em; margin-block: -.08em; }

/* Weiter unten: Arbeiten beim Scrollen */
.arbeiten { padding: clamp(72px, 14vh, 160px) clamp(20px, 3.4vw, 44px) clamp(80px, 20vh, 220px); }
.arbeiten-kopf { display: flex; justify-content: space-between; align-items: end; gap: 20px; flex-wrap: wrap; margin-bottom: clamp(28px, 4vw, 48px); }
.arbeiten h2 { font: 400 clamp(36px, 5vw, 72px)/.98 'Instrument Serif', Georgia, serif; letter-spacing: -.02em; max-width: 11ch; }
.arbeiten h2 em { font-style: italic; color: var(--akzent); }
.raster { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(16px, 2.4vw, 32px); }
.karte .bild { aspect-ratio: 3 / 2; max-height: none; }
.karte h3 { font: 400 clamp(22px, 2.2vw, 30px)/1.1 'Instrument Serif', Georgia, serif; margin-top: 14px; }
.karte p { margin-top: 8px; }

/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .arbeiten { display: none; }

@media (max-width: 760px) {
  .satzspiegel { grid-template-columns: 1fr; align-items: start; }
  .bild { justify-self: stretch; aspect-ratio: 4 / 3; max-height: none; }
  .bild figcaption { display: none; }
  .raster { grid-template-columns: 1fr; }
  .daten { gap: 6px 22px; }
  .buehne { grid-template-rows: auto auto auto; }
}
@media (hover: none) { .bild img { transition: none; } }
:root.reduziert .bild img { transition: none; }
</style>
</head>
<body>
<section class="buehne" aria-label="Fallstudie Leerstelle">
  <header class="kopf">
    <p class="mono marke">Studio Halde <span>· Arbeiten 2026</span></p>
    <div class="modi mono" role="group" aria-label="Variante">
      <button type="button" data-modus="maske" aria-pressed="true">Maske</button>
      <button type="button" data-modus="unschaerfe" aria-pressed="false">Unschärfe</button>
      <button type="button" data-modus="linie" aria-pressed="false">Kurve .19</button>
    </div>
    <span class="haarlinie" data-linie></span>
  </header>

  <div class="satzspiegel">
    <div class="spalte">
      <p class="mono rubrik" data-reveal data-art="klein"><i>N° 07</i> Editorial Design · Fallstudie</p>
      <h1 class="titel" data-reveal data-art="gross">Ein Magazin, das sich <em>Zeit</em> nimmt.</h1>
      <p class="vorspann" data-reveal data-art="text">Für Leerstelle haben wir Raster, Schrift und Rhythmus neu gedacht. 64 Seiten über Dinge und Räume, gesetzt mit viel Luft und wenigen, klaren Gesten.</p>
      <dl class="daten mono" data-daten>
        <span class="haarlinie" data-linie></span>
        <div><dt>Kunde</dt><dd>Leerstelle Verlag</dd></div>
        <div><dt>Umfang</dt><dd>64 Seiten</dd></div>
        <div><dt>Jahr</dt><dd>2026</dd></div>
      </dl>
    </div>
    <figure class="bild" data-bild>
      <img src="bilder/magazin-doppelseite-hoch.jpg" alt="Aufgeschlagenes Magazin: Doppelseite „Die Ruhe der Dinge“ mit Keramikfoto auf Terrakotta und zweispaltigem Text" width="800" height="1000">
      <figcaption class="mono">Leerstelle, Ausgabe 01 · Die Ruhe der Dinge</figcaption>
    </figure>
  </div>

  <footer class="fuss">
    <svg class="kurvenbild" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
      <path class="achse" d="M0 1 H1"/>
      <path data-kurve d="M0 1 C0.16 0 0.3 0 1 0"/>
      <circle data-punkt cx="0" cy="1" r=".07"/>
    </svg>
    <p class="werte mono" data-werte aria-live="polite"></p>
  </footer>
</section>

<section class="arbeiten" aria-label="Weitere Arbeiten">
  <div class="arbeiten-kopf">
    <h2 data-reveal data-art="gross" data-scroll>Weitere Arbeiten, in <em>Ruhe</em> erzählt.</h2>
    <p class="mono" data-reveal data-art="klein" data-scroll>Erscheint beim Scrollen · start top 85 %</p>
  </div>
  <div class="raster">
    <article class="karte">
      <figure class="bild" data-bild data-scroll><img src="bilder/marke-eselsohr.jpg" alt="Wortbildmarke Eselsohr: Blatt mit rosa Eselsohr neben kursivem Schriftzug, Buchhandlung und Lesecafé, auf Anthrazit" width="1200" height="800" loading="lazy"></figure>
      <h3 data-reveal data-art="text" data-scroll>Eselsohr</h3>
      <p class="mono" data-reveal data-art="klein" data-scroll>Erscheinungsbild · 2025</p>
    </article>
    <article class="karte">
      <figure class="bild" data-bild data-scroll><img src="bilder/fenster-licht.jpg" alt="Schmale Holzbank vor salbeigrüner Wand, darauf eine dunkle Vase mit kahlen Zweigen im schrägen Fensterlicht" width="1200" height="800" loading="lazy"></figure>
      <h3 data-reveal data-art="text" data-scroll>Haus Ahorn</h3>
      <p class="mono" data-reveal data-art="klein" data-scroll>Bildsprache und Website · 2024</p>
    </article>
    <article class="karte">
      <figure class="bild" data-bild data-scroll><img src="bilder/stoff-stapel.jpg" alt="Stapel gefalteter Leinentücher in Weiß, Rosé, Senf und Beere auf einem hellen Holztisch" width="1200" height="800" loading="lazy"></figure>
      <h3 data-reveal data-art="text" data-scroll>Leinenwerk</h3>
      <p class="mono" data-reveal data-art="klein" data-scroll>Katalog und Bildsprache · 2024</p>
    </article>
  </div>
</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/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js"></script>
<script>
(() => {
  gsap.registerPlugin(SplitText, ScrollTrigger, CustomEase);
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  // Mobil: Dauern um 20 % kürzen, Distanzen halbieren
  const mobil = () => matchMedia('(max-width: 760px)').matches;

  // Zwei gemessene Kurven: expo.out ≈ cubic-bezier(0.16, 1, 0.3, 1) und die Variante (0.19, 1, 0.22, 1)
  CustomEase.create('editorial', '0.16, 1, 0.3, 1');
  CustomEase.create('editorial19', '0.19, 1, 0.22, 1');

  // Skala 600 / 1000 / 1200 / 1600 ms, Zeilenversatz 100 ms
  const MODI = {
    maske: {
      kurve: 'editorial', css: 'cubic-bezier(0.16, 1, 0.3, 1)', bez: [0.16, 1, 0.3, 1],
      gross: 1.2, text: 1.0, klein: 1.0, bild: 1.6, linie: 1.2, versatz: 0.1,
      text_weg: 'yPercent 100 → 0 (Maske)', bild_weg: 'clip-path inset 100 % → 0, scale 1,1 → 1',
    },
    unschaerfe: {
      kurve: 'editorial', css: 'cubic-bezier(0.16, 1, 0.3, 1)', bez: [0.16, 1, 0.3, 1],
      gross: 1.2, text: 1.0, klein: 1.0, bild: 1.6, linie: 1.2, versatz: 0.1, y: 24, blur: 10,
      text_weg: 'blur 10 px → 0, y 24 px → 0, Opazität', bild_weg: 'blur 10 px → 0, scale 1,1 → 1, Opazität',
    },
    linie: {
      kurve: 'editorial19', css: 'cubic-bezier(0.19, 1, 0.22, 1)', bez: [0.19, 1, 0.22, 1],
      gross: 1.2, text: 1.0, klein: 1.0, bild: 1.2, linie: 0.7, versatz: 0.06,
      text_weg: 'yPercent 100 → 0 (Maske)', bild_weg: 'clip-path von links, scale 1,1 → 1',
    },
  };

  const buehne = document.querySelector('.buehne');
  const werte = document.querySelector('[data-werte]');
  const punkt = document.querySelector('[data-punkt]');
  const kurvePfad = document.querySelector('[data-kurve]');
  let modus = 'maske', teilungen = [], zeitleiste = null, scrollTweens = [];

  const faktor = () => (mobil() ? 0.8 : 1);
  const ms = s => Math.round(s * 1000 * faktor());

  function zeigeWerte(m) {
    kurvePfad.setAttribute('d', `M0 1 C${m.bez[0]} ${1 - m.bez[1]} ${m.bez[2]} ${1 - m.bez[3]} 1 0`);
    if (reduziert()) {
      werte.innerHTML = '<span>Reduzierte Bewegung: <b>nur Opazität, 300 ms, ohne Versatz</b></span>';
      return;
    }
    werte.innerHTML =
      `<span>Kurve <b>${m.css}</b></span>` +
      `<span>Titel <b>${ms(m.gross)} ms</b></span>` +
      `<span>Bild <b>${ms(m.bild)} ms</b></span>` +
      `<span>Linie <b>${ms(m.linie)} ms</b></span>` +
      `<span>Versatz <b>${Math.round(m.versatz * 1000)} ms</b></span>` +
      `<span>Text <b>${m.text_weg}${mobil() && m.y ? ' (mobil 12 px)' : ''}</b></span>` +
      `<span>Bild <b>${m.bild_weg}</b></span>`;
  }

  // Text teilen (Zeilen in Masken) bzw. für Unschärfe nur in Zeilen ohne Maske
  function teile(el, m) {
    const mitMaske = modus !== 'unschaerfe';
    const t = SplitText.create(el, { type: 'lines', mask: mitMaske ? 'lines' : undefined, linesClass: 'zeile', aria: 'auto' });
    teilungen.push(t);
    gsap.set(el, { visibility: 'visible' });
    return t.lines;
  }

  // Tween für einen Textblock in der aktuellen Variante
  function textTween(el, m, extra = {}) {
    const f = faktor();
    const dauer = m[el.dataset.art] * f;
    if (reduziert()) {
      gsap.set(el, { visibility: 'visible' });
      return gsap.from(el, { opacity: 0, duration: 0.3, ease: 'none', ...extra });
    }
    const zeilen = teile(el, m);
    if (modus === 'unschaerfe') {
      return gsap.from(zeilen, { opacity: 0, y: m.y * (mobil() ? 0.5 : 1), filter: `blur(${m.blur * (mobil() ? 0.5 : 1)}px)`, duration: dauer, stagger: m.versatz, ease: m.kurve, clearProps: 'filter', ...extra });
    }
    return gsap.from(zeilen, { yPercent: 100, duration: dauer, stagger: m.versatz, ease: m.kurve, ...extra });
  }

  function bildTween(fig, m, extra = {}) {
    const img = fig.querySelector('img');
    const dauer = m.bild * faktor();
    gsap.set(fig, { visibility: 'visible' });
    if (reduziert()) return gsap.from(fig, { opacity: 0, duration: 0.3, ease: 'none', ...extra });
    const tl = gsap.timeline(extra);
    if (modus === 'unschaerfe') {
      tl.fromTo(fig, { opacity: 0, filter: `blur(${m.blur * (mobil() ? 0.5 : 1)}px)` }, { opacity: 1, filter: 'blur(0px)', duration: dauer, ease: m.kurve, clearProps: 'filter' }, 0);
    } else {
      const start = modus === 'linie' ? 'inset(0% 100% 0% 0%)' : 'inset(100% 0% 0% 0%)';
      tl.fromTo(fig, { clipPath: start }, { clipPath: 'inset(0% 0% 0% 0%)', duration: dauer, ease: m.kurve }, 0);
    }
    tl.fromTo(img, { scale: 1.1 }, { scale: 1, duration: dauer, ease: m.kurve, clearProps: 'transform' }, 0);
    return tl;
  }

  function linieTween(el, m) {
    if (reduziert()) return gsap.from(el, { opacity: 0, duration: 0.3, ease: 'none' });
    return gsap.fromTo(el, { scaleX: 0 }, { scaleX: 1, duration: m.linie * faktor(), ease: m.kurve });
  }

  function aufraeumen() {
    if (zeitleiste) zeitleiste.kill();
    scrollTweens.forEach(t => { if (t.scrollTrigger) t.scrollTrigger.kill(); t.kill(); });
    scrollTweens = [];
    teilungen.forEach(t => t.revert());
    teilungen = [];
    const alle = document.querySelectorAll('[data-reveal], [data-bild], [data-bild] img, [data-linie], [data-daten] > div');
    gsap.killTweensOf(alle);
    gsap.set(alle, { clearProps: 'all' });
  }

  function aufbauen() {
    aufraeumen();
    const m = MODI[modus];
    zeigeWerte(m);
    const v = reduziert() ? 0 : 1;   // reduziert: kein Versatz

    const q = s => buehne.querySelector(s);
    const tl = gsap.timeline();
    tl.add(linieTween(q('.kopf [data-linie]'), m), 0);
    tl.add(bildTween(q('[data-bild]'), m), 0.1 * v);
    tl.add(textTween(q('[data-art="klein"]'), m), 0.2 * v);
    tl.add(textTween(q('.titel'), m), 0.3 * v);
    tl.add(textTween(q('.vorspann'), m), 0.7 * v);
    tl.add(linieTween(q('[data-daten] [data-linie]'), m), 0.8 * v);
    const daten = buehne.querySelectorAll('[data-daten] > div');
    tl.add(reduziert()
      ? gsap.from(daten, { opacity: 0, duration: 0.3, ease: 'none' })
      : gsap.from(daten, { opacity: 0, y: (mobil() ? 12 : 24), duration: m.klein * faktor(), stagger: m.versatz, ease: m.kurve }), 0.9 * v);
    // Kurvenpunkt läuft synchron zur Titelbewegung
    if (!reduziert()) {
      const p = { t: 0 };
      tl.fromTo(p, { t: 0 }, { t: 1, duration: m.gross * faktor(), ease: 'none', onUpdate() {
        const e = gsap.parseEase(m.kurve)(p.t);
        punkt.setAttribute('cx', p.t.toFixed(3)); punkt.setAttribute('cy', (1 - e).toFixed(3));
      } }, 0.3);
    } else { punkt.setAttribute('cx', 1); punkt.setAttribute('cy', 0); }
    zeitleiste = tl;

    scrollVorbereiten(m);
  }

  // Weiter unten: einmal beim Eintritt, start top 85 %
  function scrollVorbereiten(m) {
    document.querySelectorAll('.arbeiten [data-scroll]').forEach((el, i) => {
      const st = { scrollTrigger: { trigger: el.closest('.karte') || el, start: 'top 85%', once: true } };
      const versatz = reduziert() ? 0 : (el.closest('.karte') ? [...el.closest('.raster').children].indexOf(el.closest('.karte')) * 0.15 : 0);
      const d = { delay: versatz + (el.matches('h3') ? 0.2 : el.matches('p') && el.closest('.karte') ? 0.3 : 0) * (reduziert() ? 0 : 1) };
      const t = el.matches('[data-bild]') ? bildTween(el, m, { ...st, ...d }) : textTween(el, m, { ...st, ...d });
      scrollTweens.push(t);
    });
  }

  function setzeModus(neu) {
    modus = neu;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
  }
  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b) return;
    pause();
    setzeModus(b.dataset.modus);
    aufbauen();
  });

  // Abgang für die Schleife: kurz, mit In-Kurve, alles zugleich
  function verbergen() {
    const ziele = buehne.querySelectorAll('[data-reveal], [data-bild], [data-linie], [data-daten] > div');
    return gsap.to(ziele, { opacity: 0, duration: 0.45, ease: 'power2.in' });
  }

  let uhr = null;
  const reihe = ['maske', 'unschaerfe', 'linie'];
  function vorfuehren() {
    pause();
    let i = 0;
    setzeModus(reihe[0]);
    aufbauen();
    const takt = () => {
      uhr = setTimeout(() => {
        verbergen().then(() => {
          i = (i + 1) % reihe.length;
          setzeModus(reihe[i]);
          aufbauen();
          takt();
        });
      }, 3600);
    };
    takt();
  }
  function pause() { clearTimeout(uhr); uhr = null; }
  function neustart() { pause(); scrollTo(0, 0); aufbauen(); }

  document.fonts.ready.then(aufbauen);   // erst nach den Schriften teilen, sonst falsche Umbrüche

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