vergnyx.dev
  • work
  • writing
  • experiments
  • terminal
  • /about
  • /now
  • /uses
contact me
vergnyx.dev
vergnyx.dev
workwritingexperimentsterminal/about/now/usescontact me
writing/gsap-autoalpha-lcp-clock.md

autoAlpha: 0 Hides the Element. Not the LCP Clock.

Traced a 2-second LCP render delay to GSAP hiding my hero subheading before animating it in. The fix was simpler than I wanted it to be.

engineering·6 min read·1,229 words·2026·06·07gsap-autoalpha-lcp-clock.md

The Lighthouse number was 2098ms.

That's Largest Contentful Paint — the timestamp when the browser finishes rendering the largest visible element in the viewport. The threshold for "good" is 2.5 seconds. I was inside it. Technically fine.

But the breakdown was strange: TTFB was 59ms, and the remaining 2039ms was labeled "render delay." Ninety-seven percent of the LCP time was just the client waiting. Not for the network. For something on the page.

I had a pretty good guess what.

The Boot Sequence

My portfolio homepage opens with a boot sequence animation. A fake terminal prompt types itself out. Lines of output fade in. The headline words rise up one by one. Then a subheading slides up below them. Then two CTAs. The whole thing takes about two seconds and is meant to feel like the site waking up.

It's built on GSAP. Before any element animates, GSAP hides it — to prevent a flash where the browser paints content in its final position before JavaScript repositions it for the entrance. The pattern looks like this:

typescript
gsap.set(sub, { autoAlpha: 0, y: 12 });

autoAlpha is GSAP's compound property that sets both opacity and visibility in one call. autoAlpha: 0 means invisible and hidden from layout. This runs synchronously inside useEffect at component mount, before the animation timeline starts.

The subheading — the descriptive line below the main headline — was getting this treatment. Hidden at mount, revealed partway through the sequence: after the prompt typed, after the lines faded in, after the headline words rose into place.

The subheading was also the LCP element.

What the Browser Sees

LCP looks for the largest text block or image that's visible in the viewport. Invisible elements don't count. autoAlpha: 0 sets both opacity and visibility to hide, which is exactly what the LCP algorithm skips.

So when the component mounted and GSAP ran gsap.set(sub, { autoAlpha: 0, y: 12 }), the browser quietly took the subheading off its candidate list.

LCP wouldn't fire until the subheading became visible. That happened when the animation timeline reached it.

The animation took about two seconds.

LCP = the length of the animation.

The profiler made this worse-looking. It flagged 171ms of forced synchronous layout reflows during page load — about 124ms of which came from GSAP measuring element positions and geometry while building its animation plan. Those reflows happen before the timeline even starts, stacking on top of the animation duration rather than overlapping with it.

What I Tried First

Once I understood the problem, I didn't want to just speed up the animation. That felt too blunt. I wanted to fix it properly.

Attempt 1: CSS pre-offset. Remove the subheading from GSAP's visibility guard. Apply the starting transform (translateY(12px)) in CSS so the element is visible at first paint, positioned where the animation begins. LCP fires early. GSAP inherits the transform and animates from there. The logic was sound. The execution was messy — the handoff between CSS-applied and GSAP-applied transforms was glitchy enough in practice that I reverted it.

Attempt 2: Defer the geometry refresh. ScrollTrigger recalculates every trigger's geometry when fonts load. That's a forced reflow that can block the main thread for 100ms or more. I moved it into requestIdleCallback with a 500ms timeout, hoping to push it past the critical rendering window. Reverted — the 500ms delay was landing directly in the FCP-to-TTI window under CPU throttle, raising TBT instead of lowering it.

Attempt 3: Debounce the resize callback. The smooth-scroll library I use watches for body-height changes via ResizeObserver. During initial load — fonts settling, lazy images landing — those changes fire rapidly. I wrapped the callback in requestAnimationFrame to coalesce them. Reverted. Simplified the code afterward, made no measurable difference to LCP.

Three attempts, three reverts. Each targeted a specific bottleneck. Each introduced a different failure mode.

The Blunt Fix

Tighten the animation.

Prompt reveal: 0.4 seconds → 0.25 seconds. Line stagger interval: 0.18 seconds → 0.10 seconds. Headline word animation: 0.65 seconds → 0.45 seconds, stagger 0.06s → 0.04s.

Across the sequence, about 35% faster. The subheading reveals earlier. LCP fires earlier.

The DevTools performance trace afterward: LCP 1390ms. Lighthouse on production without throttle: 2.1 seconds, score 88. Mobile Lighthouse with its default throttling: 1.6 seconds, score 97.

Everything I tried to fix the "real" problem made things equal or worse. The thing I dismissed as too blunt was the thing that worked. At some point I need to stop being surprised by this pattern.

The Throttle Problem

Here's where it gets complicated.

Lighthouse with 4x CPU throttling — the setting simulating slower devices — gave me: LCP 11.1 seconds, score 57.

Same page. Same animation, now 35% faster.

When JavaScript runs slower, GSAP's initialization takes longer. The layout reflows take longer. The animation itself runs longer because it's driven by a JavaScript clock that slows with the CPU. And none of that changes the fundamental problem: the subheading is hidden until the animation reveals it.

On fast hardware, "until the animation reveals it" is fast. On slow hardware, it isn't.

The animation duration is a floor for LCP, not a ceiling. On a fast machine, you can get the floor low enough to matter. On a slow machine, you can't get there by trimming percentages — the floor itself is too high.

Mobile Lighthouse (which uses a lighter throttling profile than 4x desktop) gave 1.6s and a 97 score. Real phones on real networks are fast. The 4x desktop throttle is aggressive — it simulates something like a budget phone struggling. But it's not imaginary.

The Trade-off I Made

The real fix is to not hide the LCP element at all. Paint it at first paint, visible, in its resting position. LCP fires before GSAP touches anything. Animate other elements around it.

I tried that. It got messy — the handoff issues, the visual continuity of the sequence. And it meant rethinking the subheading entrance, which is part of what gives the boot sequence its cadence.

So I made a judgment call: keep the animation, accept the throttled-Lighthouse penalty. My portfolio audience is mostly developers on modern machines. The 4x throttle scenario is real but not the primary case here. For a product with a broader user base, that calculation would look different.

What I can't do is pretend the trade-off isn't there.

What the Pattern Is

The lesson generalizes beyond GSAP. Any JavaScript animation that starts an element invisible — opacity: 0, visibility: hidden, any form of "hide first, reveal with the animation" — makes that element invisible to the LCP measurement until the reveal fires.

If the element the browser would otherwise pick as LCP starts invisible, LCP is gated by the reveal. On fast hardware, the reveal is quick. On slow hardware, the reveal is slow. The gap between those two scenarios is larger than most throttle-free Lighthouse scores would suggest.

Setting autoAlpha: 0 on hero content before the intro animation is standard GSAP practice. It's also telling the browser: this element doesn't exist yet. The LCP clock doesn't care — it's just not counting that element until it reappears.

If your LCP score looks fine without throttling and collapses under CPU throttle, look at what's hiding your LCP element. It's probably JavaScript.

The browser can paint what it can see.

If nothing's visible yet, the clock is still running.

Similar reads

infra

Google Can't Build Compute Fast Enough. So It Rents.

Google committed $180B in capex this year and still had to rent xAI's GPUs for Gemini. The AI compute crunch is real, even for the cloud.

4 min · 974 words
$ exit 0 · end of filegsap-autoalpha-lcp-clock.md · /writing© Vaibhav Verma · 2026