Zum Inhalt
← Preloader & Intro

Prozent-Zähler-Preloader (0 bis 100)

Riesige Prozentziffern zählen bis 100, rollen, wandern oder messen echt, und gleiten dann aus der Maske, bevor der Loader wegwischt.

Große Ziffern zählen von 0 bis 100, meist in einer Display-Schrift mit fester Ziffernbreite. Oft springen sie in unregelmäßigen Schritten (1–10, 20, 23, 30, 50 …), damit es echt wirkt. Dabei wandert der Zähler mit dem Fortschritt über den Bildschirm, füllt eine Linie oder rollt wie ein Kilometerzähler. Bei 100 gleiten die Ziffern aus ihrer Maske nach oben, dann wischt oder blendet der Loader weg.

Der Fortschritt ist sofort lesbar, und die Zahl wird selbst zum typografischen Bild. Die Wartezeit bekommt einen Takt und ein klares Ende. Ein Award-Klassiker der 2020er.

Wann einsetzen

Wenn es echten Ladebedarf gibt (WebGL, Video, große Kampagnenbilder) und der Fortschritt wirklich gemessen wird. Passt zu Marken, die Typografie in den Vordergrund stellen.

Wann nicht

Als reine Deko auf schnellen Seiten. Ein Fake-Zähler kostet Zeit, verschlechtert den LCP und gilt 2026 als Klischee. Gabriel Contassot nennt seinen Zähler in der eigenen Case Study dekorativ, weil es wenig zu laden gibt.

Varianten

  • Wandern: Der Zähler schiebt sich per translateX mit dem Fortschritt über den Bildschirm (Contassot).
  • Rollen: Jede Stelle ist eine senkrechte Ziffernspalte und rollt per translateY (Odometer, Lusion).
  • Feste Stufen: Der Wert springt mit steps(14) in 3 s auf 100 (Joffrey Spitzer).
  • Gemessen: echter Fortschritt aus geladenen Dateien oder aus THREE.LoadingManager.onProgress.
  • Zähler plus Bild-Flicker.
  • Zähler, der zur Seitenzahl wird (siehe loader-wird-layout).

Umsetzung

Echter Fortschritt: Assets per Promise laden und den Anteil erledigter Promises zählen, oder THREE.LoadingManager(onLoad, onProgress(url, loaded, total)) nutzen. Den Anzeigewert glätten, damit er nicht springt: gsap.to(obj, { val: ziel, duration: 0.4, ease: 'power2.out', onUpdate }). Gegen Zittern braucht jede Stelle eine feste Breite: font-variant-numeric: tabular-nums oder, wenn die Tabellenziffern der Schrift anders aussehen, eine Box je Stelle. Mindest- und Höchstdauer setzen (z. B. min 1000 ms, max 3000 ms, dann trotzdem weiter). Fallstrick: Der Fortschritt bleibt oft bei 99 % stehen, weil Schriften fehlen oder Bilder noch nicht decodiert sind. Deshalb auf document.fonts.ready und img.decode() warten. Abgang wie bei Joffrey Spitzer: Label und Zahl yPercent -105 in 1 s power2.out, 150 ms versetzt.

Robust umsetzen

Telefon. Ziffern kleiner. Den wandernden Zähler am Rand begrenzen: Contassot deckelt die Translation auf Mobilgeräten bei 95 %.

Ohne Bewegung. Statischer Prozentwert oder Balken. Nichts rollt, nichts wandert. Den Loader am Ende kurz ausblenden statt wegwischen.

Barrierefrei. role="progressbar" mit aria-valuenow, oder eine Live Region mit wenigen Ansagen (25, 50, 75, 100). Nicht jedes Prozent ansagen. aria-busy am main, bis der Loader weg ist. Die großen Ziffern selbst aria-hidden setzen.

Code

<!DOCTYPE html>
<!--
  Prozent-Zähler-Preloader (0 bis 100)
  Fundus · Preloader & Intro · https://cecodesign.de/lab/fundus/preloader-intro/prozent-zaehler/
  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>Prozent-Zähler-Preloader (0 bis 100) · 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: 'Zalando Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/zalando-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Fragment Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fragment-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }

/* Solgut, Hautpflege aus Halle (Saale) · Rezept „Kollektion und Ritual“:
   warmes Off-White, Fast-Schwarz mit Rotstich, eine Grotesk (Zalando Sans, nicht fett), Mono nur für Rubriken und Formeln */
:root {
  --grund: #f6f3ee; --text: #121010; --flaeche: #ebe7e0; --linie: #cecdc6; --meta: #736d64; --signal: #a3402a;
  --nacht: #121010; --hell: #f6f3ee; --hell-leise: rgba(246, 243, 238, .5); --hell-linie: rgba(246, 243, 238, .16);
  --kurve: cubic-bezier(0.87, 0, 0.13, 1);       /* expo.inOut, Messwert (Wisch) */
  --weich: cubic-bezier(0.25, 1, 0.5, 1);        /* Rezept: Bildüberblendung 1 s */
  --gx: clamp(16px, 2.6vw, 40px);
  --pult: 56px;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Zalando Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
html.laedt { overflow: hidden; }
body { padding-bottom: var(--pult); }
a { color: inherit; text-decoration: none; }
img { display: block; width: 100%; height: 100%; object-fit: cover; }
.mono { font: 400 11px/1.45 'Fragment Mono', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ── Die Seite hinter dem Loader ── */
.buehne { position: relative; height: calc(100svh - var(--pult)); min-height: 520px; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; padding: 16px var(--gx); }
.marke { font-size: 21px; font-weight: 450; font-stretch: 125%; letter-spacing: -.01em; line-height: 1; }
.kopf nav ul { display: flex; gap: clamp(18px, 2.4vw, 36px); list-style: none; padding: 0; font-size: 14px; }
.kopf nav a, .kopf__rechts a { transition: opacity 200ms; }
.kopf nav a:hover, .kopf__rechts a:hover { opacity: .55; }
.kopf__rechts { justify-self: end; display: flex; gap: 22px; font-size: 14px; }
.menue { display: none; }

.held { position: relative; overflow: hidden; background: var(--flaeche); }
.held img { object-position: 62% 50%; }
.held__text { position: absolute; left: var(--gx); bottom: clamp(20px, 3.4vw, 44px); max-width: 30rem; display: grid; gap: 14px; }
.held__text > * { opacity: 0; transition: opacity 1000ms var(--weich); transition-delay: calc(var(--i) * 120ms + 100ms); }
.buehne[data-zustand='fertig'] .held__text > * { opacity: 1; }
.held h1 { font-size: clamp(2rem, 5vw, 4.5rem); font-weight: 400; font-stretch: 125%; letter-spacing: -.01em; line-height: 1.05; text-wrap: balance; }
.held p:not(.mono) { font-size: 15px; line-height: 1.5; max-width: 34ch; color: rgba(18, 16, 16, .78); text-wrap: pretty; }
.held .mono { color: rgba(18, 16, 16, .62); }
.mehr { justify-self: start; font-size: 14px; padding-bottom: 3px; border-bottom: 1px solid currentColor; }
.bildzeile { position: absolute; right: var(--gx); top: clamp(16px, 2vw, 28px); color: rgba(18, 16, 16, .55); text-align: right; opacity: 0; transition: opacity 1000ms var(--weich) 500ms; }
.buehne[data-zustand='fertig'] .bildzeile { opacity: 1; }

.reihe { padding: clamp(48px, 8vw, 104px) var(--gx) clamp(64px, 10vw, 128px); }
.reihe__kopf { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; margin-bottom: 24px; }
.reihe__kopf h2 { font-size: clamp(1.4rem, 2.2vw, 2rem); font-weight: 400; font-stretch: 125%; letter-spacing: -.01em; }
.reihe__kopf .mono, .produkt .mono { color: var(--meta); }
.produkte { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(12px, 1.6vw, 24px); list-style: none; padding: 0; }
.produkt { display: grid; gap: 12px; }
.produkt .bild { aspect-ratio: 4 / 5; background: var(--flaeche); overflow: hidden; }
.produkt h3 { font-size: 15px; font-weight: 500; line-height: 1.35; display: flex; justify-content: space-between; gap: 12px; }
.produkt h3 span { font-weight: 400; }   /* kein tnum: die Tabellenziffern von Zalando Sans haben eine Null mit Schrägstrich */

/* ── Loader ── */
.lader { position: fixed; z-index: 10; top: 0; left: 0; right: 0; bottom: var(--pult); background: var(--nacht); color: var(--hell);
         display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto; padding: 16px var(--gx) clamp(16px, 2.4vw, 32px); }
.lader[hidden] { display: none; }
.lader .mono { color: var(--hell-leise); }
.l-kopf { display: grid; grid-template-columns: 1fr auto; align-items: start; gap: 24px; }
.l-kopf .marke { color: var(--hell); }
.l-kopf p { text-align: right; }
.l-mitte { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 360px); align-items: center; gap: 24px clamp(24px, 6vw, 96px); }
.liste { list-style: none; padding: 0; }
.liste .rubrik { border-top: 0; padding-bottom: 10px; }
.liste li { display: flex; justify-content: space-between; gap: 16px; padding: 7px 0; border-top: 1px solid var(--hell-linie); }
.liste li:last-child { border-bottom: 1px solid var(--hell-linie); }
.liste b { font-weight: 400; color: var(--hell-leise); transition: color 150ms; }
.liste li.da b { color: var(--hell); }
.motto { max-width: 19ch; font-size: clamp(20px, 2.1vw, 30px); font-weight: 400; font-stretch: 125%; line-height: 1.2; letter-spacing: -.01em; text-wrap: balance; }
.motto span { color: var(--hell-leise); }

.l-fuss { position: relative; display: grid; gap: clamp(10px, 1.6vw, 18px); }
.spur { position: relative; height: clamp(110px, 21vw, 300px); }
.zaehler { position: absolute; left: 0; bottom: 0; display: grid; gap: 8px; will-change: transform; }
.maske { display: block; overflow: hidden; }
.maske > * { display: block; }
/* feste Ziffernbreite pro Stelle (--ziffer, gemessen) statt tnum: kein Zittern, aber die normale Null */
.zahl { --ziffer: .8em; display: flex; align-items: flex-start; font-size: clamp(110px, 21vw, 300px); font-weight: 300; font-stretch: 125%; line-height: .8; }
.zahl sup { font: 400 11px/1 'Fragment Mono', monospace; letter-spacing: .06em; color: var(--hell-leise); margin: .5em 0 0 .7em; }
.zahl-text { display: flex; height: .8em; }
.zahl-text i, .spalte { font-style: normal; width: var(--ziffer); text-align: center; }
.rollen { display: none; height: .8em; }
.lader[data-modus='rollen'] .zahl-text { display: none; }
.lader[data-modus='rollen'] .rollen { display: flex; }
.spalte { height: .8em; overflow: hidden; transition: color 150ms; }
.spalte.null { color: rgba(246, 243, 238, .2); }
.band { display: block; will-change: transform; }
.band span { display: block; height: .8em; }
.linie { height: 1px; background: var(--hell-linie); }
.linie i { display: block; height: 100%; background: var(--hell); transform-origin: left center; transform: scaleX(0); }
.l-unten { display: flex; justify-content: space-between; gap: 16px; }

/* ── Pult: Varianten und Werte ── */
.pult { position: fixed; z-index: 20; left: 0; right: 0; bottom: 0; height: var(--pult); display: flex; align-items: center; justify-content: space-between; gap: 10px 28px;
        padding: 0 var(--gx); background: var(--grund); border-top: 1px solid var(--linie); white-space: nowrap; }
.modi { display: flex; gap: 4px; align-items: center; flex-shrink: 0; color: var(--meta); }
.modi > span { margin-right: 8px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--meta); background: none; border: 1px solid var(--linie); border-radius: 2px; height: 28px; padding: 0 11px; cursor: pointer; transition: background 150ms, color 150ms, border-color 150ms; }
.modi button:hover { color: var(--text); border-color: var(--meta); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi .nochmal { border-color: transparent; }
.werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px 14px; font-size: 10px; color: var(--meta); min-width: 0; overflow: hidden; }
.werte b { color: var(--text); font-weight: 400; }

/* Reduzierte Bewegung: Wert springt in Vierteln, nichts rollt oder wandert, Loader blendet 250 ms aus */
.reduziert .held__text > *, .reduziert .bildzeile { transition: opacity 250ms linear; transition-delay: 0ms; }
.reduziert .modi button, .reduziert .kopf a, .reduziert .spalte, .reduziert .liste b { transition: none; }

/* Vorschau: nur Bühne und Umschalter */
.vorschau body { overflow: hidden; }
.vorschau .werte { display: none; }
/* Pult aus (Vorschau) oder eingeklappt (Telefon): Loader und Bühne reichen bis zum unteren Rand */
html.vorschau, html:has([data-pult='zu']) { --pult: 0px; }

@media (max-width: 1180px) {
  .werte span:nth-child(n+3) { display: none; }
}
@media (max-width: 860px) {
  .kopf { grid-template-columns: 1fr auto; }
  .kopf nav, .kopf__rechts a:not(.tasche) { display: none; }
  .menue { display: inline; }
  .werte { display: none; }
}
@media (max-width: 640px) {
  .buehne { height: auto; min-height: 0; grid-template-rows: auto auto; }
  .held { display: grid; overflow: visible; background: none; }
  .held img { aspect-ratio: 4 / 5; height: auto; object-position: 70% 50%; background: var(--flaeche); }
  .held__text { position: static; padding: 22px var(--gx) 0; }
  .bildzeile { display: none; }
  .produkte { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 72%; overflow-x: auto; scroll-snap-type: x mandatory; margin: 0 calc(var(--gx) * -1); padding: 0 var(--gx); scroll-padding: 0 var(--gx); scrollbar-width: none; }
  .produkt { scroll-snap-align: start; }
  .modi > span { display: none; }
  .l-kopf p { display: none; }
  .l-mitte { grid-template-columns: minmax(0, 1fr); align-content: center; }
}
@media (hover: none) {
  .modi button:hover { color: var(--meta); border-color: var(--linie); }
  .modi button[aria-pressed='true']:hover { color: var(--grund); border-color: var(--text); }
  .kopf nav a:hover, .kopf__rechts a:hover { opacity: 1; }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="buehne" data-zustand="laden">
  <header class="kopf">
    <a class="marke" href="#">Solgut</a>
    <nav aria-label="Hauptnavigation"><ul><li><a href="#">Pflege</a></li><li><a href="#">Rituale</a></li><li><a href="#">Die Sole</a></li><li><a href="#">Läden</a></li></ul></nav>
    <div class="kopf__rechts"><a href="#">Suche</a><a href="#">Konto</a><a class="tasche" href="#">Tasche (0)</a><span class="menue">Menü</span></div>
  </header>
  <main class="held" id="inhalt" aria-busy="true">
    <img data-motiv="kampagne" width="1800" height="1125" alt="Bernsteinflasche Solefluid und ein Tiegel Salzbalsam auf einem Travertinblock, davor eine Tube Handcreme und grobe Salzkristalle im Fensterlicht">
    <div class="held__text">
      <p class="mono" style="--i:0">Kollektion 04 · Winterpflege</p>
      <h1 style="--i:1">Salz, Wasser, Zeit.</h1>
      <p style="--i:2">Sole aus Halle, in kleinen Chargen gerührt. Für Haut, die Heizungsluft und Frost kennt.</p>
      <a class="mehr" href="#" style="--i:3">Zur Kollektion</a>
    </div>
    <p class="bildzeile mono">Solefluid S12 · Salzbalsam B4 · Handcreme H8<br>Fotografiert in der Werkstatt, Halle-Giebichenstein</p>
  </main>
</div>

<section class="reihe" aria-labelledby="reihe-titel">
  <div class="reihe__kopf"><h2 id="reihe-titel">Die Winterreihe</h2><p class="mono">3 Produkte</p></div>
  <ul class="produkte">
    <li class="produkt"><div class="bild"><img data-motiv="flasche" width="800" height="1000" alt="Solefluid S12 in Bernsteinglas mit Pipette"></div><h3>Solefluid S12 <span>28,00 €</span></h3><p class="mono">Sole 12 % + Panthenol 5 % · 30 ml</p></li>
    <li class="produkt"><div class="bild"><img data-motiv="tiegel" width="800" height="1000" alt="Salzbalsam B4 im mattweißen Tiegel mit Sanddeckel"></div><h3>Salzbalsam B4 <span>34,00 €</span></h3><p class="mono">Sole 4 % + Sheabutter 20 % · 50 ml</p></li>
    <li class="produkt"><div class="bild"><img data-motiv="tube" width="800" height="1000" alt="Handcreme H8 in weißer Tube mit schwarzer Kappe"></div><h3>Handcreme H8 <span>16,00 €</span></h3><p class="mono">Sole 8 % + Glycerin 10 % · 75 ml</p></li>
  </ul>
</section>

<div class="lader" data-modus="rollen" role="progressbar" aria-label="Seite lädt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
  <div class="l-kopf">
    <span class="marke">Solgut</span>
    <p class="mono">Kollektion 04 · Winterpflege<br>Halle (Saale) · 51° 29′ N</p>
  </div>
  <div class="l-mitte">
    <p class="motto">Sole aus 400 Metern Tiefe. <span>Gerührt in Chargen zu 60 Tiegeln.</span></p>
    <ul class="liste mono" aria-hidden="true"></ul>
  </div>
  <div class="l-fuss">
    <div class="spur">
      <div class="zaehler">
        <span class="maske mono"><span class="titel">Lädt</span></span>
        <span class="maske"><span class="zahl" aria-hidden="true"><span class="zahl-text"><i>0</i></span><span class="rollen"><span class="spalte"><span class="band"></span></span><span class="spalte"><span class="band"></span></span><span class="spalte"><span class="band"></span></span></span><sup>%</sup></span></span>
      </div>
    </div>
    <div class="linie"><i></i></div>
    <div class="l-unten mono"><span>Seit 2019</span><span data-stand>0 von 6 Dateien</span></div>
  </div>
</div>
<p class="sr" aria-live="polite" data-ansage></p>

<div class="pult">
  <div class="modi mono" role="group" aria-label="Zähler">
    <span>Zähler</span>
    <button type="button" data-v="rollen" aria-pressed="true">Rollen</button>
    <button type="button" data-v="wandern" aria-pressed="false">Wandern</button>
    <button type="button" data-v="gemessen" aria-pressed="false">Gemessen</button>
    <button type="button" class="nochmal" data-nochmal aria-label="Nochmal abspielen">↻</button>
  </div>
  <p class="werte mono" data-werte></p>
</div>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
/* Bildwelt: Kampagnenbild und Packshots werden auf Canvas gemalt und als echte <img> geladen.
   Auf einer echten Seite stehen hier die Bilddateien; gemessen wird genauso (fertig = geladen und decodiert). */
const MALER = (() => {
  const zufall = s => () => { s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
  const lin = (c, x0, y0, x1, y1, st) => { const g = c.createLinearGradient(x0, y0, x1, y1); st.forEach(([o, f]) => g.addColorStop(o, f)); return g; };
  const rad = (c, x, y, r0, r1, st) => { const g = c.createRadialGradient(x, y, r0, x, y, r1); st.forEach(([o, f]) => g.addColorStop(o, f)); return g; };
  const weich = (c, px, f) => { c.save(); c.filter = `blur(${px}px)`; f(); c.restore(); };
  const rr = (c, x, y, w, h, r, f) => { c.fillStyle = f; c.beginPath(); c.roundRect(x, y, w, h, r); c.fill(); };
  const ell = (c, x, y, rx, ry, f) => { c.fillStyle = f; c.beginPath(); c.ellipse(x, y, Math.max(rx, .1), Math.max(ry, .1), 0, 0, Math.PI * 2); c.fill(); };
  const korn = (c, w, h, st, r) => { const d = c.getImageData(0, 0, w, h), p = d.data; for (let i = 0; i < p.length; i += 4) { const n = (r() - .5) * st; p[i] += n; p[i + 1] += n; p[i + 2] += n; } c.putImageData(d, 0, 0); };

  // Schlagschatten nach rechts unten (Fensterlicht von links oben)
  const schatten = (c, x, fuss, breite, laenge, staerke = .3) => weich(c, breite * .18, () => {
    c.fillStyle = `rgba(92, 70, 44, ${staerke})`; c.beginPath();
    c.moveTo(x - breite / 2, fuss); c.lineTo(x + breite / 2, fuss);
    c.lineTo(x + breite / 2 + laenge, fuss - laenge * .16); c.lineTo(x - breite / 2 + laenge * .9, fuss - laenge * .2); c.closePath(); c.fill();
  });

  // Bernsteinflasche mit Pipette; x Mitte, fuss Standlinie, H Gesamthöhe
  function flasche(c, x, fuss, H, r) {
    const b = H * .3, kh = H * .62, hals = b * .38;
    const k0 = fuss - kh;
    // Körper
    c.fillStyle = lin(c, x - b / 2, 0, x + b / 2, 0, [[0, '#3a1806'], [.18, '#7a3610'], [.42, '#b25c1c'], [.62, '#8e4414'], [1, '#2e1305']]);
    c.beginPath(); c.moveTo(x - b / 2, fuss - b * .06); c.lineTo(x - b / 2, k0 + b * .22);
    c.quadraticCurveTo(x - b / 2, k0, x - hals / 2, k0 - b * .02); c.lineTo(x + hals / 2, k0 - b * .02);
    c.quadraticCurveTo(x + b / 2, k0, x + b / 2, k0 + b * .22); c.lineTo(x + b / 2, fuss - b * .06);
    c.quadraticCurveTo(x + b / 2, fuss, x + b / 2 - b * .06, fuss); c.lineTo(x - b / 2 + b * .06, fuss); c.quadraticCurveTo(x - b / 2, fuss, x - b / 2, fuss - b * .06); c.fill();
    // Glanz
    weich(c, b * .04, () => { rr(c, x - b * .3, k0 + b * .2, b * .07, kh - b * .4, b * .03, 'rgba(255, 214, 160, .55)'); rr(c, x + b * .3, k0 + b * .3, b * .03, kh - b * .6, b * .02, 'rgba(255, 200, 140, .25)'); });
    // Etikett
    const ey = k0 + kh * .36, eh = kh * .4;
    c.fillStyle = lin(c, x - b / 2, 0, x + b / 2, 0, [[0, '#cfc7ba'], [.35, '#f3eee6'], [.7, '#e6dfd3'], [1, '#bdb4a6']]); c.fillRect(x - b / 2, ey, b, eh);
    c.fillStyle = '#121010'; c.textAlign = 'center';
    c.font = `450 ${b * .12}px 'Zalando Sans', sans-serif`; c.fillText('Solgut', x, ey + eh * .3);
    c.font = `${b * .075}px 'Fragment Mono', monospace`; c.fillText('SOLEFLUID S12', x, ey + eh * .62); c.fillStyle = 'rgba(18,16,16,.55)'; c.fillText('SOLE 12 % · 30 ML', x, ey + eh * .8);
    // Pipette: Ring, Gummiball
    const ry = k0 - b * .02;
    rr(c, x - hals * .62, ry - H * .07, hals * 1.24, H * .075, hals * .1, lin(c, x - hals, 0, x + hals, 0, [[0, '#0c0a09'], [.35, '#3b3532'], [1, '#0c0a09']]));
    c.fillStyle = lin(c, x - hals, 0, x + hals, 0, [[0, '#d9d1c4'], [.35, '#f6f1e8'], [1, '#b8af9f']]);
    c.beginPath(); c.moveTo(x - hals * .5, ry - H * .07); c.lineTo(x - hals * .5, ry - H * .2); c.quadraticCurveTo(x - hals * .5, ry - H * .265, x, ry - H * .265);
    c.quadraticCurveTo(x + hals * .5, ry - H * .265, x + hals * .5, ry - H * .2); c.lineTo(x + hals * .5, ry - H * .07); c.fill();
    c.fillStyle = 'rgba(0,0,0,.12)'; for (let i = 0; i < 4; i++) c.fillRect(x - hals * .5, ry - H * (.09 + i * .022), hals, 1.5);
  }
  // Tiegel mattweiß, Deckel sandfarben; x Mitte, fuss Standlinie, B Breite
  function tiegel(c, x, fuss, B) {
    const h = B * .62, dh = B * .2, e = B * .09;
    c.fillStyle = lin(c, x - B / 2, 0, x + B / 2, 0, [[0, '#cfc8bd'], [.3, '#f7f4ee'], [.6, '#ebe6de'], [1, '#bdb5a8']]);
    c.beginPath(); c.moveTo(x - B / 2, fuss - h); c.lineTo(x - B / 2, fuss - e); c.ellipse(x, fuss - e, B / 2, e, 0, Math.PI, 0, true); c.lineTo(x + B / 2, fuss - h); c.closePath(); c.fill();
    // Deckel
    const d0 = fuss - h;
    c.fillStyle = lin(c, x - B / 2, 0, x + B / 2, 0, [[0, '#8f7b5c'], [.3, '#c9b490'], [.65, '#b39d78'], [1, '#7c6a4e']]);
    c.beginPath(); c.moveTo(x - B * .51, d0); c.lineTo(x - B * .51, d0 - dh); c.lineTo(x + B * .51, d0 - dh); c.lineTo(x + B * .51, d0); c.ellipse(x, d0, B * .51, e, 0, 0, Math.PI); c.fill();
    ell(c, x, d0 - dh, B * .51, e, lin(c, x - B / 2, 0, x + B / 2, 0, [[0, '#bba783'], [.4, '#ddcca9'], [1, '#a8936f']]));
    c.fillStyle = '#121010'; c.textAlign = 'center'; c.font = `450 ${B * .085}px 'Zalando Sans', sans-serif`; c.fillText('Solgut', x, fuss - h * .48);
    c.font = `${B * .05}px 'Fragment Mono', monospace`; c.fillStyle = 'rgba(18,16,16,.6)'; c.fillText('SALZBALSAM B4', x, fuss - h * .3);
  }
  // Tube liegend oder stehend; Mittelpunkt, Länge, Drehung
  function tube(c, x, y, L, dreh) {
    const D = L * .2;
    c.save(); c.translate(x, y); c.rotate(dreh);
    // Körper verjüngt sich zum Falz
    c.fillStyle = lin(c, 0, -D / 2, 0, D / 2, [[0, '#cfc8bc'], [.3, '#f8f5ef'], [.7, '#e9e4db'], [1, '#b7afa2']]);
    c.beginPath(); c.moveTo(-L * .38, -D / 2); c.lineTo(L * .4, -D * .62); c.lineTo(L * .4, D * .62); c.lineTo(-L * .38, D / 2); c.closePath(); c.fill();
    c.fillStyle = 'rgba(0,0,0,.08)'; for (let i = 0; i < 6; i++) c.fillRect(L * (.36 + i * .006), -D * .62, 1.5, D * 1.24);
    // Schulter und Kappe
    c.fillStyle = lin(c, 0, -D / 2, 0, D / 2, [[0, '#1a1716'], [.35, '#4a4440'], [1, '#0d0b0a']]);
    c.beginPath(); c.roundRect(-L * .5, -D * .36, L * .13, D * .72, D * .08); c.fill();
    c.fillStyle = '#121010'; c.font = `450 ${D * .26}px 'Zalando Sans', sans-serif`; c.textAlign = 'left'; c.fillText('Solgut', -L * .3, D * .02);
    c.font = `${D * .14}px 'Fragment Mono', monospace`; c.fillStyle = 'rgba(18,16,16,.55)'; c.fillText('HANDCREME H8 · 75 ML', -L * .3, D * .26);
    c.restore();
  }
  // Salzkristalle: kleine Würfel mit Licht- und Schattenseite
  function salz(c, cx, cy, streu, n, groesse, r) {
    for (let i = 0; i < n; i++) {
      const a = r() * Math.PI * 2, d = Math.pow(r(), 1.6) * streu, s = groesse * (.4 + r() * .9);
      const x = cx + Math.cos(a) * d * 1.8, y = cy + Math.sin(a) * d * .5;
      c.save(); c.translate(x, y); c.rotate(r() * Math.PI);
      c.fillStyle = 'rgba(92,70,44,.22)'; c.fillRect(-s / 2 + s * .25, -s / 2 + s * .12, s, s);
      c.fillStyle = r() < .5 ? '#fbf9f4' : '#ece7de'; c.fillRect(-s / 2, -s / 2, s, s);
      c.fillStyle = 'rgba(255,255,255,.9)'; c.fillRect(-s / 2, -s / 2, s, s * .35);
      c.fillStyle = 'rgba(150,135,110,.35)'; c.fillRect(-s / 2, s * .2, s, s * .3);
      c.restore();
    }
  }
  // Wand mit Fensterlicht (zwei Flügel, Sprosse)
  function wand(c, w, h, bis) {
    c.fillStyle = lin(c, 0, 0, 0, bis, [[0, '#e3dacb'], [1, '#d6cab8']]); c.fillRect(0, 0, w, bis);
    weich(c, w * .012, () => {
      c.fillStyle = 'rgba(255, 247, 232, .55)';
      [[.04, .19], [.215, .36]].forEach(([a, b]) => { c.beginPath(); c.moveTo(w * a, -10); c.lineTo(w * b, -10); c.lineTo(w * (b + .16), bis); c.lineTo(w * (a + .16), bis); c.closePath(); c.fill(); });
    });
    weich(c, w * .02, () => { c.fillStyle = 'rgba(120, 96, 64, .08)'; c.fillRect(0, bis - h * .06, w, h * .06); });
  }

  function kampagne(c, w, h, r) {
    const kante = h * .66;
    wand(c, w, h, kante);
    // Tischplatte
    c.fillStyle = lin(c, 0, kante, 0, h, [[0, '#cdbda4'], [1, '#b8a68b']]); c.fillRect(0, kante, w, h - kante);
    c.fillStyle = 'rgba(255,248,236,.5)'; c.fillRect(0, kante, w, 2);
    weich(c, w * .02, () => { c.fillStyle = 'rgba(255, 247, 232, .35)'; c.beginPath(); c.moveTo(w * .2, kante); c.lineTo(w * .5, kante); c.lineTo(w * .62, h); c.lineTo(w * .3, h); c.closePath(); c.fill(); });
    // Travertinblock
    const bx0 = w * .5, bx1 = w * .9, by0 = h * .47, bt = h * .045;
    schatten(c, (bx0 + bx1) / 2, kante + h * .035, bx1 - bx0, w * .2, .26);
    c.fillStyle = lin(c, bx0, 0, bx1, 0, [[0, '#e2d6c2'], [.5, '#d5c7b0'], [1, '#bfae95']]); c.fillRect(bx0, by0, bx1 - bx0, kante + h * .035 - by0);
    c.fillStyle = lin(c, 0, by0 - bt, 0, by0, [[0, '#efe6d6'], [1, '#e4d9c6']]);
    c.beginPath(); c.moveTo(bx0, by0); c.lineTo(bx0 + w * .02, by0 - bt); c.lineTo(bx1 + w * .02, by0 - bt); c.lineTo(bx1, by0); c.closePath(); c.fill();
    c.fillStyle = '#b5a389'; c.beginPath(); c.moveTo(bx1, by0); c.lineTo(bx1 + w * .02, by0 - bt); c.lineTo(bx1 + w * .02, kante + h * .035 - bt); c.lineTo(bx1, kante + h * .035); c.closePath(); c.fill();
    for (let i = 0; i < 90; i++) ell(c, bx0 + r() * (bx1 - bx0), by0 + 6 + r() * (kante - by0), 2 + r() * 9, .8 + r() * 1.6, `rgba(128, 104, 72, ${.12 + r() * .2})`);
    c.strokeStyle = 'rgba(128,104,72,.12)'; c.lineWidth = 1;
    for (let k = 0; k < 7; k++) { const y = by0 + (kante - by0) * (k + .5) / 7; c.beginPath(); c.moveTo(bx0, y); for (let x = bx0; x <= bx1; x += 20) c.lineTo(x, y + Math.sin(x / 90 + k) * 2); c.stroke(); }
    // Objekte auf dem Block
    const fx = w * .63, tx = w * .79, H = h * .4;
    schatten(c, fx, by0 - bt * .4, H * .3, w * .14, .32);
    schatten(c, tx, by0 - bt * .4, w * .11, w * .1, .26);
    tiegel(c, tx, by0 - bt * .35, w * .11);
    flasche(c, fx, by0 - bt * .3, H, r);
    salz(c, w * .71, by0 - bt * .3, w * .018, 26, w * .006, r);
    // Tube auf dem Tisch, Salz davor
    schatten(c, w * .63, h * .89, w * .19, w * .06, .3);
    tube(c, w * .63, h * .85, w * .22, -.05);
    salz(c, w * .8, h * .9, w * .03, 60, w * .007, r);
    salz(c, w * .9, h * .8, w * .015, 18, w * .006, r);
    // Licht über allem
    c.fillStyle = rad(c, w * .3, -h * .2, 10, w * .8, [[0, 'rgba(255,250,240,.16)'], [1, 'rgba(255,250,240,0)']]); c.fillRect(0, 0, w, h);
    korn(c, w, h, 9, r);
  }

  // Packshots: einheitliche Fläche #ebe7e0, gleicher Boden, gleiches Licht
  function packshot(c, w, h, r, f) {
    const boden = h * .74;
    c.fillStyle = lin(c, 0, 0, 0, boden, [[0, '#efebe4'], [1, '#e4dfd6']]); c.fillRect(0, 0, w, boden);
    c.fillStyle = lin(c, 0, boden, 0, h, [[0, '#ded8ce'], [1, '#d3ccc0']]); c.fillRect(0, boden, w, h - boden);
    weich(c, 30, () => { c.fillStyle = 'rgba(255,255,255,.45)'; c.beginPath(); c.moveTo(w * .05, 0); c.lineTo(w * .45, 0); c.lineTo(w * .85, boden); c.lineTo(w * .45, boden); c.closePath(); c.fill(); });
    f(boden);
    korn(c, w, h, 7, r);
  }
  const flascheBild = (c, w, h, r) => packshot(c, w, h, r, b => { schatten(c, w * .5, b + h * .04, w * .2, w * .26, .28); flasche(c, w * .5, b + h * .04, h * .64, r); salz(c, w * .27, b + h * .12, w * .035, 22, w * .012, r); });
  const tiegelBild = (c, w, h, r) => packshot(c, w, h, r, b => { schatten(c, w * .5, b + h * .05, w * .42, w * .22, .28); tiegel(c, w * .5, b + h * .05, w * .42); salz(c, w * .78, b + h * .11, w * .03, 12, w * .012, r); });
  const tubeBild = (c, w, h, r) => packshot(c, w, h, r, b => { schatten(c, w * .5, b + h * .1, w * .7, w * .14, .3); tube(c, w * .5, b + h * .06, w * .72, -.1); salz(c, w * .3, b + h * .17, w * .04, 18, w * .012, r); });

  const MOTIVE = { kampagne, flasche: flascheBild, tiegel: tiegelBild, tube: tubeBild };
  // malt ein Motiv in ein <img> und wartet auf decode(); ergibt die Dauer in ms
  return (img, saat = 7) => new Promise(fertig => {
    const t0 = performance.now();
    const w = +img.getAttribute('width'), h = +img.getAttribute('height');
    const cv = document.createElement('canvas'); cv.width = w; cv.height = h;
    const c = cv.getContext('2d', { willReadFrequently: true });
    try { MOTIVE[img.dataset.motiv](c, w, h, zufall(saat)); } catch (e) { /* Fläche bleibt */ }
    cv.toBlob(b => {
      if (!b) return fertig(performance.now() - t0);
      const alt = img.src; img.src = URL.createObjectURL(b);
      img.decode().catch(() => {}).then(() => { if (alt.startsWith('blob:')) URL.revokeObjectURL(alt); fertig(performance.now() - t0); });
    }, 'image/jpeg', .88);
  });
})();

(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
  const buehne = $('.buehne'), main = $('#inhalt'), lader = $('.lader');
  const spur = $('.spur'), zaehler = $('.zaehler'), titel = $('.titel'), zahl = $('.zahl'), zahlText = $('.zahl-text');
  const spalten = $$('.spalte'), baender = $$('.band'), fuellung = $('.linie i');
  const liste = $('.liste'), stand = $('[data-stand]'), ansage = $('[data-ansage]');
  const bilder = $$('img[data-motiv]');

  // Ziffernbänder: Zehner und Einer zweimal 0–9 (rollen immer vorwärts), Hunderter 0–1
  baender.forEach((b, i) => { b.innerHTML = (i === 0 ? '01' : '01234567890123456789').split('').map(z => `<span>${z}</span>`).join(''); });

  // Messwerte
  const WANDERN = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 23, 30, 50, 70, 80, 100];   // Contassot, 17 Schritte
  const SCHRITT = 0.165;                         // s je Schritt (2,8 s / 17)
  const ROLLEN = { dauer: 3.0, stufen: 14 };     // Joffrey: steps(14) in 3000 ms
  const ZIFFER = ROLLEN.dauer / ROLLEN.stufen;   // 214 ms Rollweg je Stufe, expo.inOut (Lusion)
  const GLAETTEN = 0.4, MIN = 1.0, MAX = 3.0;    // gemessen: Anzeige 400 ms power2.out, min 1000 / max 3000 ms
  const ABGANG = { dauer: 1.0, versatz: 0.15, weg: -105 };   // yPercent −105, power2.out
  const WISCH = 1.0;                             // Loader yPercent −100, expo.inOut
  const ORDNUNG = ['rollen', 'wandern', 'gemessen'];
  const WIRKSTOFFE = [['Sole aus Halle', '4–12 %'], ['Panthenol', '5 %'], ['Glycerin', '10 %'], ['Sheabutter', '20 %']];

  // breiteste Ziffer messen und als feste Stellenbreite setzen (Zalando Sans: tnum hätte eine Null mit Schrägstrich)
  function zifferMessen() {
    const m = document.createElement('span'); m.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap';
    zahl.appendChild(m);
    let breit = 0;
    for (let z = 0; z < 10; z++) { m.textContent = String(z); breit = Math.max(breit, m.getBoundingClientRect().width); }
    zahl.style.setProperty('--ziffer', (breit / parseFloat(getComputedStyle(zahl).fontSize)).toFixed(3) + 'em');
    m.remove();
  }

  let modus = 'rollen', tl = null, folge = null, vorfuehrung = false, bereit = false, lauf = 0;
  let pos = [0, 0, 0], letzte = -1, angesagt = 0;

  function werteZeigen() {
    const w = $('[data-werte]');
    if (reduziert()) { w.innerHTML = '<span>Reduziert: Wert in <b>Vierteln</b>, kein Rollen, kein Wandern</span><span>Ausblenden <b>250 ms</b></span>'; return; }
    const abgang = `<span>Abgang <b>yPercent −105 · 1000 ms power2.out · 150 ms</b></span><span>Wisch <b>expo.inOut 1000 ms</b></span>`;
    w.innerHTML = {
      rollen: `<span>Dekorativ · Takt <b>steps(14) · 3000 ms</b></span><span>Ziffer <b>expo.inOut · 214 ms</b></span>`,
      wandern: `<span>Dekorativ · Folge <b>1–10, 20, 23, 30, 50, 70, 80, 100</b></span><span>Schritt <b>165 ms</b> · Weg <b>x = p × Restbreite</b></span>`,
      gemessen: `<span>Echt: <b>6 Dateien</b> · Anzeige <b>400 ms power2.out</b></span><span>Dauer <b>min 1000 · max 3000 ms</b></span>`,
    }[modus] + abgang;
  }

  // ── Anzeige ──
  function rolle(v, sofort) {
    const ziel = [Math.floor(v / 100), Math.floor(v / 10) % 10, v % 10];
    ziel.forEach((z, i) => {
      const n = i === 0 ? 2 : 10;
      const aktuell = pos[i] % n;
      let schritt = (z - aktuell + n) % n;
      pos[i] = aktuell + schritt;
      const b = baender[i];
      if (sofort || reduziert()) { gsap.killTweensOf(b); pos[i] = z; gsap.set(b, { yPercent: -z * 100 / (n * (i === 0 ? 1 : 2)) }); return; }
      if (!schritt) return;
      const nach = pos[i];
      gsap.to(b, { yPercent: -nach * 100 / (i === 0 ? 2 : 20), duration: ZIFFER, ease: 'expo.inOut', overwrite: true,
        onComplete() { if (i && nach >= 10) { pos[i] = nach - 10; gsap.set(b, { yPercent: -pos[i] * 5 }); } } });
    });
    // führende Nullen leise
    spalten[0].classList.toggle('null', v < 100);
    spalten[1].classList.toggle('null', v < 10);
  }

  function zeige(v, sofort) {
    v = Math.max(0, Math.min(100, Math.round(v)));
    if (reduziert()) v = Math.floor(v / 25) * 25;
    if (v === letzte && !sofort) return;
    letzte = v;
    if (modus === 'rollen') rolle(v, sofort); else zahlText.innerHTML = String(v).split('').map(z => `<i>${z}</i>`).join('');
    lader.setAttribute('aria-valuenow', v);
    gsap.set(fuellung, { scaleX: v / 100 });
    if (modus === 'wandern' && !reduziert()) {
      const weg = Math.max(0, spur.clientWidth - zaehler.offsetWidth);
      gsap.to(zaehler, { x: weg * v / 100, duration: SCHRITT, ease: 'power2.out', overwrite: true });
    }
    // Live Region: nur 25 / 50 / 75 / 100
    const viertel = Math.floor(v / 25) * 25;
    if (viertel > angesagt) { angesagt = viertel; ansage.textContent = viertel === 100 ? 'Geladen.' : `Lädt, ${viertel} Prozent`; }
  }

  function position() {
    const weg = Math.max(0, spur.clientWidth - zaehler.offsetWidth);
    gsap.set(zaehler, { x: modus === 'rollen' ? weg : 0 });   // Rollen rechts, Wandern startet links, Gemessen links
  }

  // ── Ablauf ──
  function aufraeumen() {
    lauf++;
    if (tl) tl.kill(); if (folge) folge.kill();
    tl = folge = null;
    gsap.killTweensOf([zaehler, titel, zahl, lader, fuellung, ...baender, liste]);
    gsap.set([zaehler, titel, zahl, lader, liste, $('.l-kopf'), $('.l-mitte'), $('.l-unten'), $('.linie')], { clearProps: 'all' });
    pos = [0, 0, 0]; letzte = -1; angesagt = 0;
  }

  // Solange der Loader liegt, ist die Seite darunter nicht erreichbar (Fokus nicht unter dem Overlay)
  const unterLader = [buehne, document.querySelector('.reihe')];
  const sperren = () => unterLader.forEach(e => { e.inert = buehne.dataset.zustand !== 'fertig'; });
  new MutationObserver(sperren).observe(buehne, { attributes: true, attributeFilter: ['data-zustand'] });
  sperren();

  function neustart() {
    if (!bereit) return;
    aufraeumen();
    werteZeigen();
    lader.dataset.modus = modus;
    buehne.dataset.zustand = 'laden';
    html.classList.add('laedt');
    main.setAttribute('aria-busy', 'true');
    ansage.textContent = '';
    scrollTo(0, 0);
    if (modus !== 'gemessen') liste.innerHTML = `<li class="rubrik"><span>Wirkstoffe der Kollektion</span></li>` + WIRKSTOFFE.map(([n, w]) => `<li class="da"><span>${n}</span><b>${w}</b></li>`).join('');
    stand.textContent = modus === 'gemessen' ? `0 von ${bilder.length + 2} Dateien` : 'Kampagne wird geladen';
    zeige(0, true);
    position();
    if (modus === 'gemessen' || reduziert()) return messen(lauf);

    tl = gsap.timeline();
    if (modus === 'rollen') {
      const s = { v: 0 };
      tl.to(s, { v: 100, duration: ROLLEN.dauer, ease: `steps(${ROLLEN.stufen})`, onUpdate: () => zeige(s.v) });
      tl.call(abgang, null, ROLLEN.dauer + .35);
    } else {
      WANDERN.forEach((v, i) => tl.call(() => zeige(v), null, (i + 1) * SCHRITT));
      tl.call(abgang, null, WANDERN.length * SCHRITT + .45);
    }
  }

  // Echter Fortschritt: Schriften laden, Bilder malen und decodieren, Anteil fertiger Dateien zählen
  function messen(id) {
    const t0 = performance.now();
    const anzeige = { v: 0 };
    const dateien = [
      { name: 'zalando-sans.woff2', tun: () => document.fonts.load("300 100px 'Zalando Sans'") },
      { name: 'fragment-mono.woff2', tun: () => document.fonts.load("11px 'Fragment Mono'") },
      ...bilder.map((img, i) => ({ name: `${img.dataset.motiv}-${img.getAttribute('width')}.jpg`, tun: () => MALER(img, 7 + i * 31) })),
    ];
    liste.innerHTML = `<li class="rubrik"><span>Geladen wird</span></li>` + dateien.map(d => `<li><span>${d.name}</span><b>…</b></li>`).join('');
    const zeilen = [...liste.children].slice(1);
    let fertig = 0, ende = false;
    const weiter = () => {
      if (id !== lauf) return;
      const ziel = fertig / dateien.length * 100;
      if (reduziert()) zeige(ziel);
      else gsap.to(anzeige, { v: ziel, duration: GLAETTEN, ease: 'power2.out', overwrite: true, onUpdate: () => zeige(anzeige.v) });
      stand.textContent = `${fertig} von ${dateien.length} Dateien`;
    };
    const schluss = () => {
      if (ende || id !== lauf) return; ende = true;
      const rest = Math.max(0, MIN - (performance.now() - t0) / 1000);
      folge = gsap.delayedCall(rest, () => {
        if (reduziert()) { zeige(100); return abgang(); }
        gsap.to(anzeige, { v: 100, duration: GLAETTEN, ease: 'power2.out', overwrite: true, onUpdate: () => zeige(anzeige.v), onComplete: () => { folge = gsap.delayedCall(.35, abgang); } });
      });
    };
    const notbremse = setTimeout(schluss, MAX * 1000);   // Höchstdauer: dann trotzdem weiter
    // nacheinander, damit jede Datei einzeln zählt; ein Frame Luft für die Anzeige
    dateien.reduce((kette, d, i) => kette.then(() => new Promise(r => requestAnimationFrame(r))).then(() => {
      if (id !== lauf) return;
      const s = performance.now();
      return Promise.resolve(d.tun()).catch(() => {}).then(() => {
        if (id !== lauf) return;
        fertig++;
        zeilen[i].classList.add('da');
        const ms = performance.now() - s;
        zeilen[i].querySelector('b').textContent = ms < 1 ? 'Cache' : `${Math.round(ms)} ms`;
        weiter();
      });
    }), Promise.resolve()).then(() => { clearTimeout(notbremse); schluss(); });
  }

  function abgang() {
    const id = lauf;
    ansage.textContent = 'Geladen.';
    if (reduziert()) {
      tl = gsap.timeline({ onComplete: () => fertig(id) });
      tl.to(lader, { autoAlpha: 0, duration: .25, ease: 'none' });
      return;
    }
    tl = gsap.timeline({ onComplete: () => fertig(id) });
    tl.to([titel, zahl], { yPercent: ABGANG.weg, duration: ABGANG.dauer, ease: 'power2.out', stagger: ABGANG.versatz }, 0)
      .to([$('.l-kopf'), $('.l-mitte'), $('.l-unten')], { autoAlpha: 0, duration: .3, ease: 'none' }, 0)
      .to($('.linie'), { scaleX: 0, transformOrigin: 'right center', duration: .6, ease: 'expo.inOut' }, 0)
      .call(() => { buehne.dataset.zustand = 'fertig'; html.classList.remove('laedt'); main.removeAttribute('aria-busy'); }, null, .55 + WISCH * .5)
      .to(lader, { yPercent: -100, duration: WISCH, ease: 'expo.inOut' }, .55);
  }

  function fertig(id) {
    if (id !== lauf) return;
    buehne.dataset.zustand = 'fertig';
    html.classList.remove('laedt');
    main.removeAttribute('aria-busy');
    if (vorfuehrung) folge = gsap.delayedCall(2.4, naechste);
  }

  function setzen(v) {
    modus = v;
    $$('[data-v]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
  }
  function naechste() { setzen(ORDNUNG[(ORDNUNG.indexOf(modus) + 1) % ORDNUNG.length]); neustart(); }

  $$('[data-v]').forEach(b => b.addEventListener('click', () => { vorfuehrung = false; setzen(b.dataset.v); neustart(); }));
  $('[data-nochmal]').addEventListener('click', () => { vorfuehrung = false; neustart(); });
  addEventListener('resize', () => { if (buehne.dataset.zustand === 'laden' && modus !== 'wandern') position(); });

  function vorfuehren() { vorfuehrung = true; gsap.globalTimeline.resume(); setzen('rollen'); neustart(); }
  function pause() { gsap.globalTimeline.pause(); }
  function weiter() { gsap.globalTimeline.resume(); }


  werteZeigen();
  if (!window.gsap) { buehne.dataset.zustand = 'fertig'; html.classList.remove('laedt'); lader.hidden = true; bilder.forEach((img, i) => MALER(img, 7 + i * 31)); return; }
  // Erster Start: Schriften und Bilder bereit, dann läuft der Zähler
  html.classList.add('laedt');
  document.fonts.ready
    .then(() => Promise.all(bilder.map((img, i) => MALER(img, 7 + i * 31))))
    .then(() => { zifferMessen(); bereit = true; neustart(); });
})();
</script>
</body>
</html>