Zum Inhalt
← Schrift-Animationen

Scroll-gescrubbte Zeichen-Choreografie

Eine Kapitelüberschrift setzt sich Zeichen für Zeichen aus der Unschärfe zusammen, solange gescrollt wird, und löst sich am Ende des Kapitels wieder auf.

Eine große Überschrift wird in Zeichen zerlegt. Jedes Zeichen startet außerhalb der Ruhelage: unscharf und unsichtbar, auseinandergezogen, in 3D gekippt oder gestreckt. Der Weg in die Ruhelage hängt am Scrollfortschritt (scrub). Scrollt man zurück, löst sich die Zeile wieder auf. Ein Stagger von der Mitte oder vom Anfang gibt der Bewegung eine Richtung.

Der Nutzer setzt die Überschrift durch sein eigenes Scrollen zusammen. Das gibt ihm Kontrolle über das Tempo und macht aus einer Zwischenüberschrift einen kleinen Auftritt. Weil nichts von selbst abläuft, wirkt der Effekt auch in ruhigen Layouts nicht aufdringlich.

Wann einsetzen

Eine Kapitelüberschrift oder ein einzelnes Statement zwischen Bildern und Projekten. Intros einzelner Sektionen auf Portfolio- und Projektseiten.

Wann nicht

Nicht auf jeder Überschrift wiederholen. Nicht für Inhalte, die ohne Scrollen lesbar sein müssen, etwa Navigation, Preise oder Formulartitel. Nicht für lange Sätze über rund 40 Zeichen.

Varianten

  • Unschärfe: opacity 0 und blur(10px) auf scharf, am Ende wieder aus (belegt bei Fantasy)
  • Laufweite: Zeichen starten gespreizt und ziehen sich zur Zeilenmitte zusammen
  • Kippen: 3D-Flip pro Zeichen mit rotateX -90° und transformOrigin '50% 50% -0.5em'
  • Streuung aus Zufallspositionen
  • Streckung und Stauchung über scaleY und yPercent
  • Geteilter Text: obere und untere Hälfte versetzt, sie treffen sich beim Scrollen

Umsetzung

SplitText.create(el, { type: 'lines,words,chars', autoSplit: true }). Eine Timeline, normiert auf 1.25: fromTo(chars, { opacity: 0, filter: 'blur(10px)' }, { opacity: 1, filter: 'blur(0px)', duration: 0.25, ease: 'none', stagger: { amount: 0.25 } }, 0), dann to(chars, { opacity: 0, filter: 'blur(10px)', duration: 0.25, ease: 'power2.in', stagger: { amount: 0.25 } }, 0.75). Kopplung per ScrollTrigger { trigger: sektion, start: 'top bottom', end: 'bottom bottom', scrub: true }. Eine hohe Sektion mit sticky Bühne hält die Überschrift im Bild. Für 3D-Varianten perspective: 1000px am Elternelement oder transformPerspective setzen. Erst nach document.fonts.ready teilen, sonst stimmen die Umbrüche nicht. Fallstrick: viele Zeichen mit 3D-Transforms oder Blur erzeugen viele Ebenen, darum nur kurze Überschriften verwenden.

Robust umsetzen

Telefon. Nach Wörtern statt Zeichen teilen, das halbiert die Ebenen und bleibt flüssig. Alternativ einmalig beim Eintritt auslösen statt scrubben.

Ohne Bewegung. Text steht in Ruhelage, keine Teilung und keine Scroll-Kopplung. Die Sektion wird nicht gepinnt.

Barrierefrei. Die Überschrift bleibt ein echtes h2. SplitText setzt aria-label auf das Element und blendet die Teil-Spans für Screenreader aus. Der Text steht von Anfang an im DOM, auch wenn er optisch noch unsichtbar ist. Die Ruhephase muss lang genug sein, damit man die Zeile in Ruhe lesen kann.

Code

<!DOCTYPE html>
<!--
  Scroll-gescrubbte Zeichen-Choreografie
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/scroll-char-choreo/
  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>Scroll-gescrubbte Zeichen-Choreografie · 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: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; 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; }

/* Welt: Rezept „Architektur: ruhiges Raster“ – Kessling Rohde Architekten, Bielefeld (erfunden) */
:root {
  --grund: #ffffff; --flaeche: #e1e3e3; --text: #231f20; --leise: #76767a; --zart: #c5c7c7; --dunkel: #1f2323;
  --fokus: #0022ff;
  --kurve: cubic-bezier(0.1, 0.6, 0.4, 1);   /* UI-Token des Rezepts, 250 ms */
  --rand: clamp(16px, 3vw, 40px);
  --kopf: 64px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Hanken Grotesk', system-ui, sans-serif; font-weight: 400; font-size: 100%; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); padding-top: var(--kopf); font-size: 1.0625rem; line-height: 1.45; letter-spacing: -.012em; overflow-x: clip; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--fokus); outline-offset: 3px; }
.meta { font-size: .8rem; line-height: 1.35; color: var(--leise); letter-spacing: -.005em; }
.meta b { font-weight: 400; color: var(--text); }
.raster { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); column-gap: 20px; padding-inline: var(--rand); }

/* Kopf */
.kopf { position: fixed; top: 0; left: 0; right: 0; z-index: 10; height: var(--kopf); background: var(--grund); align-items: center; }
.marke { grid-column: 1 / 5; font-size: 1rem; letter-spacing: -.02em; white-space: nowrap; }
.marke span { color: var(--leise); }
.nav { grid-column: 7 / 13; display: flex; justify-content: space-between; gap: 16px; list-style: none; font-size: 1rem; letter-spacing: -.015em; }
.nav a { transition: color .25s var(--kurve); }
.nav a:hover, .nav a[aria-current] { color: var(--leise); }
.menue { display: none; grid-column: 9 / 13; justify-self: end; font: inherit; background: none; border: 0; color: inherit; font-size: 1rem; }

/* Intro */
.intro { padding-block: clamp(36px, 8vh, 96px) clamp(28px, 5vh, 56px); row-gap: clamp(28px, 5vh, 56px); }
.intro h1 { grid-column: 1 / 9; font-size: clamp(1.953rem, 1.2rem + 2.4vw, 3.052rem); font-weight: 450; line-height: 1.1; letter-spacing: -.02em; text-wrap: balance; }
.intro h1 em { font-style: italic; font-weight: 400; }
.intro .seit { grid-column: 10 / 13; align-self: end; }
.tafel { margin: 0; }
.tafel svg { display: block; width: 100%; height: auto; background: #f4f5f5; }
.tafel figcaption { margin-top: 10px; display: flex; justify-content: space-between; gap: 12px; }
.tafeln { grid-column: 5 / 13; display: grid; grid-template-columns: 5fr 8fr; gap: 20px; align-items: end; margin-bottom: clamp(24px, 5vh, 56px); }
.hinweis-scroll { grid-column: 1 / 13; display: flex; align-items: center; gap: 12px; }
.hinweis-scroll::after { content: ''; flex: 1; height: 1px; background: var(--flaeche); }

/* Kapitel mit der gescrubbten Überschrift */
.kapitel { position: relative; height: 360vh; }
.buehne { position: sticky; top: 0; height: 100svh; padding-top: var(--kopf); display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto auto; overflow: clip; }
.buehne-kopf { padding-top: clamp(14px, 3vh, 28px); row-gap: 14px; align-items: start; }
.kap-nr { grid-column: 1 / 3; }
.kap-titel { grid-column: 3 / 6; }
.kap-text { grid-column: 8 / 13; font-size: 1.0625rem; max-width: 50ch; }
.titel-zone { align-content: center; padding-block: clamp(16px, 4vh, 48px); }
.titel { grid-column: 1 / 13; font-size: clamp(3rem, .6rem + 9.4vw, 10.6rem); font-weight: 430; line-height: .94; letter-spacing: -.04em; padding-bottom: .06em; }
.titel .zeile { white-space: nowrap; }
.titel .zeichen, .titel .wort { display: inline-block; will-change: transform, opacity; }
.js:not(.reduziert) .titel { visibility: hidden; }
.ansicht { position: relative; padding-inline: var(--rand); }
.ansicht svg { display: block; width: 100%; height: clamp(110px, 24vh, 280px); }
.ansicht .meta { position: absolute; left: var(--rand); top: 0; }
@media (max-height: 760px) { .ansicht .meta { display: none; } }

/* Bedienleiste der Demo (Fundus-Chrom, bewusst in Geist Mono) */
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.leiste { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 24px; padding: 12px var(--rand) 14px; border-top: 1px solid var(--flaeche); background: var(--grund); }
.varianten { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
.varianten span { margin-right: 8px; }
.varianten button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--flaeche); border-radius: 999px; height: 28px; padding: 0 11px; cursor: pointer; transition: color .25s var(--kurve), background-color .25s var(--kurve), border-color .25s var(--kurve); }
.varianten button:hover { color: var(--text); border-color: var(--zart); }
.varianten button[aria-pressed='true'] { background: var(--text); border-color: var(--text); color: var(--grund); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.werte b { font-weight: 500; color: var(--text); }
.fortschritt { display: inline-flex; align-items: center; gap: 8px; }
.fortschritt i { position: relative; display: block; width: 64px; height: 1px; background: var(--flaeche); }
.fortschritt i::after { content: ''; position: absolute; inset: 0; background: var(--text); transform-origin: left; transform: scaleX(var(--p, 0)); }

/* Werkverzeichnis */
.werk { padding-block: clamp(80px, 14vh, 160px) clamp(80px, 12vh, 140px); row-gap: 28px; }
.werk h2 { grid-column: 1 / 5; font-size: 1.563rem; font-weight: 450; letter-spacing: -.02em; line-height: 1.15; }
.werk ol { grid-column: 5 / 13; list-style: none; }
.werk li { display: grid; grid-template-columns: 3fr 2fr 1fr; gap: 20px; padding: 14px 0 15px; border-top: 1px solid var(--flaeche); font-size: 1.0625rem; }
.werk li:last-child { border-bottom: 1px solid var(--flaeche); }
.werk li span:last-child { text-align: right; color: var(--leise); }
.werk li span:nth-child(2) { color: var(--leise); }

/* Visitenkarte */
.fuss { background: var(--dunkel); color: #f2f3f3; padding-block: 56px 40px; row-gap: 28px; }
.fuss .meta { color: #9a9c9e; }
.fuss .marke { grid-column: 1 / 5; }
.fuss address { grid-column: 5 / 9; font-style: normal; }
.fuss .kontakt { grid-column: 9 / 13; }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; padding-top: 0; }
.vorschau .intro, .vorschau .werk, .vorschau .fuss { display: none; }
.vorschau .kapitel { height: 100svh; }

/* Reduzierte Bewegung: Text in Ruhelage, keine Scroll-Kopplung */
.reduziert .kapitel { height: auto; }
.reduziert .buehne { position: relative; height: auto; min-height: 100svh; }
.reduziert .varianten { display: none; }
.reduziert .titel { visibility: visible; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (max-width: 860px) {
  .nav { display: none; }
  .menue { display: block; }
  .marke { grid-column: 1 / 9; }
  .intro h1 { grid-column: 1 / 13; }
  .intro .seit { grid-column: 1 / 13; }
  .tafeln { grid-column: 1 / 13; grid-template-columns: 1fr; }
  .kap-nr { grid-column: 1 / 4; }
  .kap-titel { grid-column: 4 / 13; }
  .kap-text { grid-column: 1 / 13; }
  .werk h2, .werk ol { grid-column: 1 / 13; }
  .werk li { grid-template-columns: 1fr auto; }
  .werk li span:nth-child(2) { display: none; }
  .fuss .marke, .fuss address, .fuss .kontakt { grid-column: 1 / 13; }
  .ansicht .meta { display: none; }
}
@media (max-width: 1200px) { .werte .weg { display: none; } }
@media (max-width: 600px) {
  .kap-text { font-size: 1rem; }
  .ansicht svg { height: 150px; }
  .leiste { padding-block: 10px 12px; }
}
@media (hover: none) { .varianten button { height: 34px; padding: 0 13px; } }
</style>
</head>
<body>
<header class="kopf raster">
  <a class="marke" href="#">Kessling Rohde <span>Architekten</span></a>
  <ul class="nav">
    <li><a href="#">Büro</a></li>
    <li><a href="#" aria-current="page">Projekte</a></li>
    <li><a href="#">Team</a></li>
    <li><a href="#">Aktuelles</a></li>
    <li><a href="#">Kontakt</a></li>
  </ul>
  <button class="menue" type="button">Menü</button>
</header>

<main>
  <section class="intro raster" aria-label="Büro">
    <h1>Wir planen Wohn- und Bildungsbauten in Ostwestfalen und bauen <em>weiter</em>, was schon da ist.</h1>
    <p class="seit meta"><b>Kessling Rohde Architekten</b><br>Partnerschaft seit 2009<br>Bielefeld-Mitte, 14 Mitarbeitende</p>
    <p class="hinweis-scroll mono">Scrollen baut die Überschrift</p>
  </section>

  <section class="kapitel" data-kapitel aria-labelledby="kap-titel">
    <div class="buehne">
      <div class="buehne-kopf raster">
        <p class="kap-nr meta">02 / 05</p>
        <p class="kap-titel meta"><b>Bauen im Bestand</b><br>Shedhalle Brackwede, 1924 / 2026</p>
        <p class="kap-text">Die alte Shedhalle bleibt stehen. Wir öffnen die Sheds nach Norden, dämmen von innen und setzen 34 Wohnungen in das vorhandene Tragwerk.</p>
      </div>
      <div class="titel-zone raster">
        <h2 class="titel" id="kap-titel" data-titel>Weiterbauen statt abreißen.</h2>
      </div>
      <figure class="ansicht">
        <svg data-zeichnung="shedhalle" viewBox="0 0 1600 300" preserveAspectRatio="xMidYMax slice" role="img" aria-label="Ansicht West der Shedhalle mit neuem Kopfbau"></svg>
        <figcaption class="meta">Ansicht West 1:200 · Bestand und Kopfbau</figcaption>
      </figure>
      <div class="leiste">
        <div class="varianten mono" role="group" aria-label="Variante">
          <span>Variante</span>
          <button type="button" data-variante="unschaerfe" aria-pressed="true">Unschärfe</button>
          <button type="button" data-variante="laufweite" aria-pressed="false">Laufweite</button>
          <button type="button" data-variante="kippen" aria-pressed="false">Kippen</button>
        </div>
        <p class="werte mono" data-werte></p>
      </div>
    </div>
  </section>

  <section class="werk raster" aria-label="Werkverzeichnis">
    <h2>Werkverzeichnis<br><span class="meta">Auswahl 2019 bis 2026</span></h2>
    <div class="tafeln">
      <figure class="tafel tafel-a">
      <svg data-zeichnung="grundriss" viewBox="0 0 400 300" role="img" aria-label="Grundriss Regelgeschoss, Wohnhof Johannisbach"></svg>
      <figcaption class="meta"><span>Wohnhof Johannisbach • Bielefeld • 2025</span><span>Grundriss 1:500</span></figcaption>
      </figure>
      <figure class="tafel tafel-b">
      <svg data-zeichnung="wohnhof" viewBox="0 0 700 300" role="img" aria-label="Ansicht Ost, Wohnhof Johannisbach"></svg>
      <figcaption class="meta"><span>Wohnhof Johannisbach • Bielefeld • 2025</span><span>Ansicht Ost 1:200</span></figcaption>
      </figure>
    </div>
    <ol>
      <li><span>Shedhalle Brackwede</span><span>Wohnen im Bestand</span><span>2026</span></li>
      <li><span>Wohnhof Johannisbach</span><span>Geförderter Wohnungsbau</span><span>2025</span></li>
      <li><span>Kita am Sieker Feld</span><span>Bildung</span><span>2024</span></li>
      <li><span>Gesamtschule Heepen, Erweiterung</span><span>Bildung</span><span>2022</span></li>
      <li><span>Hofhaus Schildesche</span><span>Wohnen</span><span>2019</span></li>
    </ol>
  </section>
</main>

<footer class="fuss raster">
  <p class="marke">Kessling Rohde <span class="meta">Architekten</span></p>
  <address class="meta"><b style="color:#f2f3f3">Büro</b><br>Hagenkamp 12<br>33602 Bielefeld</address>
  <p class="kontakt meta"><b style="color:#f2f3f3">Kontakt</b><br>0521 52 08 14-0<br>buero@kessling-rohde.de<br>Architektenkammer NRW</p>
</footer>

<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>
/* Zeichnungen: eigene SVG-Pläne statt Fotos (achromatisch, Linie 1 px) */
(() => {
  const L = '#231f20', F = '#e1e3e3', Z = '#c5c7c7';
  const r = (x, y, w, h, a = '') => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${a}/>`;
  const l = (x1, y1, x2, y2, a = '') => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${a}/>`;
  const S = `stroke="${L}" stroke-width="1" vector-effect="non-scaling-stroke"`;

  function fenster(x, y, w, h, sp = 2, ze = 3) {
    let s = r(x, y, w, h, `fill="#fff" ${S}`);
    for (let i = 1; i < sp; i++) s += l(x + w * i / sp, y, x + w * i / sp, y + h, `${S} stroke-opacity=".55"`);
    for (let j = 1; j < ze; j++) s += l(x, y + h * j / ze, x + w, y + h * j / ze, `${S} stroke-opacity=".55"`);
    return s;
  }

  const zeichnungen = {
    shedhalle() {
      const g = 286, traufe = 196, x0 = 40, x1 = 1210, n = 7, w = (x1 - x0) / n, hoch = 74;
      let s = `<defs><pattern id="ziegel" width="12" height="7" patternUnits="userSpaceOnUse"><path d="M0 6.5H12M6 0V3" stroke="${L}" stroke-opacity=".16" stroke-width="1" fill="none"/></pattern></defs>`;
      // Bestand: Sägezahndach
      let d = `M${x0},${g} L${x0},${traufe}`;
      for (let k = 0; k < n; k++) { const xs = x0 + k * w; d += ` L${xs},${traufe - hoch} L${xs + w},${traufe}`; }
      d += ` L${x1},${g} Z`;
      s += `<path d="${d}" fill="${F}"/>`;
      s += r(x0, traufe, x1 - x0, g - traufe, 'fill="url(#ziegel)"');
      for (let k = 0; k < n; k++) {
        const xs = x0 + k * w;
        s += `<path d="M${xs},${traufe} L${xs},${traufe - hoch} L${xs + w},${traufe}" fill="#eef0f0" ${S}/>`;
        s += r(xs - 1.5, traufe - hoch + 6, 3, hoch - 10, `fill="${L}" fill-opacity=".75"`);   // Nordlicht
        s += fenster(xs + w * .2, traufe + 24, w * .22, 62, 2, 4) + fenster(xs + w * .58, traufe + 24, w * .22, 62, 2, 4);
      }
      s += `<path d="${d}" fill="none" ${S}/>` + l(x0, traufe, x1, traufe, `${S} stroke-opacity=".4"`);
      // Neuer Kopfbau in Holz
      const k0 = 1250, k1 = 1500, kt = 78;
      s += r(k0, kt, k1 - k0, g - kt, `fill="#f4f5f5" ${S}`);
      for (let x = k0 + 8; x < k1; x += 8) s += l(x, kt, x, g, `stroke="${L}" stroke-opacity=".14" stroke-width="1" vector-effect="non-scaling-stroke"`);
      for (let e = 0; e < 4; e++) { const y = kt + 18 + e * 50; s += r(k0 + 22, y, k1 - k0 - 44, 26, `fill="#fff" ${S}`); s += l(k0 + 14, y + 38, k1 - 14, y + 38, `${S} stroke-opacity=".5"`); }
      s += r(k0 - 6, kt - 6, k1 - k0 + 12, 6, `fill="${L}"`);
      // Fuge zwischen Alt und Neu
      s += r(x1, 150, k0 - x1, g - 150, `fill="#fff" ${S} stroke-dasharray="3 3"`);
      // Bäume und Maßstab
      [[1546, 54], [1578, 40], [-6, 46]].forEach(([x, rr]) => { s += `<circle cx="${x}" cy="${g - rr - 30}" r="${rr}" fill="none" ${S} stroke-opacity=".6"/>` + l(x, g - 30, x, g, S); });
      [[300, 0], [318, 1], [1180, 0]].forEach(([x]) => { s += `<circle cx="${x}" cy="${g - 32}" r="4" fill="${L}"/>` + r(x - 3.5, g - 27, 7, 27, `fill="${L}"`); });
      s += l(-200, g, 1800, g, `stroke="${L}" stroke-width="1.5" vector-effect="non-scaling-stroke"`);
      s += `<g font-family="Hanken Grotesk, sans-serif" font-size="11" fill="${L}" fill-opacity=".7"><text x="${k1 + 10}" y="${kt + 4}">+12,60</text><text x="${x1 + 4}" y="${traufe - hoch - 6}">+7,40</text><text x="${k1 + 10}" y="${g - 4}">±0,00</text></g>`;
      return s;
    },
    grundriss() {
      let s = r(0, 0, 400, 300, `fill="#f4f5f5"`);
      // U-förmiger Hof
      const W = 'stroke="#231f20" stroke-width="6" fill="none" stroke-linejoin="miter"';
      s += `<path d="M60 60 H340 V250 H260 V130 H140 V250 H60 Z" fill="${F}"/>`;
      s += `<path d="M60 60 H340 V250 H260 V130 H140 V250 H60 Z" ${W}/>`;
      [[60, 95, 140, 95], [260, 95, 340, 95], [200, 60, 200, 130], [120, 60, 120, 130], [280, 60, 280, 130], [60, 175, 140, 175], [260, 175, 340, 175], [60, 215, 140, 215], [260, 215, 340, 215]]
        .forEach(([a, b, c, e]) => { s += l(a, b, c, e, 'stroke="#231f20" stroke-width="2"'); });
      [[180, 72], [92, 140], [292, 140]].forEach(([x, y]) => { s += r(x, y, 26, 18, `fill="#fff" stroke="${L}" stroke-width="1"`); for (let i = 1; i < 6; i++) s += l(x + i * 4.3, y, x + i * 4.3, y + 18, `stroke="${L}" stroke-width=".8"`); });
      s += `<circle cx="200" cy="200" r="22" fill="none" stroke="${L}" stroke-width="1" stroke-dasharray="2 3"/>`;
      s += `<path d="M352 270 l8 -18 l8 18 z" fill="${L}"/><text x="356" y="290" font-family="Hanken Grotesk, sans-serif" font-size="10" fill="${L}">N</text>`;
      return s;
    },
    wohnhof() {
      let s = r(0, 0, 700, 300, `fill="#f4f5f5"`);
      const g = 270, x0 = 70, x1 = 630, top = 60;
      s += r(x0, top, x1 - x0, g - top, `fill="${F}" ${S}`);
      const sp = 8, ze = 4, bw = (x1 - x0) / sp, bh = (g - top - 20) / ze;
      for (let i = 0; i < sp; i++) for (let j = 0; j < ze; j++) {
        const x = x0 + i * bw, y = top + 14 + j * bh;
        const loggia = (i === 2 || i === 5);
        s += loggia ? r(x + 8, y + 6, bw - 16, bh - 12, `fill="#cfd2d2" ${S}`) + l(x + 8, y + bh * .62, x + bw - 8, y + bh * .62, S)
                    : fenster(x + bw * .28, y + 8, bw * .44, bh - 18, 1, 2);
      }
      s += r(x0 - 4, top - 6, x1 - x0 + 8, 6, `fill="${L}"`);
      s += `<circle cx="40" cy="214" r="26" fill="none" ${S} stroke-opacity=".6"/>` + l(40, 240, 40, g, S);
      s += `<circle cx="664" cy="206" r="32" fill="none" ${S} stroke-opacity=".6"/>` + l(664, 238, 664, g, S);
      s += l(0, g, 700, g, `stroke="${L}" stroke-width="1.5"`);
      return s;
    },
  };
  document.querySelectorAll('[data-zeichnung]').forEach(svg => { svg.innerHTML = zeichnungen[svg.dataset.zeichnung](); });
  // Schmal: rechten Ausschnitt mit dem neuen Kopfbau zeigen
  if (matchMedia('(max-width: 600px)').matches) document.querySelector('[data-zeichnung=shedhalle]').setAttribute('preserveAspectRatio', 'xMaxYMax slice');
})();
</script>
<script>
(() => {
  gsap.registerPlugin(ScrollTrigger, SplitText);
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const touch = matchMedia('(hover: none)').matches;   // Telefon: Wörter statt Zeichen
  const kapitel = document.querySelector('[data-kapitel]');
  const titel = document.querySelector('[data-titel]');
  const werteEl = document.querySelector('[data-werte]');

  // Zeitleiste normiert auf Fantasy „charsInOut“: Teil-Tween 0,25, Stagger-Gesamt 0,25,
  // rein linear (gescrubbt), raus ab Position 0,75 mit power2.in.
  const T = { teil: 0.25, stagger: 0.25, raus: 0.75 };
  const SPREIZ = 0.9;   // Laufweite: Abstand zur Zeilenmitte × 1,9 im Startzustand

  let abstand = new Map();
  const vonMitte = (i, el) => abstand.get(el) || 0;

  const VARIANTEN = {
    unschaerfe: {
      von: 'start',
      rein: { opacity: 0, filter: 'blur(10px)' },
      ruhe: { opacity: 1, filter: 'blur(0px)' },
      raus: { opacity: 0, filter: 'blur(10px)' },
      text: 'Start <b>blur 10 px · opacity 0</b>',
    },
    laufweite: {
      von: 'center',
      rein: { opacity: 0, x: vonMitte },
      ruhe: { opacity: 1, x: 0 },
      raus: { opacity: 0, x: vonMitte },
      text: `Start <b>x = Abstand zur Mitte × ${SPREIZ}</b>`,
    },
    kippen: {
      von: 'center',
      rein: { opacity: 0, rotateX: -90, yPercent: 50, transformOrigin: '50% 50% -0.5em', transformPerspective: 1000 },
      ruhe: { opacity: 1, rotateX: 0, yPercent: 0 },
      raus: { opacity: 0, rotateX: 90, yPercent: -50 },
      text: 'Start <b>rotateX −90° · yPercent 50</b>',
    },
  };

  let variante = 'unschaerfe', split = null, tl = null, st = null, vor = null, teile = [];

  function zeigeWerte() {
    if (reduziert()) { werteEl.innerHTML = '<span>Reduzierte Bewegung: <b>Text in Ruhelage, keine Scroll-Kopplung</b></span>'; return; }
    const v = VARIANTEN[variante];
    werteEl.innerHTML = `<span><b>scrub</b> · linear, raus <b>power2.in</b></span><span>Teil <b>0.25</b> · Stagger <b>0.25 ${v.von}</b> · raus ab <b>0.75</b></span><span class="weg">${v.text}</span><span class="fortschritt">Scroll <i></i><b data-prozent>0 %</b></span>`;
  }
  function anzeigen(p) {
    const b = werteEl.querySelector('.fortschritt');
    if (!b) return;
    b.style.setProperty('--p', p.toFixed(3));
    b.querySelector('[data-prozent]').textContent = Math.round(p * 100) + ' %';
  }

  function messen() {
    abstand = new Map();
    gsap.set(teile, { clearProps: 'transform,opacity,filter' });
    teile.forEach(el => {
      const zeile = el.closest('.zeile') || titel;
      const z = zeile.getBoundingClientRect(), e = el.getBoundingClientRect();
      abstand.set(el, ((e.left + e.width / 2) - (z.left + z.width / 2)) * SPREIZ);
    });
  }

  function baueZeitleiste() {
    const v = VARIANTEN[variante];
    if (tl) tl.kill();
    gsap.set(teile, { clearProps: 'all' });
    if (variante === 'laufweite') messen();
    tl = gsap.timeline({ paused: true })
      .fromTo(teile, v.rein, { ...v.ruhe, duration: T.teil, ease: 'none', stagger: { amount: T.stagger, from: v.von }, immediateRender: true }, 0)
      .to(teile, { ...v.raus, duration: T.teil, ease: 'power2.in', stagger: { amount: T.stagger, from: v.von }, immediateRender: false }, T.raus);
    zeigeWerte();
    setze(vor ? vor.p : (st ? st.progress : 0));
  }

  const zustand = { p: 0 };
  function setze(p) {
    zustand.p = p;
    if (tl) tl.progress(p);
    anzeigen(p);
  }

  function aufbauen() {
    if (reduziert()) { zeigeWerte(); return; }
    split = SplitText.create(titel, {
      type: touch ? 'lines,words' : 'lines,words,chars',
      linesClass: 'zeile', wordsClass: 'wort', charsClass: 'zeichen',
      autoSplit: true,
      onSplit(self) {
        teile = touch ? self.words : self.chars;
        baueZeitleiste();
        gsap.set(titel, { visibility: 'visible' });
        return tl;
      },
    });
    st = ScrollTrigger.create({
      trigger: kapitel, start: 'top bottom', end: 'bottom bottom',
      onUpdate(self) { if (!vor) setze(self.progress); },   // direkt gekoppelt = scrub: true
    });
  }

  function setzeVariante(neu) {
    variante = neu;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === neu)));
    if (!reduziert() && teile.length) baueZeitleiste();
  }
  document.querySelector('.varianten').addEventListener('click', e => {
    const b = e.target.closest('[data-variante]');
    if (!b) return;
    stoppVorfuehrung();
    setzeVariante(b.dataset.variante);
  });

  // Vorführung: Scroll-Fortschritt simulieren, alle drei Varianten im Wechsel
  const reihe = ['unschaerfe', 'laufweite', 'kippen'];
  function stoppVorfuehrung() {
    if (!vor) return;
    vor.tween.kill(); vor = null;
    html.classList.remove('vorfuehrung');
    if (st) setze(st.progress);
  }
  let gebaut = false, wartet = false;
  function vorfuehren() {
    stoppVorfuehrung();
    if (reduziert()) return;
    if (!gebaut) { wartet = true; return; }   // Schriften noch nicht da: nach dem Teilen starten
    if (!html.classList.contains('vorschau')) scrollTo(0, kapitel.offsetTop);
    html.classList.add('vorfuehrung');
    let i = 0;
    const runde = () => {
      vor = { p: 0, tween: null };
      setzeVariante(reihe[i]);
      const o = vor;
      const upd = () => setze(o.p);
      o.tween = gsap.timeline({ onComplete() { i = (i + 1) % reihe.length; runde(); } })
        .to(o, { p: 0.5, duration: 4.2, ease: 'none', onUpdate: upd })            // zusammensetzen (fertig bei 0,4)
        .to(o, { p: 0.56, duration: 1.2, ease: 'none', onUpdate: upd })           // lesen
        .to(o, { p: 1, duration: 1.8, ease: 'power1.in', onUpdate: upd })         // auseinander
        .to({}, { duration: 0.6 });
    };
    runde();
  }
  function neustart() {
    stoppVorfuehrung();
    scrollTo(0, 0);
    setzeVariante('unschaerfe');
    setze(0);
  }
  // Eigenes Scrollen beendet die Vorführung (außer in der Galerie-Vorschau)
  ['wheel', 'touchstart', 'keydown'].forEach(t => addEventListener(t, () => {
    if (vor && !html.classList.contains('vorschau')) stoppVorfuehrung();
  }, { passive: true }));

  document.fonts.ready.then(() => { aufbauen(); gebaut = true; if (wartet) vorfuehren(); });   // erst nach den Schriften teilen, sonst falsche Umbrüche

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