SolutionsPackagesComponentsCLI — Coming soon ResourcesDocumentationStorybookChangelogBlogAbout Sign up free Get early access
Case study

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/motion runs 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.

No entrance (baseline) Static hero ~50ms Reference JS sequence on load Hero painted, vanished when the component loaded, then animated in ("renders twice") ~50ms Rejected: visible flash JS sequence, content hidden until load, 2s CSS reveal as a fallback No flash, but text appeared only once scripts arrived ~1,750ms Rejected: LCP regression CSS entrance from first paint, heading fades Smooth from first paint ~1,400ms Rejected: LCP moved to the component's repaint CSS entrance, heading and description slide without fading Smooth from first paint, every width ~40ms Worked, but needed the keyframe-restart workaround (see Why it broke) Shipped: JS only, hero eyebrow letters + FeatureTabs on scroll Static hero except the eyebrow; FeatureTabs animates as it comes into view ~40–48ms Shipped

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.

Server-rendered text paints before any script runs An entrance that starts on load hides text the reader already saw, then animates it back Hide only what will animate, before first paint, or don't animate it LCP ignores an element while it is at opacity 0, and records it again when it is next repainted Fading the heading in moved LCP from ~50ms to the moment the component loaded and repainted it (~1.4s) Never fade the largest text; a slide at full opacity is fine Loading a web component re-slots its server-rendered children, which restarts their CSS animations A CSS entrance replayed from the start when scripts arrived Restart under a new keyframes name with delays offset by the time already run (we shipped JS instead) An in-view trigger fires only after the frame where the element became visible Jumping straight to FeatureTabs (the nav's in-page link) showed it fully for one frame, then hid it Once loaded, hide off-screen parts from script so they are already hidden when they come into view

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.

  1. The first visibility report decides. Already on screen when it loads: it stays static.
  2. Off screen: it holds its heading, description and tabs hidden from script. Nothing is hidden by CSS.
  3. 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 for sequence(). Words stay unbreakable, characters are split by grapheme, screen readers read the text once, and revert() restores the original nodes.
  • A y option on every call sets vertical travel in pixels, replacing the preset's own movement. fade with y: 4 gives the small rise both components use.

Rules for future motion work

These now live in the motion docs under "Where to use motion".

  1. Below the fold, use JavaScript motion freely, started by an in-view trigger rather than on load.
  2. Above the fold, animate only minor elements such as an eyebrow or badge. Never the heading, the description, or a hero image.
  3. Never fade the largest text. If something big must move, slide it at full opacity.
  4. A section already visible when scripts load stays static. Hiding what the reader can see always reads as a glitch.
  5. 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.
  6. Respect reduced motion by not hiding anything, not just by shortening the animation.
  7. 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

© 2026 LightLeap Labs LLC. All rights reserved. · Terms of Service · Privacy Policy · Cookies · Contact · Support