Unschärfe-Enthüllung (Blur-to-sharp) pro Wort
Wörter tauchen gestaffelt aus der Unschärfe auf und werden scharf wie beim Fokusziehen einer Kamera.
Der Text wird in Wörter oder Zeichen zerlegt. Jedes Teil startet unsichtbar und unscharf (filter: blur 4–12px), auf Wunsch mit kleinem Versatz nach unten. Beim Eintritt oder an den Scroll gekoppelt werden die Teile gestaffelt scharf gestellt: blur auf 0, opacity auf 1, y auf 0. Weil der Stagger kürzer ist als die Dauer, überlappen die Teile. So läuft ein weicher Fokuszug über die Zeile.
Es wirkt wie das Scharfstellen einer Kamera: weich, hochwertig und weniger mechanisch als ein harter Masken-Reveal. Das passt zur ruhigen Produkt-Ästhetik und zu Glas- und Blur-Oberflächen. Die zufällige Reihenfolge lässt die Zeile atmen, statt sie abzuarbeiten.
Wann einsetzen
Hero-Headlines von Produkt- und Studio-Seiten, Intros, Untertitel unter einer maskierten Headline, Zitate und ruhige Statement-Abschnitte.
Wann nicht
Bei langen Texten, denn filter: blur auf vielen Elementen ist teuer. Nicht auf schwachen Geräten mit vielen gleichzeitig animierten Teilen. Nicht, wenn die Marke hart oder brutal wirken soll.
Varianten
- Wortweise beim Laden oder Eintritt, Reihenfolge zufällig (TRIONN: 800 ms, Stagger 50 ms, blur 12px)
- Zeichenweise und schnell (Fantasy: 250 ms, Stagger gesamt 250 ms, blur 10px)
- Dezent mit kleinem Versatz (Attio: 350 ms, blur 4px, y 8px)
- Scroll-gescrubbt: Wörter werden mit dem Scrollfortschritt scharf
- Rückwärts beim Verlassen: der Text verschwimmt wieder (Fantasy: blur 10px, power2.in)
- Blur plus leichte Buchstaben-Spreizung über letter-spacing
- SVG-Filter (feTurbulence, feDisplacementMap) für organische Verzerrung
Umsetzung
GSAP: gsap.fromTo(split.words, { filter: 'blur(12px)', autoAlpha: 0 }, { filter: 'blur(0px)', autoAlpha: 1, duration: 0.8, stagger: { each: 0.05, from: 'random' }, ease: 'power2.out', clearProps: 'filter,willChange' }). Scroll-Variante: dasselbe mit scrollTrigger { start: 'top 90%', scrub: true }. CSS ohne Bibliothek: @keyframes von blur und opacity mit animation-delay: calc(var(--i) * 50ms), oder scroll-gekoppelt mit animation-timeline: view() und animation-range-Versatz über --i. Wort- statt Zeichenebene bevorzugen, das spart Füllrate bei großen Schriftgraden. will-change: filter nur während der Animation setzen und danach entfernen. Safari rendert blur auf Inline-Elementen nur mit display: inline-block korrekt.
Robust umsetzen
Telefon. Wortweise funktioniert gut. Bei Zeichen-Splits auf dem Telefon den Blur verringern oder ganz weglassen und nur opacity und y animieren.
Ohne Bewegung. Nur ein kurzes Einblenden über opacity, ohne Blur und Versatz. Oder den Text sofort sichtbar zeigen.
Barrierefrei. SplitText mit aria: 'auto' setzt aria-label am Elternelement und versteckt die Teile vor Screenreadern. Der Endzustand muss immer erreicht werden: Text am Ende vollständig scharf, filter per clearProps entfernt, damit Zoom und Lesbarkeit nicht leiden.
Code
<!DOCTYPE html>
<!--
Unschärfe-Enthüllung (Blur-to-sharp) pro Wort
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/blur-reveal/
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>Unschärfe-Enthüllung (Blur-to-sharp) pro Wort · 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: #efece6; --text: #151513; --leise: rgba(21, 21, 19, .66); --linie: rgba(21, 21, 19, .14);
--akzent: #b4441c;
--kurve: cubic-bezier(0.33, 1, 0.68, 1); /* power2.out, TRIONN */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: var(--grund); overflow-x: hidden; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.buehne { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(20px, 3vw, 36px); padding: clamp(18px, 3.6vw, 44px); }
/* Kopf: erfundenes Studio */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.marke { font-weight: 600; font-size: 15px; letter-spacing: -.01em; display: flex; align-items: center; gap: 10px; }
.marke i { width: 10px; height: 10px; border-radius: 50%; background: var(--akzent); }
.nav { display: flex; gap: clamp(14px, 2.4vw, 28px); }
/* Mitte: Headline links, Arbeit rechts */
.mitte { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, .62fr); gap: clamp(24px, 4vw, 64px); align-items: end; }
.text-spalte { position: relative; display: grid; gap: clamp(18px, 2.6vw, 30px); cursor: pointer; }
/* Zugang: Tastatur-Zugang zum Wiederholen; unsichtbar, bis er per Tab den Fokus bekommt (Klick wandert zur Spalte hoch) */
.wiederholen-knopf { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.wiederholen-knopf:focus-visible { z-index: 5; top: -40px; width: auto; height: 30px; overflow: visible; clip-path: none; padding: 0 12px; border: 1px solid var(--linie); border-radius: 999px; background: var(--grund); color: var(--text); outline: 2px solid var(--text); outline-offset: 2px; }
.titel { font-size: clamp(44px, 7.4vw, 118px); font-weight: 550; line-height: .96; letter-spacing: -.045em; max-width: 12ch; }
.titel em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.015em; color: var(--akzent); margin-right: -.06em; }
.augenbraue { display: flex; flex-wrap: wrap; gap: 4px 18px; }
.augenbraue span + span::before { content: '/'; margin-right: 18px; opacity: .5; }
.unter { display: flex; gap: clamp(16px, 3vw, 40px); align-items: start; }
.unter .mono { padding-top: .35em; white-space: nowrap; }
.text { font-size: clamp(15px, 1.35vw, 19px); line-height: 1.5; color: var(--leise); max-width: 42ch; }
.arbeit { display: grid; gap: 10px; }
.arbeit figure { aspect-ratio: 4 / 5; overflow: hidden; border-radius: 3px; background: #ddd8cf; }
.arbeit img { width: 100%; height: 100%; object-fit: cover; display: block; }
.arbeit figcaption { display: flex; justify-content: space-between; gap: 10px; }
/* Fuß: Fokusanzeige, Varianten, Werte */
.fuss { display: grid; grid-template-columns: auto 1fr auto; gap: 14px 28px; align-items: center; border-top: 1px solid var(--linie); padding-top: 14px; }
.fokus { display: flex; align-items: center; gap: 12px; min-width: 210px; }
.skala { position: relative; width: 96px; height: 14px; background: repeating-linear-gradient(90deg, var(--linie) 0 1px, transparent 1px 8px); }
.skala b { position: absolute; top: -2px; bottom: -2px; left: 0; width: 1.5px; background: var(--akzent); }
.fokus output { color: var(--text); min-width: 6ch; font-variant-numeric: tabular-nums; }
.werte { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.werte b { color: var(--text); font-weight: 500; }
.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 .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: rgba(21, 21, 19, .32); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.modi button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; }
/* Wörter und Zeichen als inline-block: Safari rendert filter sonst nicht */
.wort, .zeichen { display: inline-block; }
.js .teilen { visibility: hidden; }
/* Zweiter Abschnitt: an den Scroll gekoppelt */
.weiter { padding: clamp(90px, 20vh, 220px) clamp(18px, 3.6vw, 44px) 40vh; border-top: 1px solid var(--linie); display: grid; gap: 28px; }
.zitat { font-size: clamp(30px, 5vw, 78px); font-weight: 500; line-height: 1.04; letter-spacing: -.035em; max-width: 17ch; }
.zitat em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; }
.weiter footer { display: flex; gap: 18px; flex-wrap: wrap; }
.tipp-touch { display: none; }
@media (hover: none) { .tipp-maus { display: none; } .tipp-touch { display: inline; } }
@media (max-width: 760px) {
.nav span:not(:last-child) { display: none; }
.augenbraue span:last-child { display: none; }
.mitte { grid-template-columns: 1fr; align-items: start; align-content: center; }
.arbeit figure { aspect-ratio: 3 / 2; }
.unter { flex-direction: column; gap: 10px; }
.fuss { grid-template-columns: 1fr; }
.modi { order: -1; }
}
/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .weiter, .vorschau .tipp { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="buehne">
<header class="kopf">
<p class="marke"><i aria-hidden="true"></i>Studio Halde</p>
<nav class="nav mono" aria-label="Demo-Navigation"><span>Arbeiten</span><span>Studio</span><span>Kontakt</span></nav>
</header>
<div class="mitte">
<div class="text-spalte" data-wiederholen title="Neu scharfstellen">
<button type="button" class="wiederholen-knopf mono">Neu scharfstellen</button>
<p class="mono augenbraue"><span>Markenentwicklung</span><span>Editorial Design</span><span>Ausgabe Herbst 2026</span></p>
<h1 class="titel teilen" data-titel>Wir stellen Marken <em>scharf,</em> bis man sie nicht mehr übersieht.</h1>
<div class="unter">
<p class="mono">Köln · seit 2014</p>
<p class="text teilen" data-text>Erscheinungsbilder für Cafés, Handwerk und Kultur. Zwölf Menschen, eine Siebdruckpresse und viel Geduld für das letzte Detail.</p>
</div>
</div>
<div class="arbeit">
<figure><img src="bilder/magazin-doppelseite-hoch.jpg" alt="Aufgeschlagenes Magazin, rechte Seite mit dem Titel Die Ruhe der Dinge in Serifenschrift, links angeschnitten ein Stillleben auf Terrakotta" width="800" height="1000" data-bild></figure>
<p class="mono"><span>Die Ruhe der Dinge, Heft 07</span></p>
</div>
</div>
<footer class="fuss">
<div class="fokus mono" aria-hidden="true"><span>Fokus</span><span class="skala"><b data-nadel></b></span><output data-fokus>0.0 px</output></div>
<p class="werte mono" data-werte aria-live="polite"></p>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-modus="woerter" aria-pressed="true">Wörter</button>
<button type="button" data-modus="zeichen" aria-pressed="false">Zeichen</button>
<button type="button" data-modus="dezent" aria-pressed="false">Dezent</button>
</div>
</footer>
</section>
<section class="weiter">
<p class="mono tipp"><span class="tipp-maus">Scrollen stellt scharf · Klick auf die Headline oben wiederholt</span><span class="tipp-touch">Wischen stellt scharf · Antippen der Headline oben wiederholt</span></p>
<blockquote class="zitat teilen" data-zitat>Gute Gestaltung ist wie ein Objektiv: Erst wenn es <em>sitzt,</em> sieht man nur noch das Motiv.</blockquote>
<footer class="mono"><span>Mara Halde, Gründerin</span><span>Variante: scroll-gekoppelt, wortweise</span></footer>
</section>
<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 src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/CustomEase.min.js"></script>
<script>
(() => {
gsap.registerPlugin(SplitText, ScrollTrigger, CustomEase);
const reduziert = () => document.documentElement.classList.contains('reduziert');
// Gemessene Kurven der drei Belege
CustomEase.create('unmask', 'M0,0 C0.16,1 0.3,1 1,1'); // Fantasy
CustomEase.create('attio', 'M0,0 C0.25,1 0.5,1 1,1'); // Attio [.25,1,.5,1]
// Drei Varianten, Werte aus messwerte (TRIONN, Fantasy, Attio)
const MODI = {
woerter: { type: 'words', teile: 'words', blur: 12, y: 0, dauer: 0.8, stagger: { each: 0.05, from: 'random' }, ease: 'power2.out',
anzeige: ['power2.out', '800 ms', '50 ms, zufällig', 'blur 12px → 0', 'Wörter'] },
zeichen: { type: 'words,chars', teile: 'chars', blur: 10, y: 0, dauer: 0.25, stagger: { amount: 0.25 }, ease: 'unmask',
anzeige: ['unmask (.16,1,.3,1)', '250 ms', 'gesamt 250 ms', 'blur 10px → 0', 'Zeichen'] },
dezent: { type: 'words', teile: 'words', blur: 4, y: 8, dauer: 0.35, stagger: { each: 0.03 }, ease: 'attio',
anzeige: ['(.25,1,.5,1)', '350 ms', '30 ms', 'blur 4px + y 8px → 0', 'Wörter'] },
};
const ABGANG = { blur: 10, dauer: 0.4, ease: 'power2.in' };
const titel = document.querySelector('[data-titel]');
const text = document.querySelector('[data-text]');
const bild = document.querySelector('[data-bild]');
const zitat = document.querySelector('[data-zitat]');
const werte = document.querySelector('[data-werte]');
const fokusAus = document.querySelector('[data-fokus]');
const nadel = document.querySelector('[data-nadel]');
let modus = 'woerter', teilungen = [], fokusTween = null;
function zeigeWerte(m) {
const [ease, dauer, st, weg] = m.anzeige;
werte.innerHTML = reduziert()
? '<span>Reduzierte Bewegung: <b>nur einblenden, 300 ms, ohne Blur</b></span>'
: `<span>Kurve <b>${ease}</b></span><span>Dauer <b>${dauer}</b></span><span>Stagger <b>${st}</b></span><span>Weg <b>${weg}</b></span>`;
}
// Fokusanzeige: läuft vom Start-Blur auf 0, so lange wie die ganze Welle
function fokus(von, bis, sek, ease) {
if (fokusTween) fokusTween.kill();
const o = { b: von };
const zeichnen = () => { fokusAus.textContent = o.b.toFixed(1) + ' px'; nadel.style.left = (o.b / 12 * 100) + '%'; };
zeichnen();
fokusTween = gsap.to(o, { b: bis, duration: sek, ease, onUpdate: zeichnen });
}
// Ein Element teilen und scharfstellen. Der Tween kommt aus onSplit zurück,
// damit autoSplit ihn bei neuem Umbruch sauber neu aufsetzt.
function scharfstellen(el, m, extra = {}) {
if (reduziert()) {
gsap.set(el, { visibility: 'visible' });
return gsap.from(el, { opacity: 0, duration: 0.3, ease: 'none', ...extra });
}
const t = SplitText.create(el, {
type: m.type, autoSplit: true, wordsClass: 'wort', charsClass: 'zeichen',
onSplit(self) {
gsap.set(el, { visibility: 'visible' });
const teile = self[m.teile];
return gsap.fromTo(teile,
{ filter: `blur(${m.blur}px)`, autoAlpha: 0, y: m.y, willChange: 'filter, opacity' },
{ filter: 'blur(0px)', autoAlpha: 1, y: 0, duration: m.dauer, stagger: m.stagger, ease: m.ease,
clearProps: 'filter,willChange,transform', ...extra });
},
});
teilungen.push(t);
return t;
}
function wellenDauer(m, n) {
const s = m.stagger.amount != null ? m.stagger.amount : m.stagger.each * (n - 1);
return m.dauer + s;
}
function aufbauen() {
teilungen.forEach(t => t.revert());
teilungen = [];
gsap.killTweensOf([titel, text, bild]);
const m = MODI[modus];
zeigeWerte(m);
scharfstellen(titel, m);
// Fließtext immer wortweise in der ruhigen TRIONN-Variante, etwas später
scharfstellen(text, { ...MODI.woerter, stagger: { each: 0.02, from: 'random' } }, { delay: 0.35 });
if (reduziert()) {
gsap.fromTo(bild, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: 'none' });
fokus(0, 0, 0, 'none');
} else {
// Das Bild zieht den Fokus mit: gleiche Kurve wie die Headline
gsap.fromTo(bild, { filter: `blur(${m.blur}px)`, scale: 1.04, opacity: 0.6 },
{ filter: 'blur(0px)', scale: 1, opacity: 1, duration: Math.max(m.dauer * 1.5, 0.9), delay: 0.1, ease: m.ease, clearProps: 'filter' });
const n = titel.querySelectorAll(m.teile === 'chars' ? '.zeichen' : '.wort').length;
fokus(m.blur, 0, wellenDauer(m, n), 'none');
}
}
// Abgang: wieder unscharf werden (Fantasy: blur 10px, power2.in)
function verbergen() {
const m = MODI[modus];
const teile = titel.querySelectorAll(m.teile === 'chars' ? '.zeichen' : '.wort');
fokus(0, ABGANG.blur, ABGANG.dauer + 0.2, 'power2.in');
gsap.to(text, { autoAlpha: 0, filter: 'blur(8px)', duration: ABGANG.dauer, ease: ABGANG.ease, clearProps: 'filter,opacity,visibility' });
gsap.to(bild, { filter: `blur(${ABGANG.blur}px)`, opacity: 0.6, duration: ABGANG.dauer + 0.2, ease: ABGANG.ease });
return gsap.to(teile, { filter: `blur(${ABGANG.blur}px)`, autoAlpha: 0, duration: ABGANG.dauer, ease: ABGANG.ease, stagger: { amount: 0.2, from: 'random' } });
}
function setzeModus(neu) {
modus = neu;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
}
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-modus]');
if (!b) return;
pause();
setzeModus(b.dataset.modus);
aufbauen();
});
// Klick oder Antippen auf die Headline: neu scharfstellen
document.querySelector('[data-wiederholen]').addEventListener('click', () => { pause(); aufbauen(); });
// Zitat: Wörter an den Scroll gekoppelt (scrub), Start 'top 90%' wie bei TRIONN
function zitatVorbereiten() {
if (reduziert()) { gsap.set(zitat, { visibility: 'visible' }); return; }
SplitText.create(zitat, {
type: 'words', autoSplit: true, wordsClass: 'wort',
onSplit(self) {
gsap.set(zitat, { visibility: 'visible' });
return gsap.fromTo(self.words, { filter: 'blur(12px)', opacity: 0.12 },
{ filter: 'blur(0px)', opacity: 1, ease: 'power2.out', stagger: 0.05,
scrollTrigger: { trigger: zitat, start: 'top 90%', end: 'bottom 55%', scrub: true } });
},
});
}
// Galerie und Knopf „Vorführen“: alle drei Varianten im Wechsel
let uhr = null;
const reihe = ['woerter', 'zeichen', 'dezent'];
function vorfuehren() {
pause();
let i = 0;
setzeModus(reihe[i]);
aufbauen();
const takt = () => {
uhr = setTimeout(() => {
if (reduziert()) { i = (i + 1) % reihe.length; setzeModus(reihe[i]); aufbauen(); takt(); return; }
verbergen().then(() => {
i = (i + 1) % reihe.length;
setzeModus(reihe[i]);
setTimeout(() => { aufbauen(); takt(); }, 250);
});
}, 3000);
};
takt();
}
function pause() { clearTimeout(uhr); uhr = null; }
function neustart() { pause(); scrollTo(0, 0); aufbauen(); }
document.fonts.ready.then(() => { // erst nach den Schriften teilen, sonst falsche Umbrüche
aufbauen();
zitatVorbereiten();
});
})();
</script>
</body>
</html>