Zum Inhalt
← Schrift-Animationen

Buchstaben zerstreuen sich beim Hover

Die große Kontaktadresse zerfällt beim Hover in einzelne Buchstaben, die Fläche wird schwarz, beim Verlassen setzt sich das Wort wieder zusammen.

  1. Ein Wort wird in Zeichen geteilt.
  2. Beim Hover fliegt jedes Zeichen zu einem eigenen, vorab festgelegten Versatz (x und y in em) mit Rotation, das Wort explodiert locker.
  3. Beim Verlassen setzen sich die Zeichen wieder zusammen.
  4. Oft dunkelt gleichzeitig der Hintergrund ab oder ein Bild erscheint.

Eine kleine Überraschung an einer Stelle, die sonst trocken ist. Eine E-Mail-Adresse oder ein Name wird zum Erlebnis. Lesbar bleibt das Wort trotzdem, denn die Ruhelage kehrt sofort zurück.

Wann einsetzen

Große Kontaktadresse im Footer, Name im Hero, Social-Links in verspielten Portfolios.

Wann nicht

Bei Navigation und Buttons mit klarer Funktion, denn das Ziel wird unlesbar. Bei Klickzielen, die präzise getroffen werden müssen. Bei langen Wörtern und kleinen Schriftgraden.

Varianten

  • Feste Versatztabelle je Zeichen (Olivier Larose, 0,75 s)
  • Zufall mit Seed: deterministisch, sieht bei jedem Hover gleich aus
  • Vom Zeiger weg: Richtung und Stärke aus der Zeigerposition (siehe cursor-text-proximity)
  • Physik: Zeichen fallen und prallen (Matter.js)
  • 3D-Buchstaben kippen beim Menü-Hover (Codrops 3DLettersMenuHover)
  • Gooey-Verschmelzen per SVG-Filter (Codrops GooeyTextHoverEffect)
  • Wackeln einzelner Zeichen (Motion „Bobble hover“)

Umsetzung

Zeichen als Spans mit --x, --y und --r aus einer Tabelle oder aus Seed-Zufall, damit es bei jedem Hover gleich aussieht. CSS: .zeichen { display: inline-block; transition: translate .75s cubic-bezier(.33, 1, .68, 1), rotate .75s cubic-bezier(.33, 1, .68, 1) } [data-aktiv] .zeichen { translate: calc(var(--x) * 1em) calc(var(--y) * 1em); rotate: calc(var(--r) * 1deg) }. Die Hover-Fläche ist der ruhende Container (Link), die Zeichen bekommen pointer-events: none, sonst flackert es. Etwas Innenabstand oben und unten vergrößert die Fläche. Fallstrick: Die Tabelle muss so lang sein wie das Wort (das Larose-Beispiel bricht über 13 Zeichen). Randzeichen nach innen versetzen, sonst fliegen sie aus dem Viewport.

Robust umsetzen

Telefon. Beim Tippen oder beim Erscheinen einmal kurz abspielen, oder weglassen. Tippen muss direkt zum Ziel führen.

Ohne Bewegung. Kein Zerstreuen, nur ein Farbwechsel.

Barrierefrei. Der Link trägt das ganze Wort im aria-label, die Zeichen sind aria-hidden. Tastaturfokus löst denselben Zustand aus, die Fokusmarke bleibt sichtbar.

Code

<!DOCTYPE html>
<!--
  Buchstaben zerstreuen sich beim Hover
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/buchstaben-zerstreuen/
  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>Buchstaben zerstreuen sich beim Hover · 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: 'TikTok Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/tiktok-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 300 900; font-stretch: 75% 150%; font-display: swap; }
@font-face { font-family: 'DM Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/dm-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; 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 {
  --schwarz: #0b0b0b; --hell: #f2f2f2; --weiss: #ffffff; --meta: #6b6b6b; --signal: #ff5a1f;
  --kurve: cubic-bezier(0.33, 1, 0.68, 1);   /* gemessen: power2.out-artig, 750 ms */
  --dauer: 750ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--hell); color: var(--schwarz); font-family: 'TikTok Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
a { color: inherit; }
.mono { font: 400 .75rem/1.35 'DM Mono', ui-monospace, monospace; letter-spacing: .04em; text-transform: uppercase; }

/* ---------- Outro / Kontakt ---------- */
.outro { position: relative; min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr auto auto; overflow: clip;
  padding: clamp(18px, 2.4vw, 32px) clamp(18px, 3vw, 44px) 0; background: var(--hell); color: var(--schwarz);
  transition: background-color var(--dauer) var(--kurve), color var(--dauer) var(--kurve); }
.outro[data-dunkel] { background: var(--schwarz); color: var(--weiss); --meta: #8a8a8a; }
.leiste { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; }
.logo { font: 850 1.375rem/1 'TikTok Sans', sans-serif; font-stretch: 75%; letter-spacing: -.02em; text-decoration: none; text-transform: uppercase; }
.navi { display: flex; gap: clamp(14px, 2vw, 28px); list-style: none; padding: 0; }
.navi a { text-decoration: none; font-size: .9375rem; font-weight: 500; }
.navi a[aria-current] { text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 5px; }

/* Varianten-Umschalter (Teil der Demo, dezent) */
.modi { justify-self: end; display: flex; gap: 4px; font: 500 10.5px/1 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; background: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
  border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: opacity 150ms var(--kurve), background-color 150ms var(--kurve); }
.modi button { color: inherit; opacity: .64; }
.modi button:hover { opacity: 1; }
.modi button[aria-pressed='true'] { opacity: 1; background: currentColor; }
.modi button[aria-pressed='true'] span { color: var(--hell); }
.outro[data-dunkel] .modi button[aria-pressed='true'] span { color: var(--schwarz); }

.mitte { align-self: center; display: grid; gap: clamp(18px, 3vh, 32px); padding-block: clamp(32px, 6vh, 72px); }
.frage { font-weight: 600; font-size: clamp(2rem, 4.5vw, 4.5rem); line-height: .95; letter-spacing: -.02em; max-width: 15ch; }
.frage em { font-style: normal; font-stretch: 75%; font-weight: 800; }

/* Das Wort: ruhender Container = Hover-Fläche, Zeichen fliegen darin */
.mail { display: inline-block; justify-self: start; text-decoration: none; outline: none; position: relative;
  font-weight: 800; font-stretch: 75%; font-size: clamp(3rem, 10.6vw, 12.5rem); line-height: .9; letter-spacing: -.03em; white-space: nowrap;
  padding-block: .12em; margin-block: -.12em; }
.mail .stueck { white-space: nowrap; }
.mail .zeichen { display: inline-block; pointer-events: none; will-change: translate, rotate;
  transition: translate var(--dauer) var(--kurve), rotate var(--dauer) var(--kurve), color var(--dauer) var(--kurve); }
[data-aktiv] .zeichen { translate: calc(var(--x) * 1em) calc(var(--y) * 1em); rotate: calc(var(--r) * 1deg); }
.mail [data-at] { transition-property: translate, rotate, color; }
.mail[data-aktiv] [data-at] { color: var(--signal); }
.mail:focus-visible::after { content: ''; position: absolute; inset: auto 0 -.08em 0; height: 3px; background: var(--signal); }
.unterzeile { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 32px; color: var(--meta); }
.unterzeile p { max-width: 44ch; font-size: 1.125rem; line-height: 1.4; color: inherit; }
.werte { text-align: right; }
.werte b { font-weight: 400; color: var(--schwarz); transition: color var(--dauer) var(--kurve); }
.outro[data-dunkel] .werte b { color: var(--weiss); }

/* Fußzeile mit Links und Riesen-Wortmarke */
.fuss { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 24px; padding: 18px 0; border-top: 1.5px solid currentColor; }
.fuss a { text-decoration: none; justify-self: start; }
.fuss .link { font-size: 1.125rem; font-weight: 600; letter-spacing: -.01em; white-space: nowrap; padding-block: .3em; margin-block: -.3em; }
.fuss .link .zeichen { display: inline-block; pointer-events: none; transition: translate var(--dauer) var(--kurve), rotate var(--dauer) var(--kurve); }
.fuss address { font-style: normal; color: var(--meta); font-size: .9375rem; line-height: 1.4; }
.wortmarke { font-weight: 900; font-stretch: 75%; font-size: 24vw; line-height: .74; letter-spacing: -.035em; text-transform: uppercase;
  margin: 0 0 -0.035em; width: max-content; white-space: nowrap; user-select: none; pointer-events: none; }

/* Während die Adresse zerfällt, treten Frage und Begleittext zurück */
.frage, .unterzeile p:first-child, .mitte > .mono { transition: opacity var(--dauer) var(--kurve); }
.outro[data-dunkel] :is(.frage, .unterzeile p:first-child, .mitte > .mono) { opacity: .22; }

/* Vorschau: nur die Bühne, ohne Bedienung */
.vorschau body { overflow: hidden; }
.vorschau .modi, .vorschau .werte, .vorschau .navi { display: none; }

/* Telefon */
@media (max-width: 640px) {
  .leiste { grid-template-columns: 1fr auto; }
  .navi { display: none; }
  .modi { grid-column: 1 / -1; justify-self: start; flex-wrap: wrap; }
  .mail { font-size: 14.2vw; line-height: .92; white-space: normal; }
  .mail .stueck { display: block; }
  .mail .stueck:first-child { font-size: 1.5em; }
  .unterzeile .werte { text-align: left; font-size: .8125rem; line-height: 1.5; }
  .fuss { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .fuss address { grid-column: 1 / -1; }
}
/* Touch: kein Hover, das Wort spielt einmal, sobald es zu sehen ist; Tippen schreibt direkt die Mail */
@media (hover: none) {
  .hinweis-hover { display: none; }
}
@media (hover: hover) { .hinweis-touch { display: none; } }

/* Reduzierte Bewegung: kein Zerstreuen, nur Farbwechsel */
.reduziert .zeichen { translate: none !important; rotate: none !important; }
.reduziert .outro, .reduziert .zeichen, .reduziert .werte b { transition-duration: 200ms; }

@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="outro" aria-labelledby="frage">
  <div class="leiste">
    <a class="logo" href="#">Gerberei</a>
    <nav aria-label="Hauptnavigation"><ul class="navi">
      <li><a href="#">Arbeiten</a></li><li><a href="#">Studio</a></li><li><a href="#">Journal</a></li><li><a href="#" aria-current="page">Kontakt</a></li>
    </ul></nav>
    <div class="modi" role="group" aria-label="Variante der Streuung">
      <button type="button" data-modus="tabelle" aria-pressed="true"><span>Handgesetzt</span></button>
      <button type="button" data-modus="seed" aria-pressed="false"><span>Zufall mit Seed</span></button>
      <button type="button" data-modus="zeiger" aria-pressed="false"><span>Vom Zeiger weg</span></button>
    </div>
  </div>

  <div class="mitte">
    <p class="mono">(06) Kontakt · Offenbach am Main</p>
    <h2 class="frage" id="frage">Habt ihr etwas, das <em>lauter</em> werden soll?</h2>
    <a class="mail" href="mailto:hallo@gerberei.studio" aria-label="E-Mail an hallo@gerberei.studio" data-zerstreuen="mail" data-text="hallo@|gerberei.studio"></a>
    <div class="unterzeile">
      <p><span class="hinweis-hover">Zeiger drauf, dann schreiben.</span><span class="hinweis-touch">Tippen, dann schreiben.</span> Wir antworten innerhalb eines Werktags, meistens schneller.</p>
      <p class="mono werte" data-werte aria-live="polite"></p>
    </div>
  </div>

  <div class="fuss">
    <a href="#" class="link" data-zerstreuen="link" data-text="Instagram" aria-label="Instagram"></a>
    <a href="#" class="link" data-zerstreuen="link" data-text="Are.na" aria-label="Are.na"></a>
    <a href="#" class="link" data-zerstreuen="link" data-text="LinkedIn" aria-label="LinkedIn"></a>
    <address>Gerberei GmbH<br>Kaiserstraße 39, 63065 Offenbach am Main</address>
  </div>
  <p class="wortmarke" aria-hidden="true">Gerberei</p>
</section>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const outro = document.querySelector('.outro');
  const werte = document.querySelector('[data-werte]');

  // Grenzen aus der Messung (Olivier Larose): x ±0.8em, y −0.65…0.6em, Rotation −29…20°
  const X = 0.8, Y0 = -0.65, Y1 = 0.6, R0 = -29, R1 = 20;
  // Handgesetzte Tabelle, eine Zeile je Zeichen von „hallo@gerberei.studio“ (21 Zeichen; die Tabelle muss so lang sein wie das Wort)
  const TABELLE = [
    [.25, -.45, -18], [-.20, .48, 12], [-.62, .10, -29], [.18, -.58, 16], [-.30, .30, -8], [.05, -.65, 20],
    [-.45, .55, -14], [.40, -.20, 9], [-.15, -.50, -22], [.30, .60, 14], [-.50, -.10, -10], [.62, .35, 18],
    [-.08, .52, -25], [.45, -.62, 6], [.80, -.30, 20], [-.35, .40, -16], [.22, -.45, 11], [-.60, -.05, -6],
    [.15, .58, 17], [-.25, -.60, -29], [-.38, .30, 10]
  ];
  const SEED = 63065;
  function mulberry32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }

  // Zeichen erzeugen: Spans sind aria-hidden, der Link trägt das ganze Wort im aria-label
  const woerter = [...document.querySelectorAll('[data-zerstreuen]')].map((el, nr) => {
    const zeichen = [];
    el.dataset.text.split('|').forEach(teil => {
      // Stücke umbrechen nur als Ganzes (Telefon: „hallo@“ über „gerberei.studio“)
      const stueck = document.createElement('span'); stueck.className = 'stueck'; stueck.setAttribute('aria-hidden', 'true');
      [...teil].forEach(z => {
        const s = document.createElement('span');
        s.className = 'zeichen'; s.textContent = z;
        if (z === '@') s.dataset.at = '';
        stueck.append(s); zeichen.push(s);
      });
      el.append(stueck);
    });
    return { el, nr, zeichen, gross: el.dataset.zerstreuen === 'mail' };
  });

  let modus = 'tabelle';
  function setzeVersatz(w, zeiger) {
    const n = w.zeichen.length;
    if (modus === 'tabelle') {
      w.zeichen.forEach((s, i) => { const [x, y, r] = TABELLE[i % TABELLE.length]; s.style.setProperty('--x', x); s.style.setProperty('--y', y); s.style.setProperty('--r', r); });
    } else if (modus === 'seed') {
      const zufall = mulberry32(SEED + w.nr * 101);
      w.zeichen.forEach(s => {
        s.style.setProperty('--x', (-X + zufall() * 2 * X).toFixed(3));
        s.style.setProperty('--y', (Y0 + zufall() * (Y1 - Y0)).toFixed(3));
        s.style.setProperty('--r', (R0 + zufall() * (R1 - R0)).toFixed(1));
      });
    } else {
      // Vom Zeiger weg: Richtung vom Zeiger zur ruhenden Zeichenmitte, Stärke fällt mit der Entfernung
      const box = w.el.getBoundingClientRect(), radius = Math.max(box.width, box.height) * .6;
      const px = zeiger ? zeiger.x - box.left : box.width / 2, py = zeiger ? zeiger.y - box.top : box.height / 2;
      w.zeichen.forEach((s, i) => {
        const cx = s.offsetLeft + s.offsetWidth / 2, cy = s.offsetTop + s.offsetHeight / 2;
        let dx = cx - px, dy = cy - py; const d = Math.hypot(dx, dy) || 1;
        dx /= d; dy /= d;
        const k = Math.max(.25, 1 - d / radius);
        const quer = ((i * 7) % 3 - 1) * .5;   // fester Querversatz je Zeichen: −0.5, 0 oder 0.5
        s.style.setProperty('--x', (dx * X * k).toFixed(3));
        s.style.setProperty('--y', Math.min(Y1, Math.max(Y0, (dy * .65 + quer * .8) * k)).toFixed(3));
        s.style.setProperty('--r', Math.round((dx > 0 ? R1 : R0) * k));
      });
    }
  }
  function anzeigen() {
    const was = { tabelle: 'Tabelle · 21 Zeilen', seed: `Seed ${SEED}`, zeiger: 'Radius 60 % der Breite' }[modus];
    werte.innerHTML = reduziert()
      ? 'Reduziert · <b>nur Farbwechsel, 200 ms</b>'
      : `<b>750 ms</b> · cubic-bezier(0.33, 1, 0.68, 1)<br>x <b>±0.8em</b> · y <b>−0.65…0.6em</b> · <b>−29°…20°</b> · ${was}`;
  }
  function aktiv(w, an, zeiger) {
    if (an) setzeVersatz(w, zeiger);
    w.el.toggleAttribute('data-aktiv', an);
    if (w.gross) outro.toggleAttribute('data-dunkel', an);
  }
  function setzeModus(m) {
    modus = m;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
    woerter.forEach(w => { if (w.el.hasAttribute('data-aktiv')) setzeVersatz(w); });
    anzeigen();
  }
  document.querySelectorAll('[data-modus]').forEach(b => b.addEventListener('click', () => { pause(); setzeModus(b.dataset.modus); }));

  // Hover nur mit echtem Zeiger; Fokus löst denselben Zustand aus
  const feinerZeiger = matchMedia('(hover: hover)');
  let letzterZeiger = null, zeigerBild = 0;
  woerter.forEach(w => {
    w.el.addEventListener('pointerenter', e => { if (e.pointerType === 'touch' || !feinerZeiger.matches) return; pause(); aktiv(w, true, e); });
    w.el.addEventListener('pointermove', e => {
      if (modus !== 'zeiger' || !w.el.hasAttribute('data-aktiv') || e.pointerType === 'touch') return;
      letzterZeiger = e;
      if (!zeigerBild) zeigerBild = requestAnimationFrame(() => { zeigerBild = 0; setzeVersatz(w, letzterZeiger); });
    });
    w.el.addEventListener('pointerleave', e => { if (e.pointerType === 'touch') return; aktiv(w, false); });
    w.el.addEventListener('focus', () => { if (w.el.matches(':focus-visible')) aktiv(w, true); });
    w.el.addEventListener('blur', () => aktiv(w, false));
  });

  // Touch: die Mail-Adresse spielt den Effekt einmal, sobald sie im Bild ist
  const mail = woerter.find(w => w.gross);
  if (!feinerZeiger.matches && 'IntersectionObserver' in window) {
    const io = new IntersectionObserver(e => {
      if (!e[0].isIntersecting || reduziert()) return;
      io.disconnect();
      setTimeout(() => { aktiv(mail, true); setTimeout(() => aktiv(mail, false), 1100); }, 400);
    }, { threshold: .7 });
    io.observe(mail.el);
  }

  // Vorführung: gedachter Zeiger fährt über die Adresse, Varianten wechseln je Runde
  const MODI = ['tabelle', 'seed', 'zeiger'];
  let uhren = [], runde = 0, laeuft = false;
  const spaeter = (f, ms) => uhren.push(setTimeout(f, ms));
  function eineRunde() {
    const vorschau = html.classList.contains('vorschau');
    if (vorschau) setzeModus(MODI[runde % 3]);
    runde++;
    const box = mail.el.getBoundingClientRect();
    const zeiger = t => ({ x: box.left + box.width * (.18 + .64 * t), y: box.top + box.height * .55 });
    spaeter(() => aktiv(mail, true, zeiger(0)), 300);
    if (modus === 'zeiger' && !reduziert()) [1, 2, 3, 4, 5, 6].forEach(i => spaeter(() => setzeVersatz(mail, zeiger(i / 6)), 300 + i * 380));
    spaeter(() => aktiv(mail, false), 2900);
    spaeter(() => { if (laeuft) eineRunde(); }, 4300);
  }
  function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; }
  function neustart() { pause(); woerter.forEach(w => aktiv(w, false)); laeuft = false; eineRunde(); uhren.push(setTimeout(pause, 4200)); }
  function vorfuehren() { pause(); woerter.forEach(w => aktiv(w, false)); laeuft = true; runde = 0; eineRunde(); }
  function weiter() { if (!laeuft) { laeuft = true; eineRunde(); } }

  // Riesen-Wortmarke exakt auf die Breite setzen
  const marke = document.querySelector('.wortmarke');
  function einpassen() {
    marke.style.fontSize = '';
    const soll = marke.parentElement.clientWidth - parseFloat(getComputedStyle(marke.parentElement).paddingLeft) * 2;
    marke.style.fontSize = (parseFloat(getComputedStyle(marke).fontSize) * soll / marke.scrollWidth).toFixed(2) + 'px';
  }
  addEventListener('resize', einpassen);
  (document.fonts ? document.fonts.ready : Promise.resolve()).then(einpassen);

  anzeigen();

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