JavaScript motion above the fold
Animating server-rendered hero content with JavaScript flashes it or delays LCP by up to 1.7s. Five designs, four browser behaviors, and the rules we follow now.
Animating server-rendered content above the fold with JavaScript either flashes it or delays Largest Contentful Paint (LCP), by up to 1.7s in our tests. We now use JavaScript motion only below the fold or on minor elements, and never on the page's largest text, which keeps LCP within noise of an unanimated page.
Context
We set out to add an entrance to our homepage hero: eyebrow, heading, description, buttons, then the features, in sequence, using @ds-gn/motion. Our site's architecture makes that hard in one specific way.
- The HTML is server-rendered. The hero's text is in the page and painted on first paint, before any JavaScript runs.
- Components load later.
<dsgn-hero>and its children are web components. They upgrade only once the page's scripts load, often after the first paint. @ds-gn/motionruns in JavaScript. It animates with the Web Animations API, so it can only start after that upgrade.
So any entrance on visible content starts after the reader has already seen that content.
What we tried
We went through five designs before shipping. Only the last two kept LCP at the unanimated baseline, and only the last kept the hero simple.
Measured in headless Chromium on the built homepage, median of 5 runs, with the page's scripts delayed to 1.2s to mimic a real network. Mobile (iPhone 13) matched the same pattern. With instantly loading scripts, the shipped version measured 220ms against 208ms for the unanimated page over 15 interleaved runs, within noise.
Why it broke
Four browser behaviors caused every failure above. None of them show up locally, where scripts load almost instantly, so we only found them by delaying scripts and measuring.
The largest text is not always the same element: on desktop it was the hero heading, on an iPhone 13 the description. Any rule about "the LCP element" has to cover both.
What we shipped
One switch, motion="on", on Hero and FeatureTabs (default "off", set per section in Sanity). Reduced motion keeps both static.
Hero: only the eyebrow animates. The eyebrow is a small label, never the largest text, so it is safe to hold back.
- Before the hero loads, one CSS rule keeps the eyebrow at opacity 0. It keeps its space, so nothing shifts.
- 500ms after the hero loads, its letters fade in one by one, each rising 4px. The original text is restored afterwards.
- Without JavaScript, a
<noscript>rule in the page head shows it. - Everything else in the hero is static from first paint.
FeatureTabs: animates as it comes into view. It sits below the fold, so nothing it does affects LCP.
- The first visibility report decides. Already on screen when it loads: it stays static.
- Off screen: it holds its heading, description and tabs hidden from script. Nothing is hidden by CSS.
- When it comes into view, by scrolling or by an in-page link, the heading enters letter by letter, then the description fades in, then the tabs.
@ds-gn/motion gained two general tools:
splitText(el)wraps each character in its own element forsequence(). Words stay unbreakable, characters are split by grapheme, screen readers read the text once, andrevert()restores the original nodes.- A
yoption on every call sets vertical travel in pixels, replacing the preset's own movement.fadewithy: 4gives the small rise both components use.
Rules for future motion work
These now live in the motion docs under "Where to use motion".
- Below the fold, use JavaScript motion freely, started by an in-view trigger rather than on load.
- Above the fold, animate only minor elements such as an eyebrow or badge. Never the heading, the description, or a hero image.
- Never fade the largest text. If something big must move, slide it at full opacity.
- A section already visible when scripts load stays static. Hiding what the reader can see always reads as a glitch.
- Hide content from script after load, or with CSS before upgrade. The CSS route needs a
<noscript>reveal and is only acceptable for decoration, such as the hero eyebrow. - Respect reduced motion by not hiding anything, not just by shortening the animation.
- Measure with delayed scripts before shipping. Instant local scripts hide every problem in this study. Compare LCP against the same page without motion, on desktop and mobile.
Tradeoffs and open questions
- A blocked or failed script leaves the hero eyebrow hidden. We dropped the timeout that would reveal it, so slow loads still get the animation. Accepted because the eyebrow is decoration;
<noscript>covers JavaScript being off. - The numbers are lab measurements from one machine with simulated script delays. Field LCP from real visitors would confirm them, especially on mid-range phones.
- One unexplained report: in pre-production testing, a fresh private-window load once showed the eyebrow static; reloads animated, and it did not recur. The likeliest causes are the first-visit consent prompt covering the page, or the window loading before it was on screen.
- Open: should the motion library offer a build-time CSS output, so above-the-fold motion can start at first paint without JavaScript? We built that path (row 5 of the table) and set it aside for simplicity.
Further reading
- @ds-gn/motion: animation timed by your design tokens — what the library does and how to use each part
- Where to use motion — the rules above, in the motion docs
- Letter by letter with splitText — the sequence guide