Scramble/Decode-Text (Zeichen entschlüsseln sich)
Zeichen rauschen in Akzentfarbe durch und rasten nacheinander ein: als Decode beim Laden, als Welle oder mit wachsendem Balken beim Hover.
Ein Text erscheint zuerst als zufällige Zeichen, etwa Versalien, Ziffern oder Symbole. Die Zeichen wechseln in schnellem Takt. Dann rastet von links nach rechts nach und nach das echte Zeichen ein, zeichenweise oder wortweise. Optional steht das Rauschen kurz still (revealDelay), bevor das Auflösen beginnt.
Der Effekt sieht aus wie Daten, die geladen oder entschlüsselt werden. Er erzeugt Spannung und lenkt den Blick auf Labels, ohne das Layout zu bewegen. Auf kleiner Fläche entsteht viel Charakter. Er passt zur KI- und Tech-Ästhetik und signalisiert Code-Handwerk.
Wann einsetzen
Navigation, Buttons und Labels in technischen, monospace-lastigen Designs. Metaangaben wie Datum, Koordinaten oder Projektnummern. Kurze Headlines beim Laden, Statuszeilen in Preloadern. Passt zu Tech-, KI-, Krypto- und Gaming-Marken sowie zu Developer- und Studio-Portfolios.
Wann nicht
Bei Fließtext und langen Textblöcken: unlesbar und anstrengend. Bei warmen, handwerklichen, emotionalen oder luxuriösen Marken. Bei proportionalen Schriften ohne feste Zeichenbreite, weil der Text springt. Als Dauerschleife nervt der Effekt.
Varianten
- Decode: Zeile ist mit Rauschen vorbefüllt, Zeichen rasten links nach rechts ein (GSAP ScrambleTextPlugin oder Eigenbau)
- Welle (Terminal-Hover nach Codrops/jeandawson.com): Zeichen erscheinen nacheinander mit 60–80 ms Versatz, 2–3 Wiederholungen mit Zufallszeichen, dann das echte Zeichen
- Balken: zusätzlich wächst ein Hintergrundbalken von 0 auf 100 % Breite (1 s expo.out)
- Zufallsfarben während des Scrambles
- Mit Clip-Wipe parallel (Thibault Guignand: Wipe 0,6 s power2.out)
- Mehrere Zeilen mit eigener Dauer und Verzögerung je Zeile (Gabriel Contassot)
- Wortweise statt zeichenweise
- Scramble am Cursor: nur Zeichen im Umkreis des Zeigers (siehe cursor-text-proximity)
- Preloader mit monospace Statuszeilen in snake_case (launching_…, verifying_…)
- Shuffle in den Initialen von Menüpunkten oder über ganze Absätze im LCD-Look (Codrops LetterShuffleMenu, TypeShuffleAnimation)
- Auslöser: Hover auf Links und Buttons (400–600 ms), Eintritt in den Viewport oder Laden
Umsetzung
GSAP: gsap.to(el, { duration: 1, scrambleText: { text: '{original}', chars: 'upperCase', revealDelay: .3, speed: .4, tweenLength: false } }). Das Plugin ist seit 2025 kostenlos.
Eigenbau: ein requestAnimationFrame-Takt. Pro Zeichen eine Startzeit (i × Versatz). Davor ein Zufallszeichen aus dem Zeichensatz, im festen Takt von 30 ms neu gewürfelt, danach das Zielzeichen. Leerzeichen überspringen. Das DOM nur ändern, wenn sich der Zustand eines Zeichens ändert.
Welle nach Codrops: chars.forEach((c, i) => gsap.fromTo(c, { opacity: 0 }, { duration: .03, repeat: 3, repeatRefresh: true, repeatDelay: .04, delay: (i + 1) * .07, innerHTML: () => pool[random], opacity: 1, onComplete: () => gsap.set(c, { innerHTML: orig[i], delay: .03 }) })).
Mehrere Zeilen: Arrays aus Dauer und Verzögerung je Zeile statt eines einheitlichen Staggers wirken organischer.
Fallstricke: Proportionale Schrift springt in der Breite. Monospace oder font-variant-numeric: tabular-nums nutzen, sonst Breite sperren (inline-block, min-width). Wörter in white-space: nowrap fassen, damit kein Umbruch mitten im Wort entsteht. Bei Hover laufende Tweens vor dem Start abbrechen (overwrite bzw. killTweensOf) und das Original wiederherstellen, sonst stottert der Text oder es bleiben Zufallszeichen stehen.
Robust umsetzen
Telefon. Die Hover-Variante entfällt. Stattdessen beim Tippen oder einmal beim Eintritt in den Viewport auslösen. Lade-Scramble für Headlines bleibt unverändert.
Ohne Bewegung. Kein Scramble, Text sofort im Endzustand. Hover-Feedback über Farbwechsel oder Unterstreichung.
Barrierefrei. Screenreader dürfen die Zufallszeichen nicht vorlesen. Den echten Text als aria-label (Links, Buttons) oder visuell versteckten Text bereitstellen, die animierte Ebene mit aria-hidden versehen. Keine Live-Region auf dem animierten Text. Große Flächen nicht stroboskopartig wechseln, Flackern über größere Flächen unter 3 Hz halten (WCAG 2.3.1).
Code
<!DOCTYPE html>
<!--
Scramble/Decode-Text (Zeichen entschlüsseln sich)
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/scramble-decode/
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>Scramble/Decode-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: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
--grund: #0d0f0f; --text: #eef0ec; --leise: rgba(238, 240, 236, .55); --linie: rgba(238, 240, 236, .13);
--akzent: #5fd3c4;
--kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out, Balken auf */
--kurve-zu: cubic-bezier(0.22, 1, 0.36, 1); /* power4.out, Balken zu */
--mono: 'Geist Mono', ui-monospace, monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: hidden; }
a { color: inherit; text-decoration: none; }
.mono { font: 500 11px/1.35 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* ---------- Bühne ---------- */
.buehne { min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr auto; gap: clamp(18px, 3vh, 32px); padding: clamp(16px, 3.4vw, 40px); position: relative; }
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--linie); padding-bottom: 16px; }
.marke { font: 600 13px/1 var(--mono); letter-spacing: .04em; color: var(--text); text-transform: uppercase; white-space: nowrap; }
.marke span { color: var(--akzent); }
.nav { display: flex; gap: clamp(14px, 2.4vw, 32px); }
.nav a { color: var(--text); padding: 6px 0; }
.koord { text-align: right; white-space: nowrap; }
.leiste { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background-color .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: rgba(238, 240, 236, .3); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible, .zeile:focus-visible, .nav a:focus-visible { outline: 2px solid var(--akzent); outline-offset: 3px; }
.inhalt { display: grid; grid-template-columns: 1fr; align-content: center; gap: clamp(22px, 4vh, 44px); }
.oben { display: grid; grid-template-columns: minmax(0, 1fr) clamp(220px, 27vw, 300px); gap: clamp(20px, 3vw, 40px); align-items: end; }
.titel { font: 500 clamp(38px, 9vw, 132px)/.94 var(--mono); letter-spacing: -.05em; text-transform: uppercase; }
.titel .z-block { display: block; }
.intro { font-size: clamp(15px, 1.25vw, 17px); line-height: 1.45; color: var(--leise); max-width: 34ch; padding-bottom: .4em; }
.intro em { font: italic 400 1.18em/1 'Instrument Serif', Georgia, serif; color: var(--text); }
.nw { white-space: nowrap; }
.unten { display: grid; grid-template-columns: minmax(0, 1fr) clamp(220px, 27vw, 300px); gap: clamp(20px, 3vw, 40px); align-items: end; }
.liste { list-style: none; border-top: 1px solid var(--linie); }
.zeile { display: grid; grid-template-columns: 3.2ch minmax(0, 1fr) auto 4ch; gap: 16px; align-items: baseline; padding: 13px 0; border-bottom: 1px solid var(--linie); cursor: pointer; color: var(--leise); transition: color .15s; }
.zeile .nr, .zeile .ort, .zeile .jahr { font: 500 11px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; }
.zeile .projekt { font: 500 clamp(15px, 1.7vw, 19px)/1.2 var(--mono); letter-spacing: -.01em; text-transform: uppercase; color: var(--text); }
.zeile .jahr { text-align: right; }
.zeile.aktiv { color: var(--text); }
.zeile.aktiv .nr { color: var(--akzent); }
.karte { display: grid; gap: 10px; }
.bild { aspect-ratio: 3 / 2; overflow: hidden; background: #1a1d1d; position: relative; }
.bild img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .15s linear; }
.bild img.an { opacity: 1; }
.karte .mono b { color: var(--text); font-weight: 500; }
.fuss { display: flex; justify-content: space-between; align-items: end; gap: 10px 24px; flex-wrap: wrap; border-top: 1px solid var(--linie); padding-top: 14px; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.werte b { color: var(--text); font-weight: 500; }
.hinweis-touch { display: none; }
/* ---------- Scramble ---------- */
.z { white-space: nowrap; } /* Wort bricht nie mitten durch */
.zc { display: inline; }
.zc.zufall { color: var(--akzent); } /* Rauschen in Akzentfarbe */
.zc.weg { visibility: hidden; }
/* Balken: Hintergrund wächst von 0 auf 100 % Breite */
.z-ziel { background: linear-gradient(var(--akzent), var(--akzent)) no-repeat left center / 0% 100%; -webkit-box-decoration-break: clone; box-decoration-break: clone; transition: background-size .6s var(--kurve-zu), color .6s var(--kurve-zu); }
.z-ziel.balken-an { background-size: 100% 100%; color: var(--grund); transition: background-size 1s var(--kurve), color .15s linear; }
.z-ziel.balken-an .zc.zufall { color: rgba(13, 15, 15, .55); }
.z-ziel.balken-zu { background-position: right center; }
.titel .z-ziel { padding: 0 .06em; margin: 0 -.06em; }
/* ---------- Abschnitt unter der Bühne ---------- */
.weiter { border-top: 1px solid var(--linie); padding: clamp(80px, 16vh, 180px) clamp(16px, 3.4vw, 40px) 26vh; display: grid; gap: 28px; }
.satz { font: 500 clamp(30px, 6vw, 72px)/1 var(--mono); letter-spacing: -.04em; text-transform: uppercase; max-width: 18ch; }
.knopf { justify-self: start; font: 500 12px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; border: 1px solid var(--linie); border-radius: 999px; padding: 15px 20px; transition: border-color .15s; }
.knopf:hover { border-color: var(--akzent); }
/* gedachter Zeiger für die Vorführung */
.zeiger { position: fixed; left: 0; top: 0; width: 14px; height: 14px; border-radius: 50%; background: var(--text); mix-blend-mode: difference; pointer-events: none; opacity: 0; transition: opacity .3s linear; z-index: 9; }
.zeiger.an { opacity: 1; }
/* ---------- Telefon ---------- */
@media (max-width: 760px) {
.nav, .koord { display: none; }
.unten { grid-template-columns: 1fr; }
.karte { order: -1; grid-template-columns: 112px 1fr; align-items: end; gap: 14px; }
.zeile { grid-template-columns: 3ch minmax(0, 1fr) 4ch; gap: 12px; }
.zeile .ort { display: none; }
.oben { grid-template-columns: 1fr; gap: 16px; }
}
@media (hover: none) {
.hinweis-maus { display: none; }
.hinweis-touch { display: inline; }
}
/* ---------- Reduzierte Bewegung: kein Rauschen, Farbwechsel statt Balken ---------- */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
:root.reduziert .z-ziel.balken-an { background-size: 0 100%; color: var(--akzent); }
:root.reduziert .nav a:hover, :root.reduziert .knopf:hover, :root.reduziert .zeile.aktiv .projekt { color: var(--akzent); }
/* ---------- Galerie-Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .weiter { display: none; }
.vorschau .buehne { height: 100svh; }
</style>
</head>
<body>
<section class="buehne">
<header class="kopf">
<a class="marke" href="#">Werkraum<span>/</span>09</a>
<nav class="nav mono" aria-label="Hauptnavigation">
<a href="#" data-scramble="hover">Arbeiten</a>
<a href="#" data-scramble="hover">Studio</a>
<a href="#" data-scramble="hover">Labor</a>
<a href="#" data-scramble="hover">Kontakt</a>
</nav>
<p class="koord mono" data-scramble="laden">51.3289° N · 12.3326° E</p>
</header>
<div class="leiste">
<p class="mono">Studio für Marken und Interfaces · <span class="nw">Leipzig-Plagwitz</span></p>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-modus="decode" aria-pressed="true">Decode</button>
<button type="button" data-modus="welle" aria-pressed="false">Welle</button>
<button type="button" data-modus="balken" aria-pressed="false">Balken</button>
</div>
</div>
<div class="inhalt">
<div class="oben">
<h1 class="titel"><span data-scramble="titel" data-zeilen="Aus Rauschen|wird Marke.">Aus Rauschen wird Marke.</span></h1>
<p class="intro">Wir gestalten Zeichen, Editorial und Interfaces für Häuser, die etwas zu sagen haben. Sechs Menschen in einem Hinterhof, <em>seit 2017.</em></p>
</div>
<div class="unten">
<ul class="liste" aria-label="Ausgewählte Arbeiten">
<li class="zeile aktiv" tabindex="0" data-bild="gefuege" data-titel="Gefüge 04/2025"><span class="nr">01</span><span class="projekt" data-scramble="zeile">Gefüge Editorial</span><span class="ort">Berlin</span><span class="jahr">2025</span></li>
<li class="zeile" tabindex="0" data-bild="roestkontor" data-titel="Kaffeerösterei, Marke"><span class="nr">02</span><span class="projekt" data-scramble="zeile">Röstkontor</span><span class="ort">Bremen</span><span class="jahr">2024</span></li>
<li class="zeile" tabindex="0" data-bild="eselsohr" data-titel="Wortbildmarke"><span class="nr">03</span><span class="projekt" data-scramble="zeile">Eselsohr Lesecafé</span><span class="ort">Halle</span><span class="jahr">2024</span></li>
<li class="zeile" tabindex="0" data-bild="licht" data-titel="Plakat, Halle 4"><span class="nr">04</span><span class="projekt" data-scramble="zeile">Licht Ausstellung</span><span class="ort">Leipzig</span><span class="jahr">2026</span></li>
</ul>
<figure class="karte">
<div class="bild">
<img class="an" src="bilder/magazin-doppelseite.jpg" alt="Gefüge: Doppelseite „Die Ruhe der Dinge“ aus Ausgabe 04/2025" data-name="gefuege">
<img src="bilder/marke-roestkontor.jpg" alt="Röstkontor: Wortmarke in Creme auf Ziegelrot mit Kaffeebohne" data-name="roestkontor" loading="lazy">
<img src="bilder/marke-eselsohr.jpg" alt="Eselsohr: Buchseite mit rosa Eselsohr und Schriftzug auf Anthrazit" data-name="eselsohr" loading="lazy">
<img src="bilder/typo-plakat.jpg" alt="Licht: Ausstellungsplakat in Neongelb mit oranger Scheibe" data-name="licht" loading="lazy">
</div>
<figcaption class="mono"><b data-scramble="karte" data-karte>Gefüge 04/2025</b><br>Projekt <span data-karte-nr>01</span> / 04</figcaption>
</figure>
</div>
</div>
<footer class="fuss">
<p class="werte mono" data-werte></p>
<p class="mono"><span class="hinweis-maus">Über Liste und Navigation fahren</span><span class="hinweis-touch">Projekt antippen</span></p>
</footer>
</section>
<section class="weiter">
<p class="mono">Kontakt · beim Scrollen, einmal</p>
<p class="satz" data-scramble="scroll">Neue Projekte ab Januar 2027.</p>
<a class="knopf" href="#" data-scramble="hover">Projekt anfragen</a>
</section>
<div class="zeiger" aria-hidden="true"></div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const touch = matchMedia('(hover: none)').matches;
/* Drei Varianten. Zeichenwechsel ohne Easing (linear, im 30-ms-Takt).
decode: Eigenbau, Zeile mit Rauschen vorbefüllt, Zeichen rasten links → rechts ein.
welle: Codrops-Terminal-Hover: Zeichen erscheinen nacheinander, 4 Zufallszeichen, dann das echte.
balken: Welle mit 3 Zufallszeichen plus Akzentbalken 0 → 100 % (1000 ms expo.out, zurück 600 ms power4.out). */
const MODI = {
decode: { art: 'decode', satz: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', wechsel: 30, versatz: 60, verzoegerung: 300, hoverMax: 600 },
welle: { art: 'welle', satz: 'A!B@C#D$E%F&G*H?J[K]L{M}N=O+P-QRSTUVWXYZ', wechsel: 30, pause: 40, wdh: 3, versatz: 70 },
balken: { art: 'welle', satz: '0123456789ABCDEF', wechsel: 30, pause: 100, wdh: 2, versatz: 60, balken: true },
};
let modus = 'decode';
/* ---------- Zerlegen: Klartext für Screenreader, animierte Ebene aria-hidden ---------- */
function zerlege(el) {
const zeilen = (el.dataset.zeilen || el.textContent.trim()).split('|');
const klar = zeilen.join(' ');
el.textContent = '';
const sr = document.createElement('span'); sr.className = 'sr'; sr.textContent = klar;
const ebene = document.createElement('span'); ebene.setAttribute('aria-hidden', 'true');
const zeichen = [], ziele = [];
zeilen.forEach((zeile, zi) => {
const block = document.createElement('span');
block.className = 'z-ziel' + (zeilen.length > 1 ? ' z-zeile' : '');
zeile.split(' ').forEach((wort, wi) => {
if (wi) { block.append(' '); }
const w = document.createElement('span'); w.className = 'z';
for (const ch of wort) {
const c = document.createElement('span'); c.className = 'zc'; c.textContent = ch;
w.append(c); zeichen.push({ el: c, ch, zeile: zi, zustand: 'fest', k: -1 });
}
block.append(w);
});
if (zeilen.length > 1) { const hb = document.createElement('span'); hb.className = 'z-block'; hb.append(block); ebene.append(hb); }
else ebene.append(block);
ziele.push(block);
});
el.append(sr, ebene);
el._z = { zeichen, ziele, klar };
return el._z;
}
const zufall = s => s[(Math.random() * s.length) | 0];
/* ---------- Ein gemeinsamer rAF-Takt für alle laufenden Scrambles ---------- */
const laeufe = new Map();
let raf = 0, angehalten = false, angehaltenSeit = 0;
function setze(z, zustand, k, satz) {
if (zustand === z.zustand && k === z.k) return;
z.zustand = zustand; z.k = k;
z.el.classList.toggle('weg', zustand === 'weg');
z.el.classList.toggle('zufall', zustand === 'zufall');
z.el.textContent = zustand === 'zufall' ? zufall(satz) : z.ch;
}
function takt(jetzt) {
raf = 0;
if (angehalten) return;
laeufe.forEach((l, el) => {
const t = jetzt - l.t0, P = l.P;
let fertig = true;
l.z.zeichen.forEach((z, i) => {
if (z.ch === ' ') return;
if (P.art === 'decode') {
const fest = l.verz + i * l.versatz;
if (t >= fest) setze(z, 'fest', -1);
else { setze(z, 'zufall', Math.floor(t / P.wechsel), P.satz); fertig = false; }
} else {
const start = (i + 1) * l.versatz + (l.zeilenVerz[z.zeile] || 0);
const periode = P.wechsel + P.pause;
const ende = P.wdh * periode + 2 * P.wechsel;
if (t < start) { setze(z, 'weg', -1); fertig = false; }
else if (t - start < ende) { setze(z, 'zufall', Math.min(P.wdh, Math.floor((t - start) / periode)), P.satz); fertig = false; }
else setze(z, 'fest', -1);
}
});
if (fertig) { laeufe.delete(el); l.fertig && l.fertig(); }
});
if (laeufe.size) raf = requestAnimationFrame(takt);
}
function stop(el) {
const l = laeufe.get(el);
if (l) laeufe.delete(el);
if (!el._z) return;
el._z.zeichen.forEach(z => setze(z, 'fest', -1));
clearTimeout(el._zu);
}
/* opt.hover: kurz (max. 600 ms bei decode), Balken bleibt bis zum Verlassen.
opt.vorbefuellt (decode): Zeile startet mit Rauschen statt mit Text. */
function scramble(el, opt = {}) {
const z = el._z || zerlege(el);
stop(el);
const P = MODI[modus];
z.ziele.forEach(b => b.classList.remove('balken-an', 'balken-zu'));
if (reduziert()) {
if (opt.hover && P.balken) z.ziele.forEach(b => b.classList.add('balken-an'));
opt.fertig && opt.fertig();
return;
}
const n = z.zeichen.length;
let versatz = P.versatz, verz = P.verzoegerung || 0;
if (P.art === 'decode' && opt.hover) { verz = 0; versatz = Math.min(P.versatz, P.hoverMax / n); }
// Mehrzeilig: jede Zeile startet neu bei Zeichen 0, Zeilen 400 ms versetzt (wie bei Contassot)
const zeilenVerz = [];
if (z.ziele.length > 1 && P.art === 'welle') {
z.ziele.forEach((_, zi) => { const idx = z.zeichen.findIndex(c => c.zeile === zi); zeilenVerz[zi] = zi * 400 - idx * versatz; });
}
const l = { z, P, t0: performance.now(), versatz, verz, zeilenVerz, fertig: null };
if (P.balken) {
z.ziele.forEach((b, zi) => setTimeout(() => b.classList.add('balken-an'), zi * 120));
if (!opt.hover) l.fertig = () => { el._zu = setTimeout(() => balkenZu(el), 250); opt.fertig && opt.fertig(); };
else l.fertig = opt.fertig;
} else l.fertig = opt.fertig;
laeufe.set(el, l);
takt(performance.now()); // sofort ersten Zustand setzen (kein Aufblitzen des Klartexts)
if (!raf && !angehalten) raf = requestAnimationFrame(takt);
}
function balkenZu(el) {
if (!el._z) return;
el._z.ziele.forEach(b => { if (b.classList.contains('balken-an')) { b.classList.add('balken-zu'); b.classList.remove('balken-an'); } });
setTimeout(() => el._z.ziele.forEach(b => b.classList.remove('balken-zu')), 650);
}
/* ---------- Elemente ---------- */
const titel = document.querySelector('[data-scramble="titel"]');
const koord = document.querySelector('[data-scramble="laden"]');
const karte = document.querySelector('[data-karte]');
const karteNr = document.querySelector('[data-karte-nr]');
const zeilen = [...document.querySelectorAll('.zeile')];
const werte = document.querySelector('[data-werte]');
const zeiger = document.querySelector('.zeiger');
document.querySelectorAll('[data-scramble]').forEach(zerlege);
document.querySelectorAll('.nav a, .knopf').forEach(a => a.setAttribute('aria-label', a.querySelector('.sr').textContent));
function zeigeWerte() {
const P = MODI[modus];
if (reduziert()) { werte.innerHTML = '<span>Reduzierte Bewegung: <b>kein Scramble, Text sofort</b></span><span>Hover <b>Farbwechsel</b></span>'; return; }
const satz = P.satz.length > 20 ? P.satz.slice(0, 14) + '…' : P.satz;
werte.innerHTML = P.art === 'decode'
? `<span>Wechsel <b>${P.wechsel} ms</b></span><span>Versatz <b>${P.versatz} ms</b></span><span>Verzögerung <b>${P.verzoegerung} ms</b></span><span>Hover <b>≤ ${P.hoverMax} ms</b></span><span>Zeichen <b>A–Z 0–9</b></span>`
: `<span>Wechsel <b>${P.wechsel} ms × ${P.wdh + 1}</b></span><span>Pause <b>${P.pause} ms</b></span><span>Versatz <b>${P.versatz} ms</b></span>` +
(P.balken ? '<span>Balken <b>1000 ms expo.out</b></span>' : `<span>Zeichen <b>${satz}</b></span>`);
}
function aktiviere(zeile, scr = true) {
zeilen.forEach(z => z.classList.toggle('aktiv', z === zeile));
zeilen.forEach(z => { const p = z.querySelector('.projekt'); if (z !== zeile && p._z) { stop(p); balkenZu(p); } });
const name = zeile.dataset.bild;
document.querySelectorAll('.bild img').forEach(i => i.classList.toggle('an', i.dataset.name === name));
karteNr.textContent = zeile.querySelector('.nr').textContent;
const neu = zeile.dataset.titel;
if (karte._z.klar !== neu) { stop(karte); karte.textContent = neu; delete karte._z; zerlege(karte); }
if (scr) { scramble(zeile.querySelector('.projekt'), { hover: true }); scramble(karte, { hover: false }); }
}
function intro(fertig) {
scramble(titel, { fertig });
scramble(koord, {});
}
/* ---------- Auslöser ---------- */
if (!touch) {
document.querySelectorAll('.nav a, .knopf').forEach(a => {
const ziel = a.querySelector('[aria-hidden]').parentElement;
a.addEventListener('mouseenter', () => { if (!vorfuehrung) scramble(ziel, { hover: true }); });
a.addEventListener('mouseleave', () => { if (MODI[modus].balken) balkenZu(ziel); });
});
zeilen.forEach(z => {
z.addEventListener('mouseenter', () => { if (!vorfuehrung) aktiviere(z); });
});
document.querySelector('.liste').addEventListener('mouseleave', () => {
if (!vorfuehrung && MODI[modus].balken) zeilen.forEach(z => balkenZu(z.querySelector('.projekt')));
});
}
zeilen.forEach(z => {
z.addEventListener('click', () => { pause(); aktiviere(z); if (touch && MODI[modus].balken) setTimeout(() => balkenZu(z.querySelector('.projekt')), 900); });
z.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); z.click(); } });
z.addEventListener('focus', () => { if (!vorfuehrung) aktiviere(z); });
});
// Scroll: einmal beim Eintritt in den Viewport
const satz = document.querySelector('[data-scramble="scroll"]');
const knopf = document.querySelector('.knopf [aria-hidden]').parentElement;
const io = new IntersectionObserver(eintraege => eintraege.forEach(e => {
if (!e.isIntersecting) return;
io.unobserve(e.target);
scramble(satz, {});
if (touch) setTimeout(() => scramble(knopf, {}), 500);
}), { threshold: .6 });
io.observe(satz);
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-modus]');
if (!b) return;
pause();
setzeModus(b.dataset.modus);
intro();
});
function setzeModus(neu) {
modus = neu;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
zeigeWerte();
}
/* ---------- Vorführung: alle drei Varianten, Titel und gedachter Zeiger über der Liste ---------- */
let vorfuehrung = false, uhren = [];
const spaeter = (fn, ms) => uhren.push(setTimeout(fn, ms));
const reihe = ['decode', 'welle', 'balken'];
function zeigerZu(el) {
const r = el.getBoundingClientRect();
zeiger.style.transition = 'opacity .3s linear, transform .7s var(--kurve)';
zeiger.style.transform = `translate(${r.left + Math.min(r.width * .42, 260)}px, ${r.top + r.height / 2 - 7}px)`;
}
function runde(i) {
setzeModus(reihe[i]);
intro();
const z0 = zeilen[(i + 1) % zeilen.length], z1 = zeilen[(i + 2) % zeilen.length];
spaeter(() => { zeiger.classList.add('an'); zeigerZu(z0); }, 1900);
spaeter(() => aktiviere(z0), 2300);
spaeter(() => zeigerZu(z1), 3300);
spaeter(() => aktiviere(z1), 3700);
spaeter(() => { zeiger.classList.remove('an'); if (MODI[modus].balken) zeilen.forEach(z => balkenZu(z.querySelector('.projekt'))); }, 4800);
spaeter(() => runde((i + 1) % reihe.length), 5600);
}
function vorfuehren() {
pause();
if (angehalten) weiterLaufen();
vorfuehrung = true;
scrollTo(0, 0);
zeiger.style.transform = `translate(${innerWidth * .55}px, ${innerHeight * .8}px)`;
runde(0);
}
function pause() { uhren.forEach(clearTimeout); uhren = []; vorfuehrung = false; zeiger.classList.remove('an'); }
function weiterLaufen() {
if (!angehalten) return;
const d = performance.now() - angehaltenSeit;
laeufe.forEach(l => { l.t0 += d; });
angehalten = false;
if (laeufe.size && !raf) raf = requestAnimationFrame(takt);
}
function neustart() {
pause(); weiterLaufen(); scrollTo(0, 0);
aktiviere(zeilen[0], false);
intro();
}
zeigeWerte();
document.fonts.ready.then(() => { if (!vorfuehrung) intro(); });
})();
</script>
</body>
</html>