A printer's colour swatch book open on a dark table beside a small loupe

Build Note

Never tween opacity on animated text

Contrast audits compute the ratio from whatever opacity they sample, so a crossfade fails colour-contrast at random. Animate transform and the failure disappears.

Published August 6, 2026

Contrast audits compute a ratio from whatever opacity the text has at the instant they sample it. Animate opacity and a crossfade can be caught half-faded, failing colour-contrast on one run and passing on the next. Animate transform instead, keep text at full opacity in every frame, and the failure disappears.

The symptom: a contrast failure that won't reproduce

You run Lighthouse, the accessibility category drops, and the culprit is a heading that is plainly readable. You run it again and it passes. You run it a third time and it fails with a different ratio — a number that appears nowhere in your stylesheet and does not correspond to any colour you chose.

That last detail is the giveaway. The audit is not reading your CSS; it computes the effective colour of the text as rendered, composited against what is behind it. If the element is mid-fade, the effective colour is a blend, and the ratio is real — for that frame. Which frame gets sampled is a race between the animation clock and the audit, which is why the result changes run to run.

How the two animation approaches behave under a contrast audit
Crossfade (opacity)Slide (transform)
Text opacity mid-animationAnywhere between 0 and 1Always 1
Computed contrastDepends when it's sampledConstant
Audit resultPasses or fails run to runDeterministic
Compositor-onlyYesYes
Hiding the exitThe fade is the exitNeeds a clip-path or overflow

Why the ratio the tool reports is genuinely correct

It is tempting to file this as a tooling bug. It is not. WCAG contrast is a property of what a person can see, and a person looking at your heading at 40% opacity is looking at low-contrast text. The audit is describing the frame it saw accurately; the problem is that your design has frames nobody intended to be read, and an automated check has no way to know that.

Which means the fix is not to suppress the check. It is to stop producing frames where the text is unreadable — and the way to do that is to move the text rather than dissolve it.

The fix: slide behind a mask

Keep the text at full opacity in every frame and animate transform instead, with a clip on the container so the words disappear by leaving the box rather than by fading. Both properties are compositor-only, so nothing is lost on performance.

app/globals.css
/* Slide-only, NO opacity tween: axe/Lighthouse compute contrast from
   tweened opacity, so a crossfade can be snapshotted mid-fade and fail
   colour-contrast. With transform-only motion every word stays at full
   opacity. */
.hero-verb {
  display: inline-grid;
  clip-path: inset(-0.3em 0);
}

.hero-verb-word {
  grid-area: 1 / 1;
  transform: translateY(-150%);
  animation: hero-verb-cycle 12s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}

@keyframes hero-verb-cycle {
  0%            { transform: translateY(-150%); }
  4.5%, 20.25%  { transform: translateY(0); }
  24.75%, 100%  { transform: translateY(150%); }
}

The mechanics of the rest of that rotator — grid stacking for zero layout shift, and holding the first change out of the measurement window — are their own build note.

The same trap, one step sideways: translucent text

Animation is the dramatic version of a problem that also exists statically. Any text set with an alpha — the usual text-white/70 pattern — is composited against its background before the ratio is computed, and the number you get is not the one your colour token implies. It has to be checked against the actual surface the text sits on.

On this site that produced a rule that looks arbitrary and is not: linen text on a teal surface must be at least 90% opacity, because 80% composites to 4.07:1 and fails AA. The same logic gives the brand teal two values — #006965 for light backgrounds, a lighter #008D89 for text on the near-black onyx, where the darker token is only 2.8:1.

tailwind.config.ts
"tk-teal": "#006965",
// Teal text on tk-onyx only — #006965 is 2.8:1 on onyx (fails AA).
"tk-teal-on-onyx": "#008D89",
"tk-linen": "#F1EADC",
"tk-slate": "#1F2C2B",
"tk-onyx": "#0F1615",

Hover states deserve the same suspicion. Dimming a link to 80% on hover is a contrast regression that only exists while the pointer is over it — invisible in review, and perfectly visible to an audit that happens to catch it. Use an underline or a darker colour instead of an opacity fade.

What to take from this

Treat opacity on text as a contrast decision rather than a visual one. Animate transform, not alpha. Compute the ratio of translucent text against the surface it actually sits on, not against the token you named. And when an audit result changes without a code change, suspect the clock before you suspect the tool.

That last habit is worth more than the specific fix. Nondeterministic audit results are almost always something animating, and hunting them is the least enjoyable part of holding a page to a performance and accessibility bar.

Nondeterministic audit failures are expensive precisely because they look like tooling noise. Hunting them is routine on performance and accessibility work, and rarer when the motion rules are set before the design is built.

Questions

Why does the failure come and go between runs?

Because the audit samples the page at whatever moment it reaches it, and a crossfade only fails while it is mid-fade. Run the test when the text happens to be settled and it passes; run it a few hundred milliseconds earlier and the same element fails. Nothing about the page changed — only when it was looked at.

Can I just raise the text colour's contrast instead?

Only if it clears the threshold at the lowest opacity the tween reaches, which for a fade from zero is impossible — no colour has sufficient contrast at 10% alpha. Raising the colour helps for a fixed translucent element; it cannot rescue an animation that passes through near-transparent.

Does this affect fade-in reveals below the fold too?

In principle yes, in practice much less, because audits usually snapshot around load rather than while you are scrolling. It's still worth arming those reveals only when JavaScript and motion are both available, so the default state of the content is visible — which fixes the audit case and the no-JS case at once.

How do I confirm this is what's happening?

Run the audit several times and watch whether the same node passes and fails without a code change. Then freeze the animation — set animation: none in devtools — and re-run. If the failure disappears and the reported contrast ratio was a value you never specified anywhere, it was computed mid-tween.

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