Wort-für-Wort-Aufhellen beim Scrollen
Ein Manifest liest sich beim Scrollen selbst vor: Wort für Wort hellt der Absatz von blass auf satt auf.
Ein großer Absatz, meist ein Statement oder Manifest, steht in voller Größe, aber blass da: mit niedriger Deckkraft (0,1 bis 0,4) oder in Grau. Der Text wird in Wörter zerlegt. Der Scrollfortschritt des Absatzes wird auf die Wörter verteilt, jedes Wort hat seinen eigenen Abschnitt. In diesem Abschnitt hellt es auf volle Deckkraft auf. So liest sich der Text im Takt des Scrollens selbst vor.
Der Effekt bremst das Lesetempo und macht einen Kernsatz zum Ereignis. Das Scrollen bekommt einen sichtbaren Sinn, ohne dass Inhalt versteckt wird: Der Text ist von Anfang an lesbar. Das wirkt ruhig und klug statt laut.
Wann einsetzen
Für ein Über-uns-Statement, ein Markenversprechen, eine Mission, ein Zitat oder einen Intro-Absatz. Am besten als einzelner großer Absatz zwischen Bildsektionen.
Wann nicht
Bei langen Texten, mehreren Absätzen hintereinander und Rechtstexten. Wenn Nutzer schnell scannen oder in eigenem Tempo lesen wollen. Höchstens ein solcher Block pro Seite.
Varianten
- Deckkraft blass → voll (Standard, z. B. 0,1 → 1)
- Farbe statt Deckkraft: Grau → Textfarbe oder Grau → Akzentfarbe
- Textmarker hinter Schlüsselwörtern, der mit dem Lesefortschritt aufzieht
- Zeichenweise statt wortweise: feiner, aber teurer
- Farbverlauf, der per background-clip: text über den Text läuft
- Rein in CSS mit view() und animation-range je Wort über --i
Umsetzung
GSAP: SplitText.create(p, { type: 'words' }), dann gsap.timeline({ scrollTrigger: { trigger: p, start: 'top bottom-=10%', end: 'bottom center', scrub: true }, defaults: { ease: 'none' } }).fromTo(words, { opacity: 0.1 }, { opacity: 1, stagger: { each: 0.15, ease: 'power1.in' } }). Weil das Ende an der Unterkante hängt, wächst der Bereich mit der Absatzhöhe.
Nur CSS: Absatz mit view-timeline-name: --st, Wörter als Spans mit --i und --n. .wort { opacity: .15; animation: lit linear both; animation-timeline: --st; animation-range: cover calc(20% + var(--i) / var(--n) * 40%) cover calc(20% + (var(--i) + 1) / var(--n) * 40%) } @keyframes lit { to { opacity: 1 } }. Wird die Timeline außerhalb des Absatzes gebraucht, hilft timeline-scope.
Fallstrick: Der blasse Ausgangszustand hat wenig Kontrast. Er ist dekorativ, der Endzustand muss sicher erreicht werden. Das Ende also nicht zu spät legen, oder Farbe statt Deckkraft animieren.
Robust umsetzen
Telefon. Funktioniert gut. Der Absatz hat mehr Zeilen; hängt das Ende an der Unterkante, wächst der Scrollbereich von selbst mit. Bei festen Bereichen das Fenster anpassen.
Ohne Bewegung. Text sofort in voller Farbe zeigen. Ein reiner Farbwechsel ist keine Bewegung und dürfte bleiben; voller Text ist aber die sichere Wahl.
Barrierefrei. Der Text steht von Anfang an im DOM. Wort-Spans mit aria-hidden versehen und den ganzen Satz als aria-label auf den Absatz legen, damit Screenreader nicht Wort für Wort stocken (SplitText macht das mit aria: 'auto'). Ausgangskontrast prüfen und nie einen blassen Endzustand riskieren.
Code
<!DOCTYPE html>
<!--
Wort-für-Wort-Aufhellen beim Scrollen
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/scroll-word-highlight/
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>Wort-für-Wort-Aufhellen beim Scrollen · 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: #f2eee7; --tinte: #1b1814; --leise: rgba(27, 24, 20, .64); --linie: rgba(27, 24, 20, .14);
--grau: #c4bbad; /* Ausgangsfarbe der Variante „Farbe“ */
--marker: #f4c95d; /* Textmarker der Variante „Marker“ */
--rand: clamp(16px, 4vw, 48px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); overflow-x: clip; }
img { display: block; width: 100%; height: auto; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
/* Kopf und Wortmarke */
.kopf { display: flex; justify-content: space-between; gap: 16px; padding: 22px var(--rand); border-bottom: 1px solid var(--linie); }
.kopf nav { display: flex; gap: 22px; }
.marke { padding: clamp(28px, 6vh, 72px) var(--rand) 0; font-size: clamp(48px, 19.5vw, 300px); font-weight: 600; line-height: .8; letter-spacing: -.06em; overflow-wrap: anywhere; }
.marke sup { white-space: nowrap; }
.marke sup { font: 500 max(9px, .09em)/1 'Geist Mono', monospace; letter-spacing: 0; vertical-align: top; margin-left: .5em; position: relative; top: .25em; }
/* Statement */
.haltung { padding: clamp(48px, 9vh, 110px) var(--rand) clamp(80px, 16vh, 180px); }
.zeile { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; padding-bottom: clamp(24px, 4vh, 44px); }
.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(--tinte); border-color: rgba(27, 24, 20, .32); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--grund); border-color: var(--tinte); }
.modi button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 2px; }
.satz { font-size: clamp(27px, 3.9vw, 60px); font-weight: 500; line-height: 1.1; letter-spacing: -.032em; max-width: 21.5em; text-wrap: pretty; }
.satz em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; font-size: 1.08em; line-height: 1; }
.satz .kern { background: linear-gradient(var(--marker), var(--marker)) no-repeat 0 86% / 0% .38em; -webkit-box-decoration-break: clone; box-decoration-break: clone; }
.satz .wort { will-change: opacity; }
.js .satz:not(.bereit) { visibility: hidden; } /* erst nach dem Teilen zeigen, kein Aufblitzen */
.reduziert .satz .kern.an { background-size: 100% .38em; }
/* Arbeiten */
.arbeiten { padding: 0 var(--rand) clamp(64px, 12vh, 140px); display: grid; grid-template-columns: 1fr 1fr; gap: clamp(16px, 2.4vw, 32px); }
.arbeiten .mono:first-child { grid-column: 1 / -1; padding-top: 20px; border-top: 1px solid var(--linie); }
.arbeiten figure { display: grid; gap: 12px; }
.arbeiten img { border-radius: 2px; background: #e9e4dc; }
.arbeiten figcaption { display: flex; justify-content: space-between; gap: 12px; }
.arbeiten figcaption b { font: 500 15px/1.3 'Instrument Sans', sans-serif; letter-spacing: -.01em; text-transform: none; color: var(--tinte); }
.fuss { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 22px var(--rand) 96px; border-top: 1px solid var(--linie); }
/* Werte-Leiste */
.werte { position: fixed; left: 0; right: 0; bottom: 0; z-index: 5; display: flex; align-items: center; gap: 6px 18px; flex-wrap: wrap; padding: 12px var(--rand); background: rgba(242, 238, 231, .88); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-top: 1px solid var(--linie); }
.werte b { color: var(--tinte); font-weight: 500; }
.werte .stand { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.werte .balken { width: 72px; height: 2px; background: var(--linie); overflow: hidden; }
.werte .balken i { display: block; height: 100%; background: var(--tinte); transform-origin: 0 50%; transform: scaleX(0); }
@media (max-width: 1100px) { .werte .weg { display: none; } }
@media (max-width: 720px) {
.kopf nav, .kopf .ort { display: none; }
.arbeiten { grid-template-columns: 1fr; }
.werte { font-size: 10px; gap: 4px 12px; padding-block: 10px; }
.werte .neben { display: none; }
.werte .stand { margin-left: 0; width: 100%; }
.werte .balken { flex: 1; }
}
/* Vorschau in der Galerie: nur Statement und Werte */
.vorschau body { overflow: hidden; }
.vorschau .marke, .vorschau .arbeiten, .vorschau .fuss { display: none; }
.vorschau .haltung { min-height: calc(100svh - 64px); display: flex; flex-direction: column; justify-content: center; padding-block: 0 72px; }
.vorschau .satz { font-size: 50px; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf mono">
<span><b style="color:var(--tinte);font-weight:500">Feldmark</b> · Studio für Erscheinungsbilder</span>
<span class="ort">Köln-Ehrenfeld</span>
<nav aria-label="Hauptnavigation"><span>Arbeiten</span><span>Studio</span><span>Kontakt</span></nav>
</header>
<p class="marke" aria-hidden="true">Feldmark<sup>Est. 2014</sup></p>
<section class="haltung" aria-labelledby="haltung-titel">
<div class="zeile">
<h2 class="mono" id="haltung-titel">(01) Haltung</h2>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-variante="deckkraft" aria-pressed="true">Deckkraft</button>
<button type="button" data-variante="farbe" aria-pressed="false">Farbe</button>
<button type="button" data-variante="marker" aria-pressed="false">Marker</button>
</div>
</div>
<p class="satz" data-satz>Wir gestalten <span class="kern">langsam.</span> Bevor der erste Strich sitzt, sitzen wir am Tresen, hören zu und trinken den Kaffee, um den es geht. Erst dann zeichnen wir. Ein gutes Zeichen muss nicht laut sein. Es soll <span class="kern">bleiben:</span> auf der Tasse, am Schild, auf der Rechnung. Und in <span class="kern">zwanzig Jahren</span> noch immer <em>nach euch</em> aussehen.</p>
</section>
<section class="arbeiten" aria-label="Ausgewählte Arbeiten">
<p class="mono">(02) Zuletzt</p>
<figure>
<img src="bilder/marke-roestkontor.jpg" width="1200" height="800" alt="Wortbildmarke Röstkontor: Kaffeebohne im Kreis über cremefarbenen Versalien auf Ziegelrot">
<figcaption class="mono"><b>Röstkontor</b><span>Wortbildmarke · 2025</span></figcaption>
</figure>
<figure>
<img src="bilder/marke-krume.jpg" width="1200" height="800" alt="Wortmarke krume: fette dunkelbraune Kleinbuchstaben unter einem Brotlaib auf Senfgelb">
<figcaption class="mono"><b>Krume</b><span>Backstube · 2024</span></figcaption>
</figure>
</section>
<footer class="fuss mono">
<span>Feldmark · Köln-Ehrenfeld</span>
<span>hallo@feldmark-studio.de</span>
</footer>
<p class="werte mono" data-werte aria-live="off"></p>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/SplitText.min.js"></script>
<script>
(() => {
gsap.registerPlugin(SplitText, ScrollTrigger);
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const vorschau = () => html.classList.contains('vorschau');
// Scrollbereich des Absatzes (gemessen bei Wokine): Oberkante bei 90 % des Viewports
// bis Unterkante bei 50 %. Für alle drei Varianten gleich.
const BEREICH = { start: 'top bottom-=10%', end: 'bottom center' };
// Drei Varianten. Deckkraft und Farbe mit den Wokine-Werten (scrub, each 0,15 mit power1.in,
// Tween-Kurve none), Marker mit den Cohere-Werten (Grundhelligkeit .4, each .05, expo.out, scrub .5).
const VARIANTEN = {
deckkraft: { von: { opacity: 0.1 }, nach: { opacity: 1 }, dauer: 0.5, versatz: 0.15, verteilung: 'power1.in', kurve: 'none', scrub: true,
anzeige: ['Deckkraft <b>0,1 → 1</b>', 'Versatz <b>each 0,15 · power1.in</b>', 'Kurve <b>none</b>', 'Scrub <b>true</b>'] },
farbe: { von: { color: '#c4bbad' }, nach: { color: '#1b1814' }, dauer: 0.5, versatz: 0.15, verteilung: 'power1.in', kurve: 'none', scrub: true,
anzeige: ['Farbe <b>#c4bbad → #1b1814</b>', 'Versatz <b>each 0,15 · power1.in</b>', 'Kurve <b>none</b>', 'Scrub <b>true</b>'] },
marker: { von: { opacity: 0.4 }, nach: { opacity: 1 }, dauer: 2, versatz: 0.05, verteilung: 'none', kurve: 'expo.out', scrub: 0.5, marker: { dauer: 1, kurve: 'expo.out' },
anzeige: ['Deckkraft <b>0,4 → 1</b>', 'Versatz <b>each 0,05</b>', 'Kurve <b>expo.out</b>', 'Scrub <b>0,5</b>', 'Marker <b>0 → 100 %</b>'] },
};
const satz = document.querySelector('[data-satz]');
const kerne = [...satz.querySelectorAll('.kern')];
const werte = document.querySelector('[data-werte]');
let variante = 'deckkraft', woerter = [], starts = [], tl = null, st = null, vorfuehrung = false, schleife = null;
let balken = null, zaehler = null;
function zeigeWerte() {
const v = VARIANTEN[variante];
const liste = reduziert()
? ['Reduzierte Bewegung: <b>Text sofort in voller Farbe</b>']
: [...v.anzeige, '<span class="weg">Bereich <b>top 90 % → bottom 50 %</b></span>'];
werte.innerHTML = liste.map((s, i) => s.startsWith('<span') ? s : `<span${i > 1 ? ' class="neben"' : ''}>${s}</span>`).join('')
+ `<span class="stand"><span>Wort <b data-zaehler>${reduziert() ? woerter.length : 0}</b>/${woerter.length}</span><span class="balken"><i data-balken></i></span></span>`;
balken = werte.querySelector('[data-balken]');
zaehler = werte.querySelector('[data-zaehler]');
if (reduziert()) balken.style.transform = 'scaleX(1)';
}
// Lesefront anzeigen: wie viele Wörter sind mindestens zur Hälfte aufgehellt
let halb = 0;
function zaehle() {
const t = tl.time();
let n = 0;
while (n < starts.length && t >= starts[n] + halb) n++;
zaehler.textContent = n;
balken.style.transform = `scaleX(${tl.progress().toFixed(3)})`;
}
function aufbauen() {
if (st) { st.kill(); st = null; }
if (tl) { tl.kill(); tl = null; }
gsap.set(woerter, { clearProps: 'opacity,color' });
gsap.set(kerne, { clearProps: 'backgroundSize' });
kerne.forEach(k => k.classList.toggle('an', variante === 'marker'));
document.body.dataset.variante = variante;
zeigeWerte();
if (reduziert()) return; // Text steht sofort in voller Farbe, Marker statisch
const v = VARIANTEN[variante];
const n = woerter.length;
const verteilen = gsap.parseEase(v.verteilung);
// entspricht stagger: { each: v.versatz, ease: v.verteilung }; einzeln gesetzt, damit die Startzeiten bekannt sind
starts = woerter.map((_, i) => v.versatz * (n - 1) * verteilen(i / (n - 1)));
const kurve = gsap.parseEase(v.kurve);
let x = 0; while (kurve(x) < 0.5) x += 0.01;
halb = x * v.dauer;
tl = gsap.timeline({ paused: true, defaults: { ease: v.kurve }, onUpdate: zaehle });
woerter.forEach((w, i) => tl.fromTo(w, v.von, { ...v.nach, duration: v.dauer }, starts[i]));
if (v.marker) {
kerne.forEach(k => {
const i = woerter.findIndex(w => k.contains(w));
tl.fromTo(k, { backgroundSize: '0% 0.38em' }, { backgroundSize: '100% 0.38em', duration: v.marker.dauer, ease: v.marker.kurve }, starts[i]);
});
}
tl.progress(0);
if (!vorfuehrung) st = ScrollTrigger.create({ trigger: satz, ...BEREICH, scrub: v.scrub, animation: tl });
}
function setzeVariante(neu) {
variante = neu;
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
}
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-variante]');
if (!b) return;
stoppeVorfuehrung(false);
setzeVariante(b.dataset.variante);
aufbauen();
if (vorschau() && tl) gsap.to(tl, { progress: 1, duration: 4.8, ease: 'none' });
});
// Erst nach den Schriften teilen. Nur Wörter, kein Zeilen-Split: Umbrüche dürfen sich ändern.
// aria: 'auto' (Standard) setzt aria-label auf den Absatz und aria-hidden auf die Wort-Spans.
const bereit = document.fonts.ready.then(() => {
const split = SplitText.create(satz, { type: 'words', wordsClass: 'wort' });
woerter = split.words;
satz.classList.add('bereit');
aufbauen();
});
// Vorführung: Scroll-Fortschritt simulieren, alle drei Varianten im Wechsel
const reihe = ['deckkraft', 'farbe', 'marker'];
function vorfuehren() {
bereit.then(() => {
stoppeVorfuehrung(false);
vorfuehrung = true;
if (!vorschau()) satz.scrollIntoView({ block: 'center' });
let i = 0;
const runde = () => {
setzeVariante(reihe[i]);
aufbauen();
const weiter = () => { i = (i + 1) % reihe.length; runde(); };
if (reduziert()) { schleife = gsap.timeline({ onComplete: weiter }).to({}, { duration: 2.4 }); return; }
schleife = gsap.timeline({ onComplete: weiter })
.fromTo(satz, { opacity: 0 }, { opacity: 1, duration: 0.4, ease: 'none' })
.to(tl, { progress: 1, duration: 4.8, ease: 'none' })
.to({}, { duration: 1.4 })
.to(satz, { opacity: 0, duration: 0.5, ease: 'power1.in' });
};
runde();
});
}
function stoppeVorfuehrung(neuAufbauen = true) {
if (schleife) { schleife.kill(); schleife = null; }
gsap.set(satz, { clearProps: 'opacity' });
if (vorfuehrung) { vorfuehrung = false; if (neuAufbauen) aufbauen(); }
}
// Wer selbst scrollt, übernimmt wieder (nicht in der Galerie-Vorschau)
['wheel', 'touchstart', 'keydown'].forEach(t => addEventListener(t, () => { if (vorfuehrung && !vorschau()) stoppeVorfuehrung(); }, { passive: true }));
function pause() { if (schleife) schleife.pause(); }
function neustart() { bereit.then(() => { stoppeVorfuehrung(false); scrollTo(0, 0); aufbauen(); ScrollTrigger.refresh(); }); }
})();
</script>
</body>
</html>