A vintage Underwood typewriter on a wooden desk, in black and white

Build Note

Animating hero text without layout shift

Stack the words in one grid cell, animate transform only, and make the first word settled at paint. A rotating headline with zero CLS and zero JavaScript.

Published August 7, 2026

Stack every word in a single CSS grid cell so the container is sized by the longest one from first paint, animate transform only, and make the first word settled at 0% so nothing moves while the page is being measured. That gives a rotating headline with zero layout shift, zero JavaScript, and no cost to LCP.

Why rotating headlines usually wreck the metrics

Because the three obvious implementations each break something different. Swapping text nodes with JavaScript changes the line width after paint, which is layout shift. Fading between absolutely positioned words takes them out of flow, so the parent collapses and the rest of the page moves. And any of it driven by an animation library puts script in the critical path, delaying the very paint the headline is supposed to be.

The homepage headline here rotates through four verbs and costs nothing on any of those axes: Cumulative Layout Shift stays at 0, First Load JS is unchanged because there is no JavaScript involved, and the largest text element is painted server-rendered and static. The whole thing is one grid trick and two keyframes.

Stack the words in one grid cell

Every word occupies the same cell, so the container is laid out once — at the width of the longest word — before anything animates. Nothing it contains can change that width later, which is what makes the shift score structurally zero rather than incidentally zero.

app/globals.css
.hero-verb {
  /* inline-grid so following text ("like") shares the verb's line */
  display: inline-grid;
  clip-path: inset(-0.3em 0);
}

.hero-verb-word {
  grid-area: 1 / 1;
  /* Right-align every word so its trailing edge — and the gap to the
     text that follows — stays constant across all four. */
  justify-self: end;
  transform: translateY(-150%);
  animation: hero-verb-cycle 12s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}

The clip-path is the mask the words slide behind. It insets by -0.3em vertically rather than 0 because line-height is tighter than the font's ascent plus descent — at a flat inset, descenders get shaved during the hold, which looks like a rendering bug rather than a design.

Make the first word static at paint

The word visible when the page loads must be at its settled position in the animation's 0% frame. If it slides in, the largest text element is moving during the window where LCP is determined, and you have made your headline arrive late for a decorative reason.

Keyframes — the wheel and the one-shot intro
/* Steady state: 3s per word inside a 12s wheel. */
@keyframes hero-verb-cycle {
  0%            { transform: translateY(-150%); }
  4.5%, 20.25%  { transform: translateY(0); }
  24.75%, 100%  { transform: translateY(150%); }
}

/* Word 1's first showing only: settled at 0% — nothing moves at paint. */
@keyframes hero-verb-intro {
  0%, 90.13% { transform: translateY(0); }
  100%       { transform: translateY(150%); }
}

Word one runs two animations: the one-shot intro that holds it in place, and the shared wheel starting later so it rejoins its normal slot. Words two to four just start the unchanged wheel at three-second offsets — 5.5s, 8.5s, 11.5s — so the rhythm after the intro is identical to a plain loop.

Expose one word, hide the rest

Assistive technology should read one grammatical sentence, not four verbs stacked in a heading. Mark the decorative words aria-hidden and leave exactly one in the accessibility tree — and make sure that same word is the one reduced-motion users see.

components/sections/Hero.tsx
<span className="hero-verb text-tk-teal-on-onyx">
  <span className="hero-verb-word" aria-hidden="true">Build</span>
  <span className="hero-verb-word">Ship</span>
  <span className="hero-verb-word" aria-hidden="true">Iterate</span>
  <span className="hero-verb-word" aria-hidden="true">Scale</span>
</span>{" "}
like you
The reduced-motion fallback
@media (prefers-reduced-motion: reduce) {
  /* nth-child(1) listed explicitly: word 1's intro+wheel shorthand is a
     (0,2,1) selector, so the bare class alone no longer out-specifies it. */
  .hero-verb-word,
  .hero-verb-word:nth-child(1) {
    animation: none;
    transform: translateY(-150%); /* park the visual-only words off-clip */
  }
  .hero-verb-word:nth-child(2) {
    transform: none; /* show the accessible one */
  }
}

That explicit nth-child(1) in the media query is not defensive noise. Because word one's animation is set through a more specific selector, the bare class no longer wins the cascade — leave it out and the first word keeps animating for exactly the users who asked it not to.

The rules this ends up encoding

Animate transform only — never opacity on text, which fails contrast audits in a way that takes a while to diagnose. Size the container from the longest state before anything moves. Make the initial frame the settled frame. Keep the first change out of the measurement window. And expose one accessible copy of the content, which is also the reduced-motion fallback.

Those five rules cover most above-the-fold motion, not just word wheels. They are the same discipline behind fixing Core Web Vitals in general: decide what the browser is allowed to do after paint, and then do not do anything else.

This is the kind of detail that separates a fast site from a site that merely looked fast in review — and it is the standard behind the front-end work I take on.

Questions

Why not use a typewriter effect instead?

Because it types character by character, which means the line width changes on every frame. Either the container is sized for the longest string — leaving a visible gap — or the text reflows, which is layout shift by definition. A word wheel changes the content in one step, inside a box that was already the right size.

Does the animation hurt the accessibility score?

Not if only one word is exposed. The three decorative words carry aria-hidden, so assistive technology reads a single, grammatical sentence rather than four verbs in a row. Under prefers-reduced-motion the animation is dropped entirely and that same accessible word is the one left visible.

Would a JavaScript animation library be easier?

Easier to write, worse to ship. A library in the critical path adds parse and execute time before the hero can settle, and it does the animation on the main thread rather than the compositor. The CSS version costs no JavaScript at all, which is why it's the only kind of motion allowed above the fold on this site.

How do you keep the surrounding text from moving?

Right-align the words inside the grid cell. The container is as wide as the longest word, so left-aligning would move the trailing edge — and the text after it — every time a shorter word appeared. Aligning to the end keeps the gap to the following word identical across all four.

Written by

Karol

Senior engineer and systems architect behind Tall Karol. Everything published here is grounded in real client work — no roundups, no tools that haven't run in production.

Why Tall KarolWork with Tall Karol

Related notes

More on web development

Related service: Web Development

Want this kind of engineering on your project?

Tall Karol takes on fractional and project-based engagements for startups and agencies.

Book a working session