D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

SplitText Breaking Lines in the Wrong Places? It Split Before the Web Font Finished Loading

· · 6 min read
SplitText Breaking Lines in the Wrong Places? It Split Before the Web Font Finished Loading

The premium WordPress theme I am building carries three independent design directions. The third one ended up on the front page, and its whole first impression rests on typography: one enormous hero title that arrives character by character, plus text blocks that rise line by line. Both are driven by SplitText on top of GSAP 3.13, with every library and font self-hosted.

The motion worked. The line breaks did not. SplitText cut lines in the wrong places, and the console had something to say about it too, its own font-timing warning, the one that appears exactly when splitting runs before the web fonts finish loading.

The symptom

A per-line reveal is only as good as the line division underneath it. If SplitText wraps the first line in the wrong spot, what rises onto the screen is not the line you wrote, it is whatever fragment happened to exist at measurement time. On the hero title, even a small error becomes loud, because the type itself is loud. The token itself reads like this:

.h1{font-family:var(--font-display);font-weight:600;font-size:15rem;line-height:.8;letter-spacing:-.72rem;text-transform:uppercase}

Those numbers leave no room for slack. The negative tracking is 0.72rem per character, and measured against the font size itself that is 0.72 divided by 15, so 4.8 percent of the font-size pulled back on every single letter. Ten letters alone shorten the line by 10 times 0.72rem, which is 7.2rem. The line box is shorter than the font too, 0.8 times 15rem gives 12rem. On desktop the font-size itself is lowered to 13rem, because 15rem overflows at 1440 once the horizontal padding applies. So the moment per-character widths are measured against the wrong font, the point where the line wraps moves with them, and at this size the difference is visible from across the room.

Why it happens

The explanation I wrote into the spec myself, and the one that still makes the most sense to me: SplitText measures text with whatever font is currently rendered. If the split runs before the custom font loads, what it measures is fallback-font line boxes, so the line splits land in the wrong places.

Two things in the CSS make that scenario the default rather than an edge case. First, every face of the custom font is declared with font-display: swap, which is precisely the instruction to paint the text in a fallback first and swap later:

@font-face {
  font-family: 'ThemeSans';
  src: url('./themesans-400.woff2') format('woff2');
  font-weight: 400;
  font-display: swap;
  font-style: normal;
}

Second, the font tokens supply that fallback explicitly. The display token and the body token both put the custom face first, with Arial and Helvetica Neue behind it:

--font-display:'ThemeSans',Arial,'Helvetica Neue',sans-serif;
--font-body:'ThemeSans',Arial,'Helvetica Neue',sans-serif;

Together they guarantee a window early in page load where the text already has measurable shape, but that shape belongs to Arial, not to the face that will actually be used. Different typefaces have different advance widths, so a different number of characters fits on a line. SplitText running inside that window locks in a line division built on measurements that are about to expire.

The fix

The fix fits in one sentence: all SplitText work is gated on document.fonts.ready. What is interesting is that in this codebase the gate takes two different shapes, because the two places need different things.

In the hero the status gets checked first. If the fonts are not done, the title is made visible anyway so nobody waits behind an empty screen, the split is deferred to document.fonts.ready, and the subtitle and eyebrow animate on their normal schedule:

if (!document.fonts || document.fonts.status !== 'loaded') {
  gsap.set(heroTitle, { visibility: 'visible' });
  document.fonts.ready.then(splitHero);
  gsap.from('.hero__sub, .hero__eyebrow', { opacity: 0, duration: 0.6, ease: 'power3.out', delay: 0.6 });
  return;
}
splitHero();

The deferred part splits the title into words and characters with masking at the character level, then lifts each character from yPercent: 100. The hero is not called directly either, it waits for a custom event that marks the page ready to begin:

function splitHero() {
  var split = new SplitText(heroTitle, { type: 'words,chars', charsClass: 'char-inner', mask: 'chars' });
  gsap.set(heroTitle, { visibility: 'visible' });
  gsap.from(split.chars, { yPercent: 100, duration: 1.4, ease: 'power4.inOut', stagger: 0.03 });
}
 
document.addEventListener('theme:start', heroIntro);

The per-line reveal module takes the other shape. Nothing here needs to appear first, so the entire split loop sits inside a single Promise, with a fallback for the case where document.fonts is simply not there. And because splitting text into lines changes element heights, ScrollTrigger.refresh() runs once every split is done, so trigger positions get recomputed on the final layout:

if (!reduce && window.SplitText) {
  (document.fonts ? document.fonts.ready : Promise.resolve()).then(function () {
    gsap.utils.toArray('[data-split-lines]').forEach(function (el) {
      var split = new SplitText(el, { type: 'lines', linesClass: 'line-inner', mask: 'lines' });
      // ... per-line reveal
    });
    ScrollTrigger.refresh();
  });
} else {
  document.querySelectorAll('[data-split-lines]').forEach(function (el) { el.style.visibility = 'visible'; });
}

Deferring the split means there is a moment when the text is not ready to be shown, and that is handled in CSS rather than JS. The initial states that hide the hero title and every line-split element only apply when JavaScript is alive, so if JS never runs, everything stays readable:

/* JS-gated initial states (safe: no-JS keeps everything visible) */
html.js .hero__title .h1{visibility:hidden}
html.js [data-split-lines]{visibility:hidden}

What changed after

The SplitText font-timing warning was eliminated by that fonts.ready gating, and in the same audit round the console was recorded at zero errors. The third direction is described accordingly in its mechanics notes, SplitText char and line reveals, fonts.ready-gated.

One honest note about the verification. I verified that round through DOM probes, one mechanic at a time. The one who caught that this kind of verification only touches the DOM and never touches what is actually visible was the client, and the bugs that came next were found from their screenshots. A clean console is evidence that the warning is gone. It is not evidence that the lines break in the right places.

Lessons