Zum Inhalt
← Schrift-Animationen

Maskierte Zeilen-/Wort-Enthüllung (SplitText mit Maske)

Zeilen gleiten aus ihrer eigenen Maske nach oben: Text wird enthüllt statt eingeblendet, präzise wie ein Filmabspann.

  1. Die Überschrift wird in Zeilen geteilt (für lautere Fassungen zusätzlich in Wörter oder Zeichen).
  2. Jedes Teil bekommt einen eigenen Rahmen, der überstehenden Inhalt abschneidet: die Maske.
  3. Der Inhalt startet 110 % seiner Höhe tiefer, also unsichtbar unter der Maskenkante.
  4. Beim Laden oder beim Eintritt in den Viewport gleiten die Teile nacheinander auf ihren Platz, mit stark ausklingender Kurve (expo.out).
  5. Die Animation läuft einmal. Bis zum Teilen ist der Text per CSS unsichtbar, damit kein ungeteilter Text aufblitzt.

Der Text scheint aus einer Kante herauszuwachsen, statt nur aufzutauchen. Das wirkt gedruckt, präzise und hochwertig, wie die Titelsequenz eines Films. Die Deckkraft bleibt bei 1, es gibt also keine matte Zwischenphase. Der Versatz führt das Auge in Leserichtung. Seit SplitText mit Masken-Option 2025 kostenlos wurde, ist das Muster auf ausgezeichneten Seiten Standard.

Wann einsetzen

Hero-Überschriften, Abschnittstitel, kurze Statements, große Zitate und Kapitelanfänge in Fallstudien. Auch als Abschluss eines Intros.

Wann nicht

Fließtext über drei, vier Zeilen, Navigation, Formulare und wiederkehrende Labels. Nicht auf jeder Überschrift der Seite, sonst nutzt sich der Effekt ab: besser nur H1 und H2. Zeichen-Teilung nie bei Schriften mit komplexer Zeichenbildung (sie zerstört Ligaturen).

Varianten

  • Zeilen (ruhig, Standard): 1000 ms, Versatz 80 ms
  • Wörter: 800 ms, Versatz 40 ms
  • Zeichen (laut, nur für kurze Wörter): 600 ms, Versatz 12 ms
  • Mit Drehung: Zeile startet mit rotate 4–8° und richtet sich beim Hochgleiten auf
  • An den Scroll gekoppelt (scrub) statt einmalig, für Kapitelübergänge
  • Ohne JavaScript: Masken-Spans im HTML, Versatz über --i oder sibling-index(), Auslöser per animation-trigger
  • Mit Scramble kombiniert (Zeichen entschlüsseln sich, während sie aufsteigen)

Umsetzung

SplitText.create(el, { type: 'lines', mask: 'lines', autoSplit: true, onSplit(self) { return gsap.from(self.lines, { yPercent: 110, duration: 1, stagger: 0.08, ease: 'expo.out' }); } }). Für den Scroll-Auslöser scrollTrigger: { trigger: el, start: 'clamp(top 80%)', once: true } in den Tween.

Drei Fallstricke: Erst nach document.fonts.ready teilen, sonst stimmen die Umbrüche nicht. Den Tween aus onSplit zurückgeben, dann setzt autoSplit ihn bei neuem Umbruch sauber neu auf. Masken schneiden Unterlängen und Akzente ab: Masken mit padding-block 0,1em und gleich großem negativem margin-block erweitern.

Robust umsetzen

Telefon. Funktioniert unverändert. Zeilen-Teilung bevorzugen, Zeichen erzeugen auf schmalen Bildschirmen sehr viele Elemente. autoSplit ist nötig, weil sich Umbrüche beim Drehen ändern.

Ohne Bewegung. Kein Versatz, nur kurz einblenden (Deckkraft 0 → 1 in 200–300 ms) oder den Text sofort zeigen.

Barrierefrei. SplitText setzt aria-label auf das Element und aria-hidden auf die Teile, Screenreader lesen also den ganzen Satz. Verschachtelte Links gehen dabei verloren: dann aria: 'none' und eine visuell versteckte Kopie. Der Text darf nie dauerhaft unsichtbar bleiben, wenn JavaScript fehlt: die Klasse zum Verstecken erst per JavaScript setzen.

Code

<!DOCTYPE html>
<!--
  Maskierte Zeilen-/Wort-Enthüllung (SplitText mit Maske)
  Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/split-mask-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>Maskierte Zeilen-Enthüllung · 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: #0e0e10; --text: #f3f2ee; --leise: rgba(243, 242, 238, .56); --linie: rgba(243, 242, 238, .14);
  --akzent: #6fd0de;
}
* { 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; }
.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; padding: clamp(20px, 4vw, 48px); gap: 24px; }
.kopf { display: flex; justify-content: space-between; align-items: center; 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 .15s, border-color .15s; }
.modi button:hover { color: var(--text); border-color: rgba(243, 242, 238, .3); }
.modi button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }

.mitte { align-self: center; display: grid; gap: clamp(20px, 3vw, 36px); }
.titel { font-size: clamp(44px, 9vw, 140px); font-weight: 600; line-height: .92; letter-spacing: -.045em; max-width: 11ch; }
.titel em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.02em; color: var(--akzent); padding-right: .04em; }
.text { font-size: clamp(16px, 1.6vw, 20px); line-height: 1.45; color: var(--leise); max-width: 40ch; }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.werte b { color: var(--text); font-weight: 500; }

/* Vor dem Teilen unsichtbar, damit kein ungeteilter Text aufblitzt (nur mit JS) */
.js .teilen { visibility: hidden; }
/* Masken: Unterlängen (g, j, y) und Akzente nicht abschneiden */
.zeile-mask, .wort-mask, .zeichen-mask { padding-block: .1em; margin-block: -.1em; }

.weiter { padding: clamp(80px, 18vh, 200px) clamp(20px, 4vw, 48px) 30vh; border-top: 1px solid var(--linie); }
.satz { font-size: clamp(32px, 5.4vw, 84px); font-weight: 500; line-height: 1.02; letter-spacing: -.035em; max-width: 16ch; margin-top: 20px; }
.satz em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; letter-spacing: -.01em; }

/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .weiter, .vorschau .hinweis { display: none; }
</style>
</head>
<body>
<section class="buehne">
  <div class="kopf">
    <p class="mono">Schrift-Animation · Maske</p>
    <div class="modi mono" role="group" aria-label="Teilen nach">
      <button type="button" data-modus="zeilen" aria-pressed="true">Zeilen</button>
      <button type="button" data-modus="woerter" aria-pressed="false">Wörter</button>
      <button type="button" data-modus="zeichen" aria-pressed="false">Zeichen</button>
    </div>
  </div>
  <div class="mitte">
    <h1 class="titel teilen" data-titel>Gestaltung, die aus der <em>Kante</em> wächst.</h1>
    <p class="text teilen" data-text>Jede Zeile gleitet aus ihrer eigenen Maske nach oben. Der Text wird enthüllt, nicht eingeblendet.</p>
  </div>
  <p class="werte mono" data-werte aria-live="polite"></p>
</section>
<section class="weiter">
  <p class="mono hinweis">Beim Scrollen, einmal</p>
  <p class="satz teilen" data-satz>Typografie, die <em>auftritt</em> wie im Abspann eines Films.</p>
</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>
(() => {
  gsap.registerPlugin(SplitText, ScrollTrigger);
  const reduziert = () => document.documentElement.classList.contains('reduziert');

  // Drei Stufen: je feiner geteilt, desto kürzer und dichter gestaffelt.
  // Kurve überall expo.out = cubic-bezier(0.16, 1, 0.3, 1).
  const MODI = {
    zeilen:  { type: 'lines',              mask: 'lines', teile: 'lines', dauer: 1.0, versatz: 0.08 },
    woerter: { type: 'lines,words',        mask: 'words', teile: 'words', dauer: 0.8, versatz: 0.04 },
    zeichen: { type: 'lines,words,chars',  mask: 'chars', teile: 'chars', dauer: 0.6, versatz: 0.012 },
  };
  const titel = document.querySelector('[data-titel]');
  const text = document.querySelector('[data-text]');
  const satz = document.querySelector('[data-satz]');
  const werte = document.querySelector('[data-werte]');
  let modus = 'zeilen', teilungen = [];

  function zeigeWerte(m) {
    werte.innerHTML = reduziert()
      ? '<span>Reduzierte Bewegung: <b>nur einblenden, 300 ms</b></span>'
      : `<span>Kurve <b>expo.out</b></span><span>Dauer <b>${m.dauer * 1000} ms</b></span><span>Versatz <b>${Math.round(m.versatz * 1000)} ms</b></span><span>Weg <b>yPercent 110 → 0</b></span>`;
  }

  // Ein Element teilen und enthüllen. Der Tween kommt aus onSplit zurück,
  // damit autoSplit ihn bei neuem Umbruch (Drehen, Breite) sauber neu aufsetzt.
  function enthuellen(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, mask: m.mask, autoSplit: true,
      linesClass: 'zeile', wordsClass: 'wort', charsClass: 'zeichen',
      onSplit(self) {
        gsap.set(el, { visibility: 'visible' });
        return gsap.from(self[m.teile], { yPercent: 110, duration: m.dauer, stagger: m.versatz, ease: 'expo.out', ...extra });
      },
    });
    teilungen.push(t);
    return t;
  }

  function aufbauen() {
    teilungen.forEach(t => t.revert());
    teilungen = [];
    gsap.killTweensOf([titel, text]);
    const m = MODI[modus];
    zeigeWerte(m);
    enthuellen(titel, m);
    enthuellen(text, MODI.zeilen, { delay: 0.25, duration: 0.9 });
  }

  function verbergen() {
    // Abgang nach oben aus derselben Maske, schneller und mit In-Kurve
    const teile = titel.querySelectorAll('.zeile, .wort, .zeichen');
    return gsap.to([...teile].filter(e => !e.querySelector('.wort, .zeichen')), { yPercent: -110, duration: 0.55, ease: 'expo.in', stagger: MODI[modus].versatz / 2 });
  }

  document.querySelector('.modi').addEventListener('click', e => {
    const b = e.target.closest('[data-modus]');
    if (!b) return;
    pause();
    setzeModus(b.dataset.modus);
    aufbauen();
  });
  function setzeModus(neu) {
    modus = neu;
    document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));
  }

  // Der Satz weiter unten: Zeilen beim Eintritt in den Viewport, einmal.
  function satzVorbereiten() {
    enthuellen(satz, MODI.zeilen, { duration: 1.1, scrollTrigger: { trigger: satz, start: 'clamp(top 80%)', once: true } });
  }

  // Galerie und Knopf „Vorführen“: alle drei Stufen im Wechsel
  let uhr = null;
  const reihe = ['zeilen', 'woerter', 'zeichen'];
  function vorfuehren() {
    pause();
    setzeModus(reihe[0]);
    aufbauen();
    let i = 0;
    const takt = () => {
      uhr = setTimeout(() => {
        if (reduziert()) { aufbauen(); takt(); return; }
        verbergen().then(() => {
          i = (i + 1) % reihe.length;
          setzeModus(reihe[i]);
          aufbauen();
          takt();
        });
      }, 2600);
    };
    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();
    satzVorbereiten();
  });

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