Zum Inhalt
← Schrift-Animationen

Schimmer-/Verlaufs-Text

Ein schmales Lichtband gleitet einmal über den Intro-Satz, ein Status-Text schimmert, bis das Ergebnis da ist, und Projekttitel füllen sich beim Hover von links.

  1. Der Text bekommt einen Verlauf als Füllung: background-clip: text, color: transparent.
  2. Der Verlauf ist doppelt so breit wie der Text (background-size 200 %) und hat einen schmalen, helleren Streifen.
  3. background-position wandert linear, der Streifen läuft dadurch über die Schrift. Als Status-Text in Schleife, als Glanz einmal beim Eintritt.
  4. Eine verwandte Form füllt den Text bei Hover von links nach rechts.

KI-Produkte haben den Schimmer als Zeichen für „denkt gerade“ oder „wird erzeugt“ eingeübt. Ein Status-Text mit Schimmer sagt deshalb ohne Spinner: hier arbeitet etwas. Läuft der Glanz nur einmal, wirkt er wie Licht auf Metall oder Glas und gibt einem Wort Wert.

Wann einsetzen

Status- und Lade-Texte („Suche Termine …“, „Erzeuge Vorschau …“), KI-Funktionen, Premium-Abzeichen, ein einmaliger Glanz auf dem Hero-Wort oder Intro-Satz.

Wann nicht

Fließtext. Dauerschleife auf Überschriften, das wirkt billig. Verläufe, deren dunkelster oder hellster Teil zu wenig Kontrast zum Grund hat.

Varianten

  • Status-Schimmer in Schleife: Band 30/55/70 %, 2000–2500 ms linear (Cursor 2500 ms, Gemini 2000 ms mit Pause ab 70 % der Zeit)
  • Einmaliger Glanz beim Eintritt: schmales Band 42,5/50/57,5 % wie bei Framer, ein Durchlauf
  • Füllung bei Hover: der Verlauf läuft von links ein und färbt den Text voll
  • An den Scroll gekoppelt: eine Farbwelle läuft beim Scrollen durch den Text (eigener Eintrag scroll-word-highlight)

Umsetzung

.schimmer { background: linear-gradient(90deg, var(--muted) 0 30%, var(--fg) 55%, var(--muted) 70% 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: schimmer 2.5s linear infinite; } @keyframes schimmer { from { background-position: 150% 0; } to { background-position: -50% 0; } } Mit 150 % → −50 % liegt das Band am Anfang und Ende außerhalb des Texts, die Schleife springt nicht. Für den einmaligen Glanz die Klasse per JS setzen und bei animationend wieder entfernen. Mehrere Blöcke (z. B. Überschrift und Unterzeile) laufen synchron, wenn sie gleich breit sind und der Winkel 90° ist. color: transparent statt -webkit-text-fill-color verwenden, dann bleibt markierter Text über ::selection { color: … } lesbar.

Robust umsetzen

Telefon. Unverändert. Hover-Füllung entfällt auf Touch, der Text steht dort voll gefüllt.

Ohne Bewegung. Kein Band: Volltonfarbe bzw. ruhiger Status-Text. Den Status über Text kommunizieren, nicht über Bewegung. Gemini setzt bei prefers-reduced-motion: reduce animation: none.

Barrierefrei. Kontrast an der dunkelsten bzw. hellsten Stelle des Verlaufs prüfen; die Grundfarbe muss allein reichen. @media (forced-colors: active) { background: none; color: CanvasText; }. Statuswechsel zusätzlich über role="status" bzw. aria-live ansagen. Markierten Text per ::selection sichtbar halten.

Code

<!DOCTYPE html>
<!--
  Schimmer-/Verlaufs-Text
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/shimmer-gradient-text/
  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>Schimmer-/Verlaufs-Text · 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: 'Host Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/host-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 300 800; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; 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; }

/* Graustufen wie im Rezept „Stille Visitenkarte“, eine Farbe nur für Fokus */
:root {
  --grund: #fcfcfc; --text: #0b0b09; --zweit: #646464; --linie: #e8e8e8; --flaeche: #f0f0f0; --fokus: #2f5bea;
  --glanz-spitze: #b4bac3;   /* Flanke = Grundfarbe, im Dunkeln eigene */
  --kurve-ui: cubic-bezier(0.165, 0.84, 0.44, 1);   /* easeOutQuart, Rezept */
  color-scheme: light;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-thema='hell']) {
    --grund: #0b0b09; --text: #e5e5e5; --zweit: #989898; --linie: #252525; --flaeche: #161614; --fokus: #7d97ff;
    --glanz-flanke: #6f6f6c; --glanz-spitze: #ffffff; color-scheme: dark;
  }
}
:root[data-thema='dunkel'] {
  --grund: #0b0b09; --text: #e5e5e5; --zweit: #989898; --linie: #252525; --flaeche: #161614; --fokus: #7d97ff;
  --glanz-flanke: #6f6f6c; --glanz-spitze: #ffffff; color-scheme: dark;
}

* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Host Grotesk', system-ui, sans-serif; font-size: 16px; line-height: 1.6; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
:focus-visible { outline: 2px solid var(--fokus); outline-offset: 3px; border-radius: 4px; }
.mono { font: 450 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--zweit); font-variant-numeric: tabular-nums; }

.seite { min-height: 100svh; max-width: 1072px; margin: 0 auto; padding: 24px 16px 20px; display: grid; gap: 28px;
  grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto 1fr auto auto;
  grid-template-areas: 'marke' 'schalter' 'main' 'werte' 'fuss'; }
.marke { grid-area: marke; } .spalte { grid-area: main; } .fuss { grid-area: fuss; }
.demo { display: contents; } .schalter { grid-area: schalter; } .werte { grid-area: werte; }
/* Breit: Demo-Angaben als Randspalte rechts, die Lesespalte bleibt links */
@media (min-width: 900px) {
  .seite { padding: 36px 32px 28px; column-gap: 32px; row-gap: clamp(40px, 7vh, 64px);
    grid-template-columns: minmax(0, 640px) 1fr minmax(200px, 232px); grid-template-rows: auto 1fr auto;
    grid-template-areas: 'marke . demo' 'main . demo' 'fuss . demo'; }
  .demo { grid-area: demo; display: flex; flex-direction: column; gap: 28px; position: sticky; top: 16px; align-self: start; padding-top: 20px; margin-top: -20px; }  /* Luft oben: Platz für den ×-Knopf des Rahmens */
}

/* Kopf: Name, Ort */
.marke { display: grid; gap: 2px; align-content: start; }
.marke b { font-weight: 500; font-size: 15px; line-height: 1.3; letter-spacing: -.005em; }
.schalter { display: flex; gap: 4px; flex-wrap: wrap; }
.schalter button { height: 28px; padding: 0 11px; border-radius: 999px; border: 1px solid var(--linie); color: var(--zweit); font: inherit; letter-spacing: inherit; text-transform: inherit; transition: color .12s var(--kurve-ui), background-color .12s var(--kurve-ui), border-color .12s var(--kurve-ui); }
.schalter button:hover { color: var(--text); background: var(--flaeche); }
.schalter button[aria-pressed='true'] { color: var(--grund); background: var(--text); border-color: var(--text); }
.thema { justify-self: start; align-self: start; height: 28px; color: var(--zweit); text-decoration: underline; text-decoration-color: var(--linie); text-underline-offset: 4px; transition: color .12s var(--kurve-ui), text-decoration-color .12s var(--kurve-ui); }
.thema:hover { color: var(--text); text-decoration-color: currentColor; }

/* Spalte */
.spalte { width: 100%; max-width: 640px; display: grid; gap: clamp(40px, 7vh, 64px); align-content: start; }
.intro { --glanz-basis: var(--text); font-weight: 500; font-size: clamp(1.375rem, 3vw, 2rem); line-height: 1.32; letter-spacing: -.012em; text-wrap: pretty; max-width: 30ch; }
.intro em { line-height: 1; font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400; font-size: 1.06em; font-variation-settings: 'opsz' 36; letter-spacing: -.005em; }
.intro-block { display: grid; }
.intro { width: 100%; }
.intro.leise { color: var(--zweit); --glanz-basis: var(--zweit); }
h2 { font-size: 1rem; font-weight: 500; color: var(--zweit); line-height: 1.4; margin-bottom: 10px; }

/* ---------- Variante 1: einmaliger Glanz beim Eintritt (Framer-Band, Gemini-Dauer) ---------- */
.glanzt {
  background: linear-gradient(90deg,
    var(--glanz-basis) 0%, var(--glanz-basis) 42.5%,
    var(--glanz-flanke, var(--glanz-basis)) 46%, var(--glanz-spitze) 50%, var(--glanz-flanke, var(--glanz-basis)) 54%,
    var(--glanz-basis) 57.5%, var(--glanz-basis) 100%);
  background-size: 200% 100%;
  background-position: 150% 0;
  -webkit-background-clip: text; background-clip: text;
  animation: glanz 2000ms linear 300ms 1 both;
}
.intro.glanzt { color: transparent; }
@keyframes glanz { from { background-position: 150% 0; } to { background-position: -50% 0; } }

/* ---------- Variante 2: Füllung bei Hover (links → rechts) ---------- */
.liste { list-style: none; border-bottom: 1px solid var(--linie); }
.zeile a { display: grid; grid-template-columns: 1fr auto; align-items: baseline; gap: 2px 20px; padding: 13px 0 12px; border-top: 1px solid var(--linie); }
.titel {
  font-size: clamp(1.0625rem, 1.5vw, 1.1875rem); font-weight: 500; line-height: 1.35; letter-spacing: -.008em; justify-self: start;
  background: linear-gradient(90deg, var(--text) 0%, var(--text) 42%, var(--zweit) 58%, var(--zweit) 100%);
  background-size: 250% 100%; background-position: 100% 0;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  transition: background-position 240ms var(--kurve-ui);            /* Abgang schneller */
}
.zeile a:hover .titel, .zeile a:focus-visible .titel, .zeile.an .titel { background-position: 0% 0; transition-duration: 480ms; }
.titel::selection, .glanzt::selection { color: var(--grund); background: var(--text); }
.jahr { grid-row: span 2; align-self: center; font-variant-numeric: tabular-nums; color: var(--zweit); font-size: 14px; text-align: right; }
.art { font-size: 14px; color: var(--zweit); line-height: 1.4; }

/* ---------- Variante 3: Status-Schimmer in Schleife (Cursor-Werte) ---------- */
.termin { display: grid; gap: 14px; }
.termin .zeile1 { display: flex; align-items: center; gap: 12px 16px; flex-wrap: wrap; }
.knopf { height: 40px; padding: 0 16px; border-radius: 10px; background: var(--text); color: var(--grund); font-size: 15px; font-weight: 500; letter-spacing: -.005em; transition: transform .16s var(--kurve-ui), opacity .16s var(--kurve-ui); }
.knopf:active, .knopf.drueckt { transform: scale(.97); }
.knopf[aria-busy="true"] { opacity: .6; cursor: progress; }
.status { font-size: 1rem; line-height: 1.45; color: var(--zweit); min-height: 1.45em; }
.schimmer {
  --spitze: color-mix(in oklab, var(--text) 70%, var(--zweit));
  background: linear-gradient(90deg, var(--zweit) 0%, var(--zweit) 30%, var(--spitze) 55%, var(--zweit) 70%, var(--zweit) 100%);
  background-size: 200% 100%;
  -webkit-background-clip: text; background-clip: text; color: transparent;
  animation: schimmer 2500ms linear infinite;
}
@keyframes schimmer { from { background-position: 150% 0; } to { background-position: -50% 0; } }
.termine { display: flex; flex-wrap: wrap; gap: 6px; }
.termine button { height: 30px; padding: 0 12px; border: 1px solid var(--linie); border-radius: 999px; font-size: 14px; color: var(--text); font-variant-numeric: tabular-nums; transition: background-color .12s var(--kurve-ui); }
.termine button:hover { background: var(--flaeche); }
.erscheint { animation: rein 320ms var(--kurve-ui) both; }
@keyframes rein { from { opacity: 0; transform: translateY(4px); } }

/* Verwendete Werte als kleines Datenblatt */
.werte { display: grid; gap: 0; border-top: 1px solid var(--linie); align-content: start; }
.werte span { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0 6px; border-bottom: 1px solid var(--linie); }
.werte b { font-weight: 450; color: var(--text); text-align: right; letter-spacing: .06em; }
.werte .kopfzeile { color: var(--text); }
.fuss { display: flex; justify-content: space-between; gap: 8px 24px; flex-wrap: wrap; }

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

/* Pause (API) */
[data-pausiert] *, [data-pausiert] { animation-play-state: paused !important; }

/* Hochkontrast: Verlauf weg, Systemfarbe */
@media (forced-colors: active) {
  .glanzt, .titel, .schimmer, .intro.glanzt, .intro.leise { background: none !important; color: CanvasText !important; -webkit-text-fill-color: CanvasText !important; }
  button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}

/* Reduzierte Bewegung: kein Glanz, kein Schimmer, Füllung sofort */
:root.reduziert .glanzt { animation: none; background: none; color: var(--glanz-basis); }
:root.reduziert .schimmer { animation: none; background: none; color: var(--zweit); }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

/* Touch: kein Hover, also stehen die Titel voll da; Füllung nur programmgesteuert */
@media (hover: none) {
  .titel { background-position: 0% 0; }
  .demo-fuellung .titel { background-position: 100% 0; }
  .demo-fuellung .zeile.an .titel { background-position: 0% 0; }
}

/* Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .seite { min-height: 100vh; height: 100vh; row-gap: 36px; padding-top: 32px; grid-template-rows: auto auto 1fr; grid-template-areas: 'marke . demo' 'main . demo'; }
.vorschau .fuss { display: none; }
.vorschau .spalte { gap: 34px; }
.vorschau .zeile a { padding: 11px 0 10px; }
.vorschau .liste .zeile:nth-child(4) { display: none; }
/* Ohne Randspalte: Lesespalte mittig und größer, damit Glanz und Füllung in 960 × 720 tragen */
.vorschau .seite { grid-template-columns: minmax(0, 720px); justify-content: center; grid-template-rows: auto 1fr; grid-template-areas: 'marke' 'main'; row-gap: 32px; }
.vorschau .spalte { max-width: none; gap: 30px; }
.vorschau .intro { font-size: 2.2rem; line-height: 1.28; max-width: none; }
.vorschau .titel { font-size: 1.3rem; }


</style>
</head>
<body>
<div class="seite">
  <header class="marke">
    <b>Ida Wenninger</b>
    <span class="mono">Interaction Design · Würzburg <span data-uhr>09:41</span></span>
  </header>
  <aside class="demo" aria-label="Demo-Steuerung">
    <div class="schalter mono" role="group" aria-label="Variante vorführen">
      <button type="button" data-modus="glanz" aria-pressed="true">Glanz</button>
      <button type="button" data-modus="fuellung" aria-pressed="false">Füllung</button>
      <button type="button" data-modus="status" aria-pressed="false">Status</button>
    </div>
    <p class="werte mono" data-werte aria-live="polite"></p>
    <button type="button" class="thema mono" data-thema-knopf>Dunkel ansehen</button>
  </aside>

  <main class="spalte">
    <div class="intro-block">
      <h1 class="intro" data-glanz>Ida Wenninger gestaltet Oberflächen für Werkzeuge, die man <em>jeden Tag</em> benutzt: Kassen, Leitstände, Abfahrtsmonitore.</h1>
      <p class="intro leise" data-glanz>Selbstständig in Würzburg, seit 2015.</p>
    </div>

    <section aria-labelledby="h-arbeiten">
      <h2 id="h-arbeiten">Ausgewählte Arbeiten</h2>
      <ol class="liste" data-liste>
        <li class="zeile"><a href="#"><span class="titel">Leitstand Trinkwasser</span><span class="jahr">2026</span><span class="art">Kommunaler Versorger, 140 Messstellen</span></a></li>
        <li class="zeile"><a href="#"><span class="titel">Kasse für 38 Filialen</span><span class="jahr">2025</span><span class="art">Bäckerei-Verbund in Unterfranken</span></a></li>
        <li class="zeile"><a href="#"><span class="titel">Abfahrtsmonitor</span><span class="jahr">2024</span><span class="art">Regionaler Verkehrsverbund</span></a></li>
        <li class="zeile"><a href="#"><span class="titel">Selbstverbuchung</span><span class="jahr">2023</span><span class="art">Stadtbibliothek, sechs Standorte</span></a></li>
      </ol>
    </section>

    <section class="termin" aria-labelledby="h-termin">
      <h2 id="h-termin">Erstgespräch</h2>
      <div class="zeile1">
        <button type="button" class="knopf" data-suchen>Freie Termine suchen</button>
        <p class="status" role="status" aria-live="polite" data-status>30 Minuten, per Video oder in der Sanderstraße.</p>
      </div>
      <div class="termine" data-termine hidden></div>
    </section>
  </main>

  <footer class="fuss">
    <p class="mono">hallo@wenninger.design</p>
    <p class="mono">Atelier im Mainviertel, Würzburg</p>
  </footer>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#0b0b09" stroke="#fcfcfc" stroke-width="1.2" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const glanzZiele = [...document.querySelectorAll('[data-glanz]')], liste = $('[data-liste]'), zeilen = [...liste.querySelectorAll('.zeile')];
  const knopf = $('[data-suchen]'), status = $('[data-status]'), termine = $('[data-termine]');
  const werte = $('[data-werte]'), zeiger = $('[data-zeiger]');
  const RUHE = status.textContent;

  // Exakt die Werte aus dem CSS, für die Einblendung unten
  const WERTE = {
    glanz:    [['Dauer', '2000 ms'], ['Durchläufe', '1'], ['Kurve', 'linear'], ['Band', '42,5/50/57,5 %'], ['Größe', '200 %'], ['Weg', '150 → −50 %']],
    fuellung: [['Hin', '480 ms'], ['Zurück', '240 ms'], ['Kurve', 'easeOutQuart'], ['Kante', '42 → 58 %'], ['Größe', '250 %']],
    status:   [['Dauer', '2500 ms'], ['Durchläufe', 'endlos'], ['Kurve', 'linear'], ['Band', '30/55/70 %'], ['Spitze', 'mix 70 %'], ['Größe', '200 %']],
  };
  const TITEL = { glanz: 'Glanz beim Eintritt', fuellung: 'Füllung bei Hover', status: 'Status-Schimmer' };
  let modus = 'glanz';
  function setzeModus(m) {
    modus = m;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
    html.classList.toggle('demo-fuellung', m === 'fuellung');
    werte.innerHTML = reduziert()
      ? '<span class="kopfzeile">Reduzierte Bewegung</span><span>Glanz <b>aus</b></span><span>Füllung <b>sofort</b></span><span>Status <b>nur Text</b></span>'
      : `<span class="kopfzeile">${TITEL[m]}</span>` + WERTE[m].map(([k, v]) => `<span>${k} <b>${v}</b></span>`).join('');
  }

  // Variante 1: Glanz einmal über den Intro-Satz
  function glanz() {
    glanzZiele.forEach(el => { el.classList.remove('glanzt'); void el.offsetWidth; el.classList.add('glanzt'); });   // neu starten
  }
  glanzZiele.forEach(el => el.addEventListener('animationend', () => el.classList.remove('glanzt')));

  // Variante 3: Status schimmert, bis das Ergebnis da ist
  let suche = null;
  function suchen(dauer = 5000) {
    clearTimeout(suche);
    termine.hidden = true; termine.innerHTML = '';
    knopf.setAttribute('aria-busy', 'true');
    status.textContent = 'Durchsuche Kalender für Dezember …';
    status.classList.add('schimmer');
    suche = setTimeout(() => {
      status.classList.remove('schimmer');
      knopf.removeAttribute('aria-busy');
      status.textContent = 'Drei Termine frei, jeweils 9 Uhr:';
      termine.innerHTML = ['Di 8. Dez.', 'Do 10. Dez.', 'Mo 14. Dez.'].map(t => `<button type="button">${t}</button>`).join('');
      termine.hidden = false;
      termine.classList.remove('erscheint'); void termine.offsetWidth; termine.classList.add('erscheint');
    }, reduziert() ? 1200 : dauer);
  }
  function statusZurueck() {
    clearTimeout(suche);
    status.classList.remove('schimmer'); status.textContent = RUHE;
    knopf.removeAttribute('aria-busy'); termine.hidden = true; termine.innerHTML = '';
  }
  knopf.addEventListener('click', () => { if (knopf.getAttribute('aria-busy')) return; stoppVorfuehrung(); setzeModus('status'); suchen(); });

  // Echte Interaktion setzt die Werte-Anzeige mit
  liste.addEventListener('pointerenter', e => { if (e.pointerType === 'mouse' && !html.classList.contains('vorfuehrung')) setzeModus('fuellung'); });
  liste.addEventListener('click', e => e.preventDefault());

  // Umschalter
  document.querySelector('.schalter').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b) return;
    stoppVorfuehrung();
    spiele(b.dataset.modus, true);
  });
  function spiele(m, einzeln) {
    setzeModus(m);
    if (m === 'glanz') glanz();
    if (m === 'status') suchen();
    if (m === 'fuellung' && einzeln) fuellenNacheinander();
  }
  // Füllung ohne Zeiger (Umschalter, Touch): Zeilen nacheinander
  function fuellenNacheinander() {
    zeilen.forEach(z => z.classList.remove('an'));
    zeilen.forEach((z, k) => setTimeout(() => z.classList.add('an'), 120 + k * 140));
    // Mit Maus wieder leeren (Hover übernimmt); auf Touch gefüllt lassen
    if (matchMedia('(hover: hover)').matches) setTimeout(() => zeilen.forEach(z => z.classList.remove('an')), 2400);
  }

  // Thema mit View Transition (Rezept: Dark Mode gleichwertig)
  const themaKnopf = $('[data-thema-knopf]');
  const istDunkel = () => html.dataset.thema ? html.dataset.thema === 'dunkel' : matchMedia('(prefers-color-scheme: dark)').matches;
  const themaText = () => { themaKnopf.textContent = istDunkel() ? 'Hell ansehen' : 'Dunkel ansehen'; };
  themaKnopf.addEventListener('click', () => {
    const umschalten = () => { html.dataset.thema = istDunkel() ? 'hell' : 'dunkel'; themaText(); };
    if (document.startViewTransition && !reduziert()) document.startViewTransition(umschalten); else umschalten();
  });
  themaText();

  // Uhr Würzburg
  const uhr = $('[data-uhr]');
  const fmt = new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Berlin' });
  const tick = () => { uhr.textContent = fmt.format(new Date()); };
  tick(); setInterval(tick, 20000);

  // ---------- Vorführung: alle drei Varianten in Schleife ----------
  let lauf = 0, uhren = [];
  const warte = (ms, t) => new Promise((ok, nein) => uhren.push(setTimeout(() => (t === lauf ? ok() : nein('abbruch')), ms)));
  let zx = 0, zy = 0;
  function zeigerZu(el, dx, dy, ms = 600) {
    const r = el.getBoundingClientRect();
    const x = r.left + (dx ?? r.width * .6), y = r.top + (dy ?? r.height * .55);
    zeiger.animate([{ transform: `translate(${zx}px, ${zy}px)` }, { transform: `translate(${x}px, ${y}px)` }],
      { duration: ms, easing: 'cubic-bezier(0.165, 0.84, 0.44, 1)', fill: 'forwards' });
    zx = x; zy = y;
  }
  function stoppVorfuehrung() {
    lauf++; uhren.forEach(clearTimeout); uhren = [];
    html.classList.remove('vorfuehrung');
    zeilen.forEach(z => z.classList.remove('an'));
  }
  async function vorfuehren() {
    stoppVorfuehrung();
    const t = lauf;
    html.classList.add('vorfuehrung');
    const r = innerWidth, h = innerHeight;
    zx = r * .84; zy = h * .66;
    zeiger.animate([{ transform: `translate(${zx}px, ${zy}px)` }], { fill: 'forwards' });
    try {
      while (t === lauf) {
        statusZurueck();
        // 1 Glanz (kurze Ruhe davor, damit der Satz erst steht)
        setzeModus('glanz');
        await warte(1200, t);
        glanz();
        await warte(2600, t);
        // 2 Füllung per gedachtem Zeiger
        setzeModus('fuellung');
        for (let i = 0; i < zeilen.length; i++) {
          if (getComputedStyle(zeilen[i]).display === 'none') continue;
          const titel = zeilen[i].querySelector('.titel');
          zeigerZu(titel, titel.getBoundingClientRect().width * .35, null, i ? 420 : 700);
          await warte(i ? 180 : 420, t);
          zeilen.forEach(z => z.classList.toggle('an', z === zeilen[i]));
          await warte(900, t);
        }
        zeigerZu(knopf, null, null, 700);
        await warte(250, t);
        zeilen.forEach(z => z.classList.remove('an'));
        await warte(500, t);
        // 3 Status-Schimmer
        setzeModus('status');
        knopf.classList.add('drueckt'); await warte(140, t); knopf.classList.remove('drueckt');
        suchen(5000);
        zeigerZu(document.documentElement, r * .84, h * .66, 1100);   // Zeiger weg, der Status arbeitet allein
        await warte(5000 + 1800, t);
      }
    } catch (e) { /* abgebrochen */ }
  }
  function pause() { lauf++; uhren.forEach(clearTimeout); uhren = []; clearTimeout(suche); html.setAttribute('data-pausiert', ''); }
  function weiter() { html.removeAttribute('data-pausiert'); if (status.classList.contains('schimmer') && !html.classList.contains('vorfuehrung')) suchen(); if (html.classList.contains('vorschau') || html.classList.contains('vorfuehrung')) vorfuehren(); }
  function neustart() { stoppVorfuehrung(); html.removeAttribute('data-pausiert'); statusZurueck(); scrollTo(0, 0); setzeModus('glanz'); glanz(); }

  setzeModus('glanz');
  document.fonts.ready.then(() => { if (!html.classList.contains('vorfuehrung')) { setzeModus(modus); glanz(); } });

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