Get in touchcorey@spiritdevs.com

Principal Engineer, Web and Mobile Platform Architecture · Corporate Interactive · Sydney, Australia

Theme
github.com/coreybainSnapshot 09 OCT 2026 · 00:08 UTC
Direct line

Start a conversation.

Send a note to corey@spiritdevs.com. Add a brief, job specification, or other context if it helps.

AttachmentsUp to 3 files · 4 MB combined

Submitting stores the message and nothing else — no queue in front of it, no autoresponder, no list to be added to.

04 / 06Writing

Why My Fade‑In Starts at 0.01

Tue 29 Sep 20264 min readCorey Baines

A one-line entrance animation was hiding my page's biggest heading from Chrome's loading metric. The fix was a hundredth of opacity.

  • Web performance
  • CSS
  • Lighthouse
A headline rising into view through faint layers, with 0.01 marking the first layer Chrome can see.

On this page

  1. Some runs had no LCP at all
  2. Every heading on the site fades in
  3. Chrome doesn't count what it can't see
  4. The fix is one value
  5. Before and after
  6. What it doesn't fix
  7. If your hero section fades in

Share

Some runs had no LCP at all#

This site runs Lighthouse on every push. It checks the public routes three times each on the desktop preset and fails the build if Largest Contentful Paint (LCP) goes over budget.

For a while, that check was unreliable. Eight of 24 runs reported no LCP value at all, and which runs failed changed from one invocation to the next. On the last measurement before the fix, the homepage produced no LCP value in any of its three runs.

When a value did come back, it sometimes named the wrong element. Instead of the page heading, Lighthouse picked a small metadata <span> near the top of the page.

Every heading on the site fades in#

The top of each page arrives with a short, staggered rise. The heading, the intro and the stats each start 12 pixels low and fully transparent, then settle into place one after another.

.hor .hor-rise {
  animation: hor-rise 0.85s var(--hor-ease) both;
  animation-delay: var(--hor-delay, 0ms);
}

@keyframes hor-rise {
  from { opacity: 0; transform: translate3d(0, 12px, 0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); }
}

animation-fill-mode: both matters here. It holds each element at its from state while it waits for its delay. On the homepage, the last element in the stagger waits 650 ms.

An animated page header whose eyebrow, heading, intro and stats rise into place one after another.

Chrome doesn't count what it can't see#

Chrome ignores elements with an opacity of 0 when choosing the LCP element. For content that stays hidden, that's the right call.

Combined with the animation, it meant the heading wasn't an LCP candidate when it first painted. Its first eligible moment came roughly its delay plus the fade after the page first painted.

Everything else on a local load settled in under 400 ms. That moment landed close to the point where Lighthouse decides the page has finished loading. Whether a run reported an LCP value depended on which happened first.

When the heading lost, a small element that didn't animate could win instead. That's how a metadata <span> ended up named as the largest thing on the page.

An animated timeline comparing two page loads. Starting at opacity 0, the heading becomes eligible only after its delay, and the trace can end first. Starting at 0.01, the heading is eligible from its first paint.

The fix is one value#

@keyframes hor-rise {
  from { opacity: 0.01; transform: translate3d(0, 12px, 0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0); }
}

At 1% opacity, while it's also moving 12 pixels, the heading looks the same as it did at zero. But it's no longer invisible to Chrome, so it's an LCP candidate from its first paint.

Two copies of the same animated header side by side, one starting at opacity 0 and one at 0.01. They look the same, but only the 0.01 copy is marked as an LCP candidate from the start.

Before and after#

I ran the same checks straight after the change, three desktop runs per route.

Before After
Runs with an LCP value 16 of 24 24 of 24
Homepage 0 of 3 3 of 3, median 0.83 s
Element Lighthouse named Sometimes a metadata <span> The heading, portrait or intro, every time

Total Blocking Time had been failing on the same routes. Lighthouse needs the LCP timestamp to calculate it, so the same fix repaired it. Both checks now fail the build, where before they only produced warnings.

What it doesn't fix#

The 0.01 start makes the metric honest about which element matters and consistent from run to run. It doesn't make the page faster.

A reader still waits for the stagger to finish before the header is fully visible. If that wait feels too long, the fix is a shorter delay, not a different opacity. That's a design decision, and I've left the stagger as it is for now.

The change also exposed a real problem. With LCP measurable on Lighthouse's mobile preset, the homepage came in at 3.8 and 4.2 seconds across two runs, and the element holding it back is the large portrait image. That's a separate problem, but I couldn't see it until LCP could be measured.

If your hero section fades in#

Open Chrome DevTools, record a page load in the Performance panel, and check which element it marks as LCP. If it isn't the element you expect, look at how that element's animation starts.

Starting above-the-fold content at a very low opacity, rather than zero, is a cheap fix. So is leaving the entrance animation off the largest element altogether.

Whichever you choose, leave a comment next to it. Mine says not to tidy the 0.01 back to 0.

02Keep readingAll writing
An annotated screenshot, a recording and diagnostic logs collected into one support report.
Older post

Bug Reports Should Bring Context

Fri 25 Sep 2026

“Agents that don't wait.” beside a dot's activity feed. Two background tasks are marked read-only, and a third, sending an invoice, waits on an Approve button.
Newer post

DevDay 2026: Agents That Don't Wait

Wed 30 Sep 2026