Zum Inhalt
← Schrift-Animationen

Wechselnde Wörter / Begrüßungs-Loader

Ein Wort im Claim rollt aus seiner Maske, der Satz passt sich weich an; dazu Liste mit Auswahlrahmen und Begrüßungs-Loader.

Ein Wort im Satz ist ein Slot: ‚Gebaut, um [Generationen] zu überdauern.‘ Der Slot hat eine Maske in Zeilenhöhe. Alle paar Sekunden gleitet das aktuelle Wort nach oben hinaus und das nächste von unten herein. Die Breite des Slots wandert weich auf die Breite des neuen Wortes, damit der Satz nicht springt. Als Loader erscheinen vor dem Seitenaufbau Grüße in mehreren Sprachen in schneller Folge. Danach fährt die Fläche nach oben weg.

Ein Satz trägt mehrere Botschaften, ohne länger zu werden. Die Bewegung lenkt den Blick genau auf das Wort, das sich ändert. Der Begrüßungs-Loader gibt der Wartezeit Persönlichkeit.

Wann einsetzen

Hero-Claims mit mehreren Leistungen, Zielgruppen oder Werten. Portfolio-Intros. Mehrsprachige oder international auftretende Marken (Loader).

Wann nicht

Wenn die Kernbotschaft nur in einem der Wörter steckt: Viele sehen nur das erste. Bei sehr langen Wörtern auf schmalen Bildschirmen. Einen Loader ohne echte Ladezeit nur kurz zeigen (unter 2 s).

Varianten

  • Wort-Slot: vertikales Rollen mit mitlaufender Breite
  • Liste mit Auswahlrahmen: die Wortliste läuft durch, Nachbarn bleiben blass sichtbar, ein Rahmen passt sich der Wortbreite an (Osmo ‚Looping Words with Selector‘)
  • Begrüßungs-Loader: Grüße in mehreren Sprachen, danach fährt die Fläche nach oben (Osmo ‚Welcoming Words Loader‘, Dennis Snellenberg)
  • 3D-Rotation: die Wörter kippen per rotateX um die Zeilenachse

Umsetzung

Slot: Block mit overflow: hidden und der Höhe einer Zeile, Wörter absolut gestapelt. Wortbreiten nach document.fonts.ready messen und in em speichern, dann skalieren sie mit. Pro Takt gleichzeitig: altes Wort translateY 0 → −100 %, neues 100 % → 0, Slot-Breite auf die neue Wortbreite. Mit GSAP als Timeline mit repeat: -1 und gsap.to(slot, { width }), ohne Bibliothek per Web Animations API. Liste mit Rahmen: drei Kopien der Liste in einer Spur, nach dem letzten Schritt unsichtbar auf die mittlere Kopie springen. Loader: erstes Wort 800 ms einblenden, dann je 120–200 ms ein Gruß, danach die Fläche in 800 ms power4.inOut von 0 auf −100 % nach oben ziehen.

Robust umsetzen

Telefon. Die Breite des Slots kann den Zeilenumbruch verschieben. Deshalb feste Zeilen setzen: den Slot auf eine eigene Zeile oder nur ein kurzes Wort dahinter. Den Loader als fixe Fläche über das ganze Fenster legen.

Ohne Bewegung. Keine Rotation: das erste Wort bleibt stehen, oder die Liste wird ausgeschrieben (‚Marken, Websites und Erlebnisse‘). Den Loader überspringen.

Barrierefrei. Für Screenreader den vollständigen Satz mit allen Begriffen als Text ausgeben, die animierten Wörter mit aria-hidden. Die Rotation muss pausierbar sein (WCAG 2.2.2). Der Loader ist aria-hidden und darf den Fokus nicht blockieren.

Code

<!DOCTYPE html>
<!--
  Wechselnde Wörter / Begrüßungs-Loader
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/rotating-words/
  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>Wechselnde Wörter / Begrüßungs-Loader · 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: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Bodoni Moda'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bodoni-moda:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 400 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: '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: Uhrenmanufaktur Behlert, Potsdam (Rezept „Manufaktur-Stille“).
   Anthrazit statt Schwarz, Weiß, Hellgrau für das Produkt, Messing nur als Linie. */
:root {
  --weiss: #ffffff; --flaeche: #f4f4f2; --anthrazit: #23282f; --meta: #6a6f76; --linie: #d9dadb; --messing: #a88a5a;
  --ein: cubic-bezier(0.22, 1, 0.36, 1);      /* power4.out, gemessen: Einblenden 800 ms */
  --ui: cubic-bezier(0.1, 0.6, 0.4, 1);       /* Rezept: UI-Token 150 ms */
  --zh: 1.18em;                                /* Zeilenhöhe des Wort-Slots */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--weiss); color: var(--anthrazit); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 1px solid var(--messing); outline-offset: 4px; }
.label { font: 500 .75rem/1.3 'Instrument Sans', system-ui, sans-serif; letter-spacing: .08em; text-transform: uppercase; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Bühne */
.buehne { position: relative; min-height: 100svh; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; }
.kopf { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 24px; padding: 0 clamp(16px, 3.4vw, 48px); height: clamp(60px, 7.5vh, 76px); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: baseline; gap: 12px; }
.marke b { font: 500 19px/1 'Bodoni Moda', serif; letter-spacing: .26em; }
.marke span { color: var(--meta); }
.nav { display: flex; gap: clamp(18px, 2.4vw, 36px); }
.nav a, .kopf-rechts a { transition: color .15s var(--ui); }
.nav a:hover, .kopf-rechts a:hover { color: var(--meta); }
.kopf-rechts { display: flex; justify-content: flex-end; gap: 24px; align-items: center; }
.kopf-rechts .termin { padding-bottom: 3px; border-bottom: 1px solid var(--messing); }

.held { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); gap: clamp(28px, 5vw, 96px); padding: clamp(20px, 3.4vw, 48px); padding-bottom: clamp(16px, 2.4vw, 32px); min-height: 0; }
.text { align-self: center; display: grid; gap: clamp(18px, 2.6vh, 30px); justify-items: start; padding-left: clamp(0px, 2vw, 32px); }
.text .label { color: var(--meta); display: flex; gap: 10px; align-items: center; }
.text > p.label::before { content: ''; width: 28px; height: 1px; background: var(--messing); }

/* Titel: Bodoni Moda, Werte aus dem Rezept */
.titel { font: 400 clamp(2.5rem, 5.5vw, 5.5rem)/1.05 'Bodoni Moda', 'Times New Roman', serif; font-optical-sizing: auto; letter-spacing: -.01em; }
.titel .z { display: block; }
.titel .z2 { display: flex; align-items: center; gap: .16em; line-height: var(--zh); }

/* Der Slot: Maske mit Höhe einer Zeile, Breite folgt dem Wort */
.slot { position: relative; display: block; flex: none; line-height: var(--zh); height: 1lh; overflow: hidden; }   /* 1lh: wächst mit, wenn Nutzer die Zeilenhöhe erhöhen (1.4.12) */
.slot .wort { position: absolute; left: 0; top: 0; line-height: var(--zh); white-space: nowrap; font-style: italic; padding-right: .1em; will-change: transform; }
.slot .wort[aria-hidden] { visibility: hidden; }
.slot.rolle { -webkit-mask-image: linear-gradient(transparent, #000 10%, #000 90%, transparent); mask-image: linear-gradient(transparent, #000 10%, #000 90%, transparent); }
.slot.rolle::after { content: ''; position: absolute; left: .02em; right: .1em; bottom: .16em; height: 1px; background: var(--messing); }
.messen { position: absolute; visibility: hidden; white-space: nowrap; font-style: italic; padding-right: .1em; }

/* Variante „Rahmen“: die ganze Liste läuft, Nachbarn bleiben blass sichtbar */
.slot.rahmen { height: calc(var(--zh) * 3); overflow: visible; }
/* Fenster breiter als der Slot, damit längere Nachbarn nicht abgeschnitten werden; Maske blendet oben und unten aus */
.fenster { position: absolute; inset: 0 -5em 0 0; overflow: hidden;
  -webkit-mask-image: linear-gradient(transparent, #000 30%, #000 70%, transparent); mask-image: linear-gradient(transparent, #000 30%, #000 70%, transparent); }
.spur { position: absolute; left: 0; top: 0; will-change: transform; }
.spur .zeile { display: block; height: var(--zh); line-height: var(--zh); white-space: nowrap; font-style: italic; padding-right: .1em; opacity: .2; transition: opacity .8s var(--ein); }
.spur .zeile.an { opacity: 1; }
.spur.sofort .zeile { transition: none; }
.auswahl { position: absolute; top: var(--zh); height: var(--zh); left: -.18em; right: -.08em; pointer-events: none;
  --c: var(--messing); --l: .2em;
  background:
    linear-gradient(var(--c), var(--c)) left top / var(--l) 1px no-repeat, linear-gradient(var(--c), var(--c)) left top / 1px var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) right top / var(--l) 1px no-repeat, linear-gradient(var(--c), var(--c)) right top / 1px var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) left bottom / var(--l) 1px no-repeat, linear-gradient(var(--c), var(--c)) left bottom / 1px var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) right bottom / var(--l) 1px no-repeat, linear-gradient(var(--c), var(--c)) right bottom / 1px var(--l) no-repeat; }

.lead { font-size: .9375rem; line-height: 1.47; color: #4b5058; max-width: 38ch; text-wrap: pretty; }
.aktionen { display: flex; flex-wrap: wrap; gap: 12px 28px; align-items: center; margin-top: 4px; }
.knopf { display: inline-flex; align-items: center; height: 48px; padding: 0 26px; background: var(--anthrazit); color: var(--weiss); transition: background .15s var(--ui); }
.knopf:hover { background: #3a414b; }
.verweis { display: inline-flex; gap: 10px; align-items: center; padding-bottom: 4px; border-bottom: 1px solid var(--linie); transition: border-color .15s var(--ui); }
.verweis:hover { border-color: var(--anthrazit); }

/* Produkt: Freisteller auf Hellgrau */
.produkt { position: relative; background: var(--flaeche); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; padding: clamp(16px, 2vw, 28px); min-height: 0; }
.produkt .oben, .produkt .unten { display: flex; justify-content: space-between; gap: 12px; color: var(--meta); font-variant-numeric: tabular-nums; }
.produkt .oben b { color: var(--anthrazit); font-weight: 500; }
.uhr { width: 100%; height: 100%; min-height: 0; max-height: 560px; place-self: center; filter: drop-shadow(0 28px 34px rgba(35, 40, 47, .16)) drop-shadow(0 4px 6px rgba(35, 40, 47, .12)); }
.sekunde { transform-box: view-box; transform-origin: 200px 342px; transition: transform .15s var(--ui); }

/* Steuerung der Demo (dezent) */
.steuer { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding: 12px clamp(16px, 3.4vw, 48px) 14px; border-top: 1px solid var(--linie); }
.werte { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.werte b { color: var(--anthrazit); font-weight: 500; }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--meta); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: color .15s var(--ui), background .15s var(--ui), border-color .15s var(--ui); }
.modi button:hover { color: var(--anthrazit); border-color: #b9bbbe; }
.modi button[aria-pressed='true'] { background: var(--anthrazit); color: var(--weiss); border-color: var(--anthrazit); }
.modi .halt { margin-left: 8px; }

/* Begrüßungs-Loader: Anthrazit-Fläche über dem ganzen Fenster, wie beim echten Seitenaufruf */
.loader { position: fixed; inset: 0; z-index: 20; background: var(--anthrazit); color: var(--weiss); display: grid; grid-template-rows: 1fr auto 1fr; justify-items: center; padding: clamp(20px, 3.4vw, 48px); transform: translateY(-100%); visibility: hidden; }
.loader.an { visibility: visible; transform: none; }
.loader .marke-klein { align-self: start; color: rgba(255, 255, 255, .5); }
.gruss { display: flex; align-items: center; gap: .45em; font: italic 400 clamp(2.5rem, 5.5vw, 4.75rem)/1.1 'Bodoni Moda', serif; font-optical-sizing: auto; letter-spacing: -.01em; }
.gruss::before { content: ''; width: .14em; height: .14em; border-radius: 50%; background: var(--messing); flex: none; }
.loader .zaehler { align-self: end; color: rgba(255, 255, 255, .5); font-variant-numeric: tabular-nums; }

/* Zweite Sektion: Anthrazit, Zahlen mit Tabellenziffern */
.kaliber { background: var(--anthrazit); color: var(--weiss); padding: clamp(72px, 14vh, 160px) clamp(16px, 3.4vw, 48px); display: grid; gap: clamp(40px, 7vh, 80px); }
.kaliber .label { color: rgba(255, 255, 255, .55); }
.kaliber h2 { font: 400 clamp(2rem, 4.2vw, 4rem)/1.05 'Bodoni Moda', serif; font-optical-sizing: auto; letter-spacing: -.01em; max-width: 16ch; margin-top: 18px; }
.kaliber h2 em { font-style: italic; }
.zahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid rgba(255, 255, 255, .16); }
.zahlen div { padding: 22px 20px 0 0; display: grid; gap: 10px; }
.zahlen b { font: 400 clamp(2rem, 3.6vw, 3.25rem)/1 'Bodoni Moda', serif; font-variant-numeric: tabular-nums lining-nums; }
.zahlen span { color: rgba(255, 255, 255, .55); }

@media (max-width: 1100px) { .nav { display: none; } .kopf { grid-template-columns: 1fr auto; } }
@media (max-width: 760px) {
  .buehne { min-height: 0; grid-template-rows: auto auto auto; }
  .held { grid-template-columns: 1fr; padding: 28px 16px 20px; }
  .text { padding-left: 0; }
  .produkt { height: 400px; }
  .kopf-rechts .sprache { display: none; }
  .marke span { display: none; }
  .zahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 28px; }
  .steuer { padding-inline: 16px; }
  .werte { order: 2; }
}

/* Reduzierte Bewegung: keine Rotation, kein Loader (JS), nur kurze Zustandswechsel */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (hover: none) { .slot { cursor: pointer; -webkit-tap-highlight-color: transparent; } }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .buehne { height: 100svh; }
.vorschau .kaliber { display: none; }
.vorschau .modi .halt, .vorschau .werte span:nth-child(n+5) { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="buehne" aria-label="Behlert, Uhrmacher in Potsdam">
  <header class="kopf">
    <a class="marke" href="#"><b>BEHLERT</b><span class="label">Potsdam</span></a>
    <nav class="nav label" aria-label="Hauptnavigation"><a href="#">Zeitmesser</a><a href="#">Manufaktur</a><a href="#">Boutique</a><a href="#">Service</a></nav>
    <div class="kopf-rechts label"><a class="sprache" href="#">DE</a><a class="termin" href="#">Termin</a></div>
  </header>

  <div class="held">
    <div class="text">
      <p class="label" data-auf>Kollektion Havel · Handaufzug</p>
      <h1 class="titel" data-auf>
        <span class="sr">Gebaut, um Generationen, Moden, Jahrzehnte, jeden Trend und uns alle zu überdauern.</span>
        <span aria-hidden="true">
          <span class="z">Gebaut, um</span>
          <span class="z z2"><span class="slot rolle" data-slot title="Tippen: nächstes Wort"></span><span>zu</span></span>
          <span class="z">überdauern.</span>
        </span>
      </h1>
      <p class="lead" data-auf>Ein Werk mit 72 Stunden Gangreserve, von einer Uhrmacherin von Anfang bis Ende gebaut. In unserer Werkstatt in der Mittelstraße entstehen im Jahr 240 Uhren.</p>
      <div class="aktionen label" data-auf>
        <a class="knopf" href="#">Termin in der Boutique</a>
        <a class="verweis" href="#">Kollektion ansehen <span aria-hidden="true">→</span></a>
      </div>
    </div>

    <figure class="produkt" data-auf>
      <div class="oben label"><b>Neu 2026</b><span>Ref. B-27.01</span></div>
      <svg class="uhr" viewBox="0 0 400 560" role="img" aria-label="Armbanduhr Havel 38: Edelstahlgehäuse, cremeweißes Zifferblatt, kleine Sekunde bei sechs Uhr, Lederband in Anthrazit">
        <defs>
          <linearGradient id="band" x1="0" x2="1"><stop offset="0" stop-color="#1b1f25"/><stop offset=".18" stop-color="#2d333c"/><stop offset=".82" stop-color="#2d333c"/><stop offset="1" stop-color="#1b1f25"/></linearGradient>
          <linearGradient id="stahl" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f7f8f9"/><stop offset=".35" stop-color="#b9bdc2"/><stop offset=".55" stop-color="#eef0f1"/><stop offset=".8" stop-color="#9ea3a9"/><stop offset="1" stop-color="#d7dadd"/></linearGradient>
          <linearGradient id="stahl2" x1="1" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e9ebed"/><stop offset=".5" stop-color="#a7acb2"/><stop offset="1" stop-color="#e3e5e7"/></linearGradient>
          <radialGradient id="blatt" cx=".5" cy=".42" r=".6"><stop offset="0" stop-color="#fdfcf8"/><stop offset=".75" stop-color="#f4f1e9"/><stop offset="1" stop-color="#e9e5da"/></radialGradient>
          <linearGradient id="zeiger" x1="0" x2="1"><stop offset="0" stop-color="#3a414b"/><stop offset=".5" stop-color="#23282f"/><stop offset=".5" stop-color="#11151a"/><stop offset="1" stop-color="#23282f"/></linearGradient>
          <linearGradient id="glas" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
        </defs>
        <!-- Band -->
        <path d="M136 0h128l-4 150H140z" fill="url(#band)"/>
        <path d="M140 410h120l4 150H136z" fill="url(#band)"/>
        <path d="M146 0l3 142M254 0l-3 142M149 418l-3 142M251 418l3 142" stroke="#8b9097" stroke-opacity=".55" stroke-dasharray="5 4"/>
        <!-- Bandanstöße -->
        <rect x="128" y="112" width="24" height="60" rx="6" fill="url(#stahl2)"/><rect x="248" y="112" width="24" height="60" rx="6" fill="url(#stahl2)"/>
        <rect x="128" y="388" width="24" height="60" rx="6" fill="url(#stahl2)"/><rect x="248" y="388" width="24" height="60" rx="6" fill="url(#stahl2)"/>
        <!-- Krone -->
        <rect x="344" y="266" width="20" height="28" rx="3" fill="url(#stahl2)"/>
        <path d="M349 268v24M353 268v24M357 268v24M361 268v24" stroke="#8f949a" stroke-width=".8"/>
        <!-- Gehäuse -->
        <circle cx="200" cy="280" r="152" fill="url(#stahl)"/>
        <circle cx="200" cy="280" r="145" fill="#c9ccd0"/>
        <circle cx="200" cy="280" r="142" fill="url(#stahl2)"/>
        <circle cx="200" cy="280" r="137" fill="url(#blatt)"/>
        <!-- Minuterie -->
        <circle cx="200" cy="280" r="128" fill="none" stroke="#23282f" stroke-opacity=".5" stroke-width=".6"/>
        <circle cx="200" cy="280" r="121" fill="none" stroke="#23282f" stroke-opacity=".5" stroke-width=".6"/>
        <g id="minuten" stroke="#23282f" stroke-opacity=".6" stroke-width=".8"></g>
        <g id="indizes" fill="url(#zeiger)"></g>
        <!-- Beschriftung -->
        <text x="200" y="208" text-anchor="middle" font-family="Bodoni Moda, serif" font-size="14" letter-spacing="3.6" fill="#23282f">BEHLERT</text>
        <text x="200" y="222" text-anchor="middle" font-family="Instrument Sans, sans-serif" font-size="6.4" letter-spacing="2.4" fill="#8b9097">POTSDAM</text>
        <text x="200" y="302" text-anchor="middle" font-family="Instrument Sans, sans-serif" font-size="5.6" letter-spacing="2" fill="#8b9097">HANDAUFZUG</text>
        <!-- Kleine Sekunde -->
        <circle cx="200" cy="342" r="31" fill="#efece3" stroke="#23282f" stroke-opacity=".35" stroke-width=".6"/>
        <circle cx="200" cy="342" r="24" fill="none" stroke="#23282f" stroke-opacity=".12" stroke-width=".5"/>
        <circle cx="200" cy="342" r="18" fill="none" stroke="#23282f" stroke-opacity=".12" stroke-width=".5"/>
        <g id="sekticks" stroke="#23282f" stroke-opacity=".55" stroke-width=".7"></g>
        <g class="sekunde" data-sekunde><path d="M199.4 349h1.2l-.25-34h-.7z" fill="#a88a5a"/><circle cx="200" cy="342" r="2.2" fill="#a88a5a"/></g>
        <!-- Zeiger 10:09 -->
        <g transform="rotate(304.5 200 280)"><path d="M200 196c4 14 5.5 46 5 84h-10c-.5-38 1-70 5-84z" fill="url(#zeiger)"/></g>
        <g transform="rotate(54 200 280)"><path d="M200 162c3 18 4 70 3.6 118h-7.2c-.4-48 .6-100 3.6-118z" fill="url(#zeiger)"/></g>
        <circle cx="200" cy="280" r="6" fill="#23282f"/><circle cx="200" cy="280" r="2" fill="#a88a5a"/>
        <!-- Glas -->
        <path d="M95 210a128 128 0 0 1 210 0a240 160 0 0 0-210 0z" fill="url(#glas)" opacity=".7"/>
      </svg>
      <figcaption class="unten label"><span>Havel 38</span><span>Edelstahl · Ø 38,5 mm</span></figcaption>
    </figure>
  </div>

  <div class="steuer">
    <p class="werte mono" data-werte aria-live="polite"></p>
    <div class="modi mono" role="group" aria-label="Variante">
      <button type="button" data-modus="rolle" aria-pressed="true">Slot</button>
      <button type="button" data-modus="rahmen" aria-pressed="false">Rahmen</button>
      <button type="button" data-modus="loader" aria-pressed="false">Begrüßung</button>
      <button type="button" class="halt" data-halt aria-pressed="false">Anhalten</button>
    </div>
  </div>

  <div class="loader" data-loader aria-hidden="true">
    <p class="marke-klein label">Behlert · Uhrmacher in Potsdam</p>
    <p class="gruss"><span data-gruss>Guten Tag</span></p>
    <p class="zaehler label"><span data-zahl>01</span> / 09</p>
  </div>
</section>

<section class="kaliber">
  <div>
    <p class="label">Kaliber B-27 · Handaufzug</p>
    <h2>Ein Werk, das man <em>hören</em> kann.</h2>
  </div>
  <div class="zahlen">
    <div><b>72 h</b><span class="label">Gangreserve</span></div>
    <div><b>21.600</b><span class="label">Halbschwingungen pro Stunde</span></div>
    <div><b>167</b><span class="label">Einzelteile</span></div>
    <div><b>38,5 mm</b><span class="label">Durchmesser</span></div>
  </div>
</section>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');

  // Gemessen bei Dennis Snellenberg (Loader) und als Kurve des Musters:
  // power4.out = cubic-bezier(0.22, 1, 0.36, 1), power4.inOut = cubic-bezier(0.83, 0, 0.17, 1)
  const EIN = 'cubic-bezier(0.22, 1, 0.36, 1)';
  const INOUT = 'cubic-bezier(0.83, 0, 0.17, 1)';
  const DAUER = 800;                    // Ein/Aus und Wortwechsel im Slot
  const TAKT = { rolle: 2400, rahmen: 2000, loader: 2400 };
  const PRO_WORT = 150;                 // Loader: je Gruß
  const ZH = 1.18;                      // Zeilenhöhe des Slots in em (= --zh)

  const WOERTER = ['Generationen', 'Moden', 'Jahrzehnte', 'jeden Trend', 'uns alle'];
  const GRUESSE = ['Guten Tag', 'Bonjour', 'Goedendag', 'Dobrý den', 'Buongiorno', 'Good day', 'God dag', 'Bom dia', 'Willkommen'];

  const slot = document.querySelector('[data-slot]');
  const werte = document.querySelector('[data-werte]');
  const loader = document.querySelector('[data-loader]');
  const gruss = document.querySelector('[data-gruss]');
  const zahl = document.querySelector('[data-zahl]');
  const haltKnopf = document.querySelector('[data-halt]');
  const auf = [...document.querySelectorAll('[data-auf]')];

  let modus = 'rolle', idx = 0, breiten = [], uhr = null, regie = 0, fuehrt = false, angehalten = false, nutzerHalt = false;

  // ---------- Hilfen ----------
  const warte = (ms, g) => new Promise((ok, nein) => setTimeout(() => (g === regie ? ok() : nein('abgebrochen')), ms));
  function spiele(el, frames, opt) {           // WAAPI, Endzustand fest übernehmen
    const a = el.animate(frames, { duration: DAUER, easing: EIN, fill: 'forwards', ...opt });
    return a.finished.then(() => { try { a.commitStyles(); } catch (e) {} a.cancel(); }).catch(() => {});
  }

  // Breiten aller Wörter in em messen (erst nach fonts.ready, sonst falsch)
  function messen() {
    const m = document.createElement('span');
    m.className = 'messen';
    slot.parentNode.appendChild(m);
    const fs = parseFloat(getComputedStyle(slot).fontSize);
    breiten = WOERTER.map(w => { m.textContent = w; return m.getBoundingClientRect().width / fs; });
    m.remove();
  }
  const em = i => breiten[i].toFixed(3) + 'em';

  // ---------- Variante 1: Wort-Slot ----------
  function baueRolle() {
    slot.className = 'slot rolle';
    slot.innerHTML = WOERTER.map((w, i) => `<span class="wort"${i === idx ? '' : ' aria-hidden="true"'}>${w}</span>`).join('');
    slot.querySelectorAll('.wort').forEach((w, i) => { w.style.transform = i === idx ? 'none' : 'translateY(100%)'; });
    slot.style.width = em(idx);
  }
  function schrittRolle() {
    const woerter = slot.querySelectorAll('.wort');
    const alt = woerter[idx], neu = (idx + 1) % WOERTER.length, nw = woerter[neu];
    nw.removeAttribute('aria-hidden');
    spiele(alt, [{ transform: 'translateY(0)', opacity: 1 }, { transform: 'translateY(-100%)', opacity: 0 }]).then(() => { alt.setAttribute('aria-hidden', 'true'); alt.style.opacity = ''; });
    spiele(nw, [{ transform: 'translateY(100%)' }, { transform: 'translateY(0)' }]);
    spiele(slot, [{ width: em(idx) }, { width: em(neu) }]);
    idx = neu;
  }

  // ---------- Variante 2: Liste mit Auswahlrahmen ----------
  const N = WOERTER.length;
  let pos = 0;                                   // Zeile in der Spur (drei Kopien der Liste)
  function spurY(p) { return `translateY(${(-(p - 1) * ZH).toFixed(3)}em)`; }
  function baueRahmen() {
    slot.className = 'slot rahmen';
    const zeilen = [...WOERTER, ...WOERTER, ...WOERTER].map(w => `<span class="zeile">${w}</span>`).join('');
    slot.innerHTML = `<span class="fenster"><span class="spur sofort">${zeilen}</span></span><span class="auswahl"></span>`;
    pos = N + idx;
    const spur = slot.querySelector('.spur');
    spur.style.transform = spurY(pos);
    spur.children[pos].classList.add('an');
    slot.style.width = em(idx);
    requestAnimationFrame(() => requestAnimationFrame(() => spur.classList.remove('sofort')));
  }
  function schrittRahmen() {
    const spur = slot.querySelector('.spur');
    const neu = (idx + 1) % N;
    spur.children[pos].classList.remove('an');
    spur.children[pos + 1].classList.add('an');
    const ziel = pos + 1;
    spiele(spur, [{ transform: spurY(pos) }, { transform: spurY(ziel) }]).then(() => {
      if (ziel >= 2 * N) {                       // unsichtbar auf die mittlere Kopie zurückspringen
        spur.classList.add('sofort');
        spur.children[ziel].classList.remove('an');
        pos = ziel - N;
        spur.children[pos].classList.add('an');
        spur.style.transform = spurY(pos);
        requestAnimationFrame(() => requestAnimationFrame(() => spur.classList.remove('sofort')));
      }
    });
    spiele(slot, [{ width: em(idx) }, { width: em(neu) }]);
    pos = ziel; idx = neu;
  }

  function schritt() { if (modus === 'rahmen') schrittRahmen(); else schrittRolle(); }
  function plane() {
    clearTimeout(uhr);
    if (fuehrt || angehalten || nutzerHalt || reduziert()) return;
    uhr = setTimeout(() => { schritt(); plane(); }, TAKT[modus]);
  }

  // ---------- Variante 3: Begrüßungs-Loader ----------
  let loaderG = 0;
  async function spieleLoader(g) {
    const meinG = ++loaderG;
    const ok = () => meinG === loaderG;
    scrollTo(0, 0);                              // wie ein frischer Seitenaufruf
    loader.classList.add('an');
    loader.style.transform = 'none';
    gruss.textContent = GRUESSE[0]; zahl.textContent = '01';
    auf.forEach(el => { el.style.transform = 'translateY(40px)'; });
    // erstes Wort: 800 ms power4.out
    await spiele(gruss, [{ opacity: 0, transform: 'translateY(.25em)' }, { opacity: 1, transform: 'none' }]);
    if (!ok()) return;
    for (let i = 1; i < GRUESSE.length; i++) {   // dann je 150 ms ein Gruß
      await new Promise(r => setTimeout(r, PRO_WORT));
      if (!ok()) return;
      gruss.textContent = GRUESSE[i]; zahl.textContent = String(i + 1).padStart(2, '0');
    }
    await new Promise(r => setTimeout(r, 450));
    if (!ok()) return;
    // Ausfahrt: 800 ms power4.inOut nach oben, Inhalt steigt im 150-ms-Versatz nach
    const raus = spiele(loader, [{ transform: 'translateY(0)' }, { transform: 'translateY(-100%)' }], { easing: INOUT });
    auf.forEach((el, i) => spiele(el, [{ transform: 'translateY(40px)' }, { transform: 'translateY(0)' }], { delay: 350 + i * PRO_WORT }));
    await raus;
    if (!ok()) return;
    loader.classList.remove('an');
  }
  function loaderWeg() {
    loaderG++;
    loader.getAnimations().forEach(a => a.cancel());
    gruss.getAnimations().forEach(a => a.cancel());
    loader.classList.remove('an'); loader.style.transform = '';
    auf.forEach(el => { el.getAnimations().forEach(a => a.cancel()); el.style.transform = ''; });
  }

  // ---------- Modus und Werte ----------
  function zeigeWerte() {
    if (reduziert()) {
      werte.innerHTML = '<span>Reduzierte Bewegung: <b>Wort statisch, kein Loader</b></span>';
      return;
    }
    const w = {
      rolle: `<span>Kurve <b>power4.out</b></span><span>Dauer <b>${DAUER} ms</b></span><span>Takt <b>${TAKT.rolle} ms</b></span><span>Weg <b>100 → −100 %</b></span><span>Breite <b>mit</b></span>`,
      rahmen: `<span>Kurve <b>power4.out</b></span><span>Schritt <b>${DAUER} ms</b></span><span>Takt <b>${TAKT.rahmen} ms</b></span><span>Nachbarn <b>20 %</b></span><span>Rahmen <b>mit</b></span>`,
      loader: `<span>Kurve <b>power4</b></span><span>Ein <b>${DAUER} ms out</b></span><span>Gruß <b>${PRO_WORT} ms</b></span><span>Aus <b>${DAUER} ms inOut</b></span><span>Weg <b>0 → −100 %</b></span>`,
    };
    werte.innerHTML = w[modus];
  }
  function setzeModus(neu, mitLoader = true) {
    modus = neu;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
    clearTimeout(uhr);
    loaderWeg();
    slot.getAnimations({ subtree: true }).forEach(a => a.cancel());
    if (modus === 'rahmen') baueRahmen(); else baueRolle();
    zeigeWerte();
    if (modus === 'loader' && mitLoader && !reduziert()) return spieleLoader();
    return Promise.resolve();
  }

  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b) return;
    regie++; fuehrt = false;                   // Vorführung beenden, Nutzer übernimmt
    setzeModus(b.dataset.modus).then(plane);
  });
  haltKnopf.addEventListener('click', () => {   // WCAG 2.2.2: Rotation anhalten
    nutzerHalt = !nutzerHalt;
    haltKnopf.setAttribute('aria-pressed', String(nutzerHalt));
    haltKnopf.textContent = nutzerHalt ? 'Weiter' : 'Anhalten';
    if (nutzerHalt) { regie++; fuehrt = false; clearTimeout(uhr); } else plane();
  });
  // Tippen oder Klicken auf das Wort: sofort weiter
  slot.addEventListener('click', () => { if (reduziert() || loader.classList.contains('an')) return; regie++; fuehrt = false; schritt(); plane(); });

  // ---------- Kleine Sekunde (Detail, läuft echt) ----------
  const sek = document.querySelector('[data-sekunde]');
  let grad = new Date().getSeconds() * 6;
  sek.style.transform = `rotate(${grad}deg)`;
  setInterval(() => { if (angehalten || reduziert()) return; grad += 6; sek.style.transform = `rotate(${grad}deg)`; }, 1000);
  (function zifferblatt() {
    const ns = 'http://www.w3.org/2000/svg';
    const g = document.getElementById('minuten'), ind = document.getElementById('indizes'), st = document.getElementById('sekticks');
    for (let i = 0; i < 60; i++) {
      const l = document.createElementNS(ns, 'line');
      l.setAttribute('x1', 200); l.setAttribute('x2', 200); l.setAttribute('y1', 280 - 128); l.setAttribute('y2', 280 - 121);
      l.setAttribute('transform', `rotate(${i * 6} 200 280)`); g.appendChild(l);
      if (i % 5 === 0) {
        const t = document.createElementNS(ns, 'line');
        t.setAttribute('x1', 200); t.setAttribute('x2', 200); t.setAttribute('y1', 342 - 31); t.setAttribute('y2', 342 - (i % 15 ? 27 : 24));
        t.setAttribute('transform', `rotate(${i * 6} 200 342)`); st.appendChild(t);
      }
    }
    for (let h = 0; h < 12; h++) {
      if (h === 6) continue;
      const mk = dx => { const r = document.createElementNS(ns, 'rect'); r.setAttribute('x', 198.4 + dx); r.setAttribute('y', 280 - 116); r.setAttribute('width', 3.2); r.setAttribute('height', h % 3 ? 16 : 22); r.setAttribute('transform', `rotate(${h * 30} 200 280)`); ind.appendChild(r); };
      if (h === 0) { mk(-3.4); mk(3.4); } else mk(0);
    }
  })();

  // ---------- Vorführung: alle drei Varianten im Wechsel ----------
  const bereit = document.fonts.ready.then(messen);
  async function vorfuehren() {
    const g = ++regie;
    fuehrt = true; angehalten = false; clearTimeout(uhr);
    await bereit;
    try {
      while (g === regie) {
        if (reduziert()) {                       // statische Zustände nacheinander zeigen
          for (const m of ['rolle', 'rahmen', 'loader']) { setzeModus(m, false); await warte(2600, g); }
          continue;
        }
        await setzeModus('rolle');
        await warte(1200, g);
        for (let i = 0; i < 3; i++) { schritt(); await warte(TAKT.rolle, g); }
        await setzeModus('rahmen');
        await warte(1000, g);
        for (let i = 0; i < 3; i++) { schritt(); await warte(TAKT.rahmen, g); }
        await setzeModus('loader');
        if (g !== regie) return;
        await warte(1000, g);
        schritt(); await warte(TAKT.loader, g);
      }
    } catch (e) { /* abgebrochen */ }
  }
  function neustart() {
    regie++; fuehrt = false; angehalten = false; idx = 0; scrollTo(0, 0);
    bereit.then(() => setzeModus(modus)).then(plane);
  }
  let warFuehrung = false;
  function pause() {
    warFuehrung = fuehrt;
    regie++; loaderG++; fuehrt = false;          // Abläufe stoppen, aktuelles Bild einfrieren
    angehalten = true; clearTimeout(uhr);
    document.getAnimations().forEach(a => a.pause());
  }
  function weiter() {
    angehalten = false;
    document.getAnimations().forEach(a => a.play());
    if (warFuehrung) return vorfuehren();
    if (loader.classList.contains('an')) loaderWeg();
    plane();
  }

  bereit.then(() => {
    if (fuehrt) return;
    setzeModus('rolle');
    plane();
    let b = innerWidth;
    addEventListener('resize', () => { if (innerWidth === b) return; b = innerWidth; messen(); slot.style.width = em(idx); });
    // Eigene Textabstände (WCAG 1.4.12): ändert sich die Wortbreite, Slot neu messen, sonst wird das Wort abgeschnitten
    const probe = document.createElement('span');
    probe.className = 'messen'; probe.setAttribute('aria-hidden', 'true'); probe.textContent = WOERTER[0];
    slot.parentNode.appendChild(probe);
    let pw = 0;
    new ResizeObserver(() => { const w = probe.getBoundingClientRect().width, erst = !pw; if (Math.abs(w - pw) < .5) return; pw = w; if (!erst) { messen(); slot.style.width = em(idx); } }).observe(probe);
  });

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