Zum Inhalt
← Schrift-Animationen

Text-Roll beim Hover (doppelte Zeile rollt durch)

Jedes Label liegt doppelt in einer einzeiligen Maske; beim Hover rollt eine Zeichenwelle die Kopie herein, ohne dass sich das Layout bewegt.

Das Label eines Links oder Buttons liegt zweimal übereinander in einer Maske mit overflow: hidden. Beim Hover rollt die obere Zeile nach oben hinaus (translateY −100 %), die Kopie rollt von unten nach. In der hochwertigen Variante rollt jedes Zeichen einzeln mit kleiner Verzögerung, so läuft eine Welle durch das Wort. Beim Verlassen rollt alles zurück.

Das Feedback ist deutlich und braucht keinen zusätzlichen Platz. Die Zeichenwelle wirkt mechanisch und taktil, wie eine Fallblattanzeige. Das Detail wirkt präzise und typografisch und gehört zu den meistkopierten Details auf Award-Seiten.

Wann einsetzen

Navigation, CTA-Buttons, Footer-Links und Projektlisten mit kurzen Labels.

Wann nicht

Links im Fließtext, weil der Effekt beim Lesen stört. Lange Labels, weil die Welle zu lange dauert. Schriften mit stark schwankender Zeilenhöhe, weil die Kopie dann springt.

Varianten

  • Ganze Zeile rollt: zwei Spans, kein Versatz (Mont-Fort: 400 ms, cubic-bezier(.4,0,.1,1)).
  • Zeichenweise gestaffelt: Index pro Zeichen, transition-delay: calc(var(--i) * 12ms), Kopie 10 ms später (Quentin Hocdé).
  • Stilwechsel: Die Kopie steht in einem anderen Schnitt oder einer anderen Farbe, zum Beispiel kursiv.
  • text-shadow-Duplikat: text-shadow: 0 1lh currentColor liefert die zweite Zeile, nur ein DOM-Knoten.
  • Kopie per ::after mit content: attr(data-text), startet bei top: 100 % und scale(.9) (Cuberto).
  • Raus und rein getrennt: erst nach oben hinaus, dann von unten herein (Codrops Hyperion: je 0,2 s; MagneticButtons: 0,15 s power2.in, dann 0,2 s expo.out).
  • Richtungsabhängig: rollt in Richtung der Mausbewegung.
  • 3D-Würfel: Zeichen kippen per rotateX um die eigene Achse.

Umsetzung

HTML: <a href="…" aria-label="Kontakt"><span class="roll" aria-hidden="true"><span class="roll-a">…Zeichen…</span><span class="roll-b">…Zeichen…</span></span></a>. Jedes Zeichen ist ein <span class="z" style="--i:0"> (eigenes Splitting oder SplitText mit propIndex, künftig sibling-index()). CSS: .roll { position: relative; display: inline-block; overflow: hidden; line-height: 1.3 } .roll-b { position: absolute; inset: 0 } .z { display: inline-block; transition: transform .8s cubic-bezier(.16,1,.3,1); transition-delay: calc(var(--i) * 12ms) } .roll-b .z { transform: translateY(100%); transition-delay: calc(var(--i) * 12ms + 10ms) } a:hover .roll-a .z { transform: translateY(-100%) } a:hover .roll-b .z { transform: none }. Hover nur unter @media (hover: hover) and (pointer: fine), :focus-visible immer gleich behandeln. Fallstricke: line-height der Maske groß genug für Unterlängen wählen, sonst blitzen sie oben auf. Bei Zeichen-Spans geht das Kerning verloren, Ligaturen prüfen. Ein wenig padding-inline verhindert abgeschnittene Überhänge.

Robust umsetzen

Telefon. Ohne Hover nicht rollen lassen. Beim Tippen einmal abspielen oder ganz weglassen. Den Hover-Effekt in @media (hover: hover) kapseln.

Ohne Bewegung. Kein Rollen. Farbwechsel und eingeblendete Unterstreichung statt Bewegung.

Barrierefrei. Der Linktext darf genau einmal vorgelesen werden. Deshalb aria-label am Link und beide Zeichenzeilen aria-hidden, oder der text-shadow-Trick, der gar nicht vorgelesen wird. Zeichen-Spans dürfen den Linknamen nicht zerhacken. :focus-visible löst denselben Effekt aus wie Hover.

Code

<!DOCTYPE html>
<!--
  Text-Roll beim Hover (doppelte Zeile rollt durch)
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/text-roll-hover/
  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>Text-Roll 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: '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 {
  --papier: #ebe7de; --tinte: #171614; --leise: rgba(23, 22, 20, .66); --linie: rgba(23, 22, 20, .15);
  --ziegel: #a4482a;
  /* Werte der aktiven Variante; JS setzt sie am <body> */
  --kurve: cubic-bezier(0.16, 1, 0.3, 1);
  --dauer: 800ms;
  --versatz: 12ms;       /* pro Zeichen */
  --nachzug: 10ms;       /* zweite Zeile startet so viel später */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
a { color: inherit; text-decoration: none; }

.seite { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; gap: clamp(20px, 3.4vw, 40px); padding: clamp(18px, 3.4vw, 44px); }

/* Kopfzeile */
.kopf { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.marke { display: flex; align-items: baseline; gap: 12px; }
.marke b { font-size: 19px; font-weight: 600; letter-spacing: -.03em; }
.marke b i { font: italic 400 1.12em/1 'Instrument Serif', Georgia, serif; letter-spacing: 0; color: var(--ziegel); padding: 0 .06em; }
.nav { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); font-size: 15px; font-weight: 500; letter-spacing: -.01em; }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 8px 0 18px; border-radius: 999px; background: var(--tinte); color: var(--papier); }
.knopf .punkt { width: 26px; height: 26px; border-radius: 50%; background: var(--papier); color: var(--tinte); display: grid; place-items: center; }
.knopf .punkt svg { width: 11px; height: 11px; }

/* Das Menü: große Zeilen, jede Zeile ist der Link */
.mitte { display: grid; grid-template-columns: 1fr clamp(220px, 25vw, 340px); gap: clamp(24px, 5vw, 72px); align-items: center; }
.menue { list-style: none; padding: 0; border-bottom: 1px solid var(--linie); }
.menue a { display: grid; grid-template-columns: 3em 1fr auto; align-items: center; gap: 12px; padding: clamp(2px, .5vw, 6px) 0; border-top: 1px solid var(--linie); outline: none; }
.menue .wort { font-size: clamp(42px, 7.6vw, 112px); font-weight: 500; letter-spacing: -.045em; }
.menue .zusatz { text-align: right; }
.bild { margin: 0; display: grid; gap: 10px; }
.bild img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 4px; }
.bild figcaption { display: flex; justify-content: space-between; }

/* Fuß: Werte und Umschalter */
.fuss { display: flex; justify-content: space-between; align-items: center; gap: 12px 24px; flex-wrap: wrap; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.werte b { color: var(--tinte); 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(--tinte); border-color: rgba(23, 22, 20, .35); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }

/* ───── Der Effekt ─────
   .roll       Maske, genau eine Zeile hoch, overflow: hidden
   .roll-a     sichtbare Zeile, Zeichen rollen nach oben hinaus (−100 %)
   .roll-b     Kopie (aria-hidden), liegt darunter und rollt nach (100 % → 0)
   --i         Zeichenindex, steuert die Verzögerung                     */
.roll { position: relative; display: inline-block; vertical-align: top; overflow: hidden; line-height: 1.3; white-space: nowrap;
  padding-inline: .08em; margin-inline: -.08em; }   /* Luft für Überhänge (Kursive, enge Laufweite) */
.roll-a, .roll-b { display: block; }
.roll-b { position: absolute; inset: 0; }
.roll .z { display: inline-block; white-space: pre; will-change: transform;
  transition: transform var(--dauer) var(--kurve);
  transition-delay: calc(var(--i) * var(--versatz)); }
.roll-b .z { transform: translateY(100%); transition-delay: calc(var(--i) * var(--versatz) + var(--nachzug)); }
/* Variante Kursiv: die Kopie wechselt den Schnitt */
[data-variante='kursiv'] .roll-b { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; letter-spacing: -.01em; color: var(--ziegel); }
.knopf .roll-b { color: inherit; }
[data-variante='kursiv'] .menue .roll-b .z { font-size: 1.08em; }

/* Auslöser: Hover nur für feine Zeiger, Tastaturfokus immer, .an für Vorführung und Tippen */
@media (hover: hover) and (pointer: fine) {
  [data-roll]:hover .roll-a .z { transform: translateY(-100%); }
  [data-roll]:hover .roll-b .z { transform: translateY(0); }
}
[data-roll]:focus-visible .roll-a .z, [data-roll].an .roll-a .z { transform: translateY(-100%); }
[data-roll]:focus-visible .roll-b .z, [data-roll].an .roll-b .z { transform: translateY(0); }
[data-roll]:focus-visible { outline: 2px solid var(--ziegel); outline-offset: 4px; border-radius: 2px; }
.menue a:focus-visible { outline-offset: -2px; }
.menue a .mono, .menue a .zusatz { transition: color .15s; }
@media (hover: hover) and (pointer: fine) { .menue a:hover .mono { color: var(--tinte); } }
.menue a.an .mono, .menue a:focus-visible .mono { color: var(--tinte); }

/* Reduzierte Bewegung: kein Rollen, Farbwechsel und Unterstreichung */
.reduziert .roll-b { display: none; }
.reduziert .roll .z { display: inline; transition: none; transform: none !important; }
.reduziert .roll { transition: color .15s linear; }
.reduziert .roll::after { content: ''; position: absolute; left: .08em; right: .08em; bottom: .1em; height: max(1px, .05em); background: currentColor; opacity: 0; transition: opacity .15s linear; }
.reduziert [data-roll]:hover .roll, .reduziert [data-roll].an .roll, .reduziert [data-roll]:focus-visible .roll { color: var(--ziegel); }
.reduziert [data-roll]:hover .roll::after, .reduziert [data-roll].an .roll::after, .reduziert [data-roll]:focus-visible .roll::after { opacity: 1; }
.reduziert .knopf:hover .roll, .reduziert .knopf.an .roll, .reduziert .knopf:focus-visible .roll { color: var(--papier); }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 9; width: 20px; height: 20px; pointer-events: none; display: none; transition: transform .7s cubic-bezier(0.16, 1, 0.3, 1); }
.vorfuehrung .zeiger { display: block; }

/* Telefon */
@media (max-width: 760px) {
  .klein { display: none; }
  .knopf { height: 38px; font-size: 14px; padding-left: 14px; }
  .mitte { grid-template-columns: 1fr; align-items: start; align-content: start; }
  .menue a { grid-template-columns: 2.2em 1fr auto; }
  .menue .wort { font-size: clamp(40px, 13vw, 64px); }
  .bild { grid-template-columns: 128px 1fr; align-items: end; gap: 14px; }
  .bild figcaption { flex-direction: column; gap: 6px; }
}
@media (max-width: 420px) { .menue .zusatz { display: none; } .menue a { grid-template-columns: 2.2em 1fr; } }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .seite { height: 100svh; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body data-variante="zeichen">
<div class="seite">
  <header class="kopf">
    <a class="marke" href="#start" aria-label="Hallberg und Rau, Startseite"><b>Hallberg<i>&amp;</i>Rau</b><span class="mono klein">Marke · Typografie · Web</span></a>
    <nav class="nav" aria-label="Hauptnavigation">
      <a class="klein" href="#journal" data-roll>Journal</a>
      <a class="klein" href="#jobs" data-roll>Jobs</a>
      <a class="knopf" href="#anfrage" data-roll>Projekt anfragen<span class="punkt" aria-hidden="true"><svg viewBox="0 0 12 12"><path d="M2 10 10 2M4 2h6v6" fill="none" stroke="currentColor" stroke-width="1.6"/></svg></span></a>
    </nav>
  </header>

  <main class="mitte">
    <ul class="menue" aria-label="Bereiche">
      <li><a href="#arbeiten" data-roll><span class="mono">01</span><span class="wort">Arbeiten</span><span class="mono zusatz">42 Projekte</span></a></li>
      <li><a href="#studio" data-roll><span class="mono">02</span><span class="wort">Studio</span><span class="mono zusatz">Seit 2014</span></a></li>
      <li><a href="#haltung" data-roll><span class="mono">03</span><span class="wort">Haltung</span><span class="mono zusatz">Weniger, genauer</span></a></li>
      <li><a href="#kontakt" data-roll><span class="mono">04</span><span class="wort">Kontakt</span><span class="mono zusatz">Ehrenfeld, Köln</span></a></li>
    </ul>
    <figure class="bild">
      <img src="bilder/typo-plakat-hoch.jpg" alt="Hochformatiges Plakat in Neongelb: LICHT in schwarzer fetter Grotesk über zwei Zeilen, ein orangefarbener Kreis und die Kursive und was es übrig lässt" width="800" height="1000">
      <figcaption><span class="mono">Zuletzt: Licht, Plakat</span><span class="mono">2026</span></figcaption>
    </figure>
  </main>

  <footer class="fuss">
    <p class="werte mono" data-werte aria-live="polite"></p>
    <div class="modi mono" role="group" aria-label="Variante">
      <button type="button" data-modus="zeichen" aria-pressed="true">Zeichen</button>
      <button type="button" data-modus="kursiv" aria-pressed="false">Kursiv</button>
      <button type="button" data-modus="zeile" aria-pressed="false">Ganze Zeile</button>
    </div>
  </footer>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#171614" stroke="#ebe7de" stroke-width="1.2" stroke-linejoin="round"/></svg>

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

  // Werte aus den Messungen: Quentin Hocdé (12 ms, +10 ms), Cuberto (expo.out 800 ms),
  // Osmo (20 ms pro Zeichen), Mont-Fort (ganze Zeile, 400 ms, cubic-bezier(.4,0,.1,1))
  const VARIANTEN = {
    zeichen: { name: 'expo.out', kurve: 'cubic-bezier(0.16, 1, 0.3, 1)', dauer: 800, versatz: 12, nachzug: 10 },
    kursiv:  { name: 'expo.out', kurve: 'cubic-bezier(0.16, 1, 0.3, 1)', dauer: 800, versatz: 20, nachzug: 10 },
    zeile:   { name: '', kurve: 'cubic-bezier(0.4, 0, 0.1, 1)', dauer: 400, versatz: 0, nachzug: 0 }
  };
  let variante = 'zeichen';

  // Label zweimal in Zeichen-Spans aufbauen. Der Link behält seinen Namen per aria-label,
  // beide Zeilen sind aria-hidden, damit der Text genau einmal vorgelesen wird.
  function zeichen(text) {
    return [...text].map((c, i) => `<span class="z" style="--i:${i}">${c === ' ' ? ' ' : c}</span>`).join('');
  }
  function aufbauen(el, ziel) {
    const text = ziel.textContent.trim();
    ziel.innerHTML = `<span class="roll" aria-hidden="true"><span class="roll-a">${zeichen(text)}</span><span class="roll-b">${zeichen(text)}</span></span>`;
    return text;
  }
  document.querySelectorAll('[data-roll]').forEach(el => {
    const wort = el.querySelector('.wort');
    if (wort) {
      const zusatz = el.querySelector('.zusatz');
      el.setAttribute('aria-label', aufbauen(el, wort) + (zusatz ? ': ' + zusatz.textContent : ''));
    } else {
      // Text steht direkt im Link (vor dem Pfeil)
      const knoten = [...el.childNodes].find(n => n.nodeType === 3 && n.textContent.trim());
      const span = document.createElement('span');
      span.textContent = knoten.textContent;
      el.replaceChild(span, knoten);
      el.setAttribute('aria-label', aufbauen(el, span));
    }
  });

  function setzeVariante(m) {
    variante = m;
    const v = VARIANTEN[m];
    document.body.dataset.variante = m;
    document.body.style.setProperty('--kurve', v.kurve);
    document.body.style.setProperty('--dauer', v.dauer + 'ms');
    document.body.style.setProperty('--versatz', v.versatz + 'ms');
    document.body.style.setProperty('--nachzug', v.nachzug + 'ms');
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
    zeigeWerte();
  }
  function zeigeWerte() {
    const v = VARIANTEN[variante];
    if (reduziert()) { werte.innerHTML = '<span>Reduzierte Bewegung: <b>Farbe + Unterstreichung, 150 ms</b></span>'; return; }
    werte.innerHTML = `<span>Kurve <b>${v.name ? v.name + ' ' : ''}${v.kurve.replace(/ /g, '')}</b></span><span>Dauer <b>${v.dauer} ms</b></span>`
      + (v.versatz ? `<span>Versatz <b>${v.versatz} ms/Zeichen</b></span><span>Kopie <b>+${v.nachzug} ms</b></span>` : '<span>Versatz <b>keiner</b></span>')
      + '<span>Weg <b>−100 %</b></span>';
  }
  knoepfe.forEach(b => b.addEventListener('click', () => { neustart(); setzeVariante(b.dataset.modus); }));

  // Touch: kein Hover. Beim Tippen einmal durchrollen, dann zurück.
  const ziele = [...document.querySelectorAll('[data-roll]')];
  ziele.forEach(el => {
    el.addEventListener('click', e => e.preventDefault());
    el.addEventListener('pointerdown', e => {
      if (e.pointerType === 'mouse') return;
      const v = VARIANTEN[variante];
      el.classList.add('an');
      clearTimeout(el._t);
      el._t = setTimeout(() => el.classList.remove('an'), v.dauer + 20 * v.versatz + 250);
    });
  });

  // Vorführung: ein gedachter Zeiger fährt die Menüzeilen und den Knopf ab.
  // Pro Runde eine Variante: Kursiv (sieht man auch im Standbild) → Zeichen → Ganze Zeile.
  const zeilen = [...document.querySelectorAll('.menue a')];
  const knopf = document.querySelector('.knopf');
  const folge = [...zeilen, knopf, null];
  const reihe = ['kursiv', 'zeichen', 'zeile'];
  let uhr = null, schrittNr = 0, runde = 0;

  function zeigerAuf(el) {
    let x, y;
    if (el) {
      const r = (el.querySelector('.roll') || el).getBoundingClientRect();
      x = r.left + Math.min(r.width * 0.55, 160); y = r.top + r.height * 0.6;
    } else { x = innerWidth * 0.62; y = innerHeight * 0.9; }
    zeiger.style.transform = `translate(${x - 2}px, ${y - 1}px)`;
  }
  function schritt() {
    const el = folge[schrittNr % folge.length];
    if (schrittNr % folge.length === 0) { setzeVariante(reihe[runde % reihe.length]); runde++; }
    zeigerAuf(el);
    ziele.forEach(z => z.classList.remove('an'));
    uhr = setTimeout(() => {
      if (el) el.classList.add('an');
      schrittNr++;
      uhr = setTimeout(schritt, el ? 1150 : 700);
    }, 260);
  }
  function vorfuehren() {
    pause();
    html.classList.add('vorfuehrung');
    schrittNr = 0; runde = 0;
    zeigerAuf(null);
    uhr = setTimeout(schritt, 400);
  }
  function pause() { clearTimeout(uhr); uhr = null; }
  function weiter() { if (!uhr && html.classList.contains('vorfuehrung')) uhr = setTimeout(schritt, 200); }
  function neustart() { pause(); html.classList.remove('vorfuehrung'); ziele.forEach(z => z.classList.remove('an')); }

  setzeVariante('zeichen');
  // Rahmen kann .reduziert erst nach diesem Skript setzen
  addEventListener('load', zeigeWerte);

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