Writing

An ease chasing an ease

Half a color change kept lagging behind the other half, and neither rule was wrong. There were two fades running where I thought there was one.

5 min read

  • CSS
  • @property
  • Animation

Two lines of text, the same color, inside the same transition. One of them kept arriving late.

They live in the facts rail on the case-study pages: small monospace labels with their values underneath. This site has a second edition it can morph into, an inverted palette, and the whole page crosses over about six tenths of a second. During that crossover the labels changed color on time and the values showed up a beat behind. Not subtly. Late enough that the rail looked like somebody was repainting it by hand, one row at a time.

I stared at it longer than I want to admit, because nothing was wrong with either of them.

What’s actually happening

The original code did the obvious thing and put a transition on the elements whose color changes:

.facts dt,
.facts dd {
  transition: color 0.6s ease;
}

That rule says: when this text changes color, don’t snap to the new one, slide there over six tenths of a second. CSS calls that slide an ease, and the whole theme switch is built out of them.

The labels named their own color. The values didn’t: they had no color of their own, so they took whatever their parent was using. That distinction is invisible right up until it’s the only thing left to explain.

That’s the whole bug.

An element that names its color gets one ease. The theme flips, the color it was pointed at is now a different color, and the element slides from the old one to the new one over 600ms. Done.

An element that inherits its color gets something much worse. Its parent is mid-animation, so the value it inherits changes on every single frame, and because this element has also been told to transition color, every one of those frames kicks off a fresh 600ms ease toward a target that’s already moved again by the time the next frame lands. You’ve built a low-pass filter and pointed it at a signal that was already smooth. It doesn’t exactly double the duration, but it stretches and delays it enough that next to something animating directly, you can’t miss it.

An ease chasing an ease.

What took me longest was accepting that both elements were behaving correctly. Neither rule was wrong. They were two separate animations that happened to look like one, and I spent a while hunting for the broken one.

The three fixes I shipped first

One afternoon, in this order.

The first was hand-sorting. I wrote out the elements that should ease: html, body, header, footer, section, article, div, the headings, p, a, span, code, and a couple of classes, and gave every one of them a 600ms color transition. That works, and it’s unmaintainable: each themed element has to be sorted into “declares its color” and “inherits it”, then re-sorted the moment anyone touches a selector. Nobody’s going to do that, and there’s no way to notice when they didn’t. My list was already missing nav, ul, li, dl, dt, dd, figure, and the dots on the preview windows, all of which snapped while everything around them eased.

The second was !important, and it’s the part I’d most like back. My rule was firing on page load (every font on the site faded in on a refresh, because nothing in it said “only while the theme is changing”), so I scoped it to a class JavaScript adds for 650ms, and put !important on the transition to be sure the scoped copy won. That took two minutes to write and three hours to notice, because !important also outranked the prefers-reduced-motion rule that was supposed to switch the whole morph off. Somebody who had asked their operating system for less motion got the full 600ms fade anyway.

The third was giving up on the list and putting the transition on html.actium-theme-transitioning *, which at least stops pretending to be selective. It also makes the original problem universal: every inheriting element in the document now chases its ancestor, and you’re animating properties nobody asked about.

All three try to manage the second animation. The fix is to not have one.

Animate the token, not the element

A theme is a set of named colors (--fg for foreground text, --bg for the background), and every rule in the stylesheet points at one of those names instead of writing a color directly. Change the name’s value and the whole page follows.

The catch is that CSS doesn’t know those names hold colors. To it, --fg: #ebeae6 is just a piece of text that happens to look like one, so changing it swaps the text instantly and everything pointing at it jumps.

@property is what changes that. It lets you tell the browser what kind of value a name holds, and once the browser knows --fg is a color, it can move through the colors in between:

@property --fg {
  syntax: '<color>';
  inherits: true;
  initial-value: #ebeae6;
}

Now --fg is a color, and a color can animate. So the ease moves off the elements entirely and goes onto the names instead:

html.actium-theme-transitioning {
  transition:
    --bg 0.6s cubic-bezier(0.16, 1, 0.3, 1),
    --fg 0.6s cubic-bezier(0.16, 1, 0.3, 1),
    --muted 0.6s cubic-bezier(0.16, 1, 0.3, 1),
    --line 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}

That’s one moving value for the whole page. Everything pointing at those names follows along. Text that names its color, text that inherits one, borders, gradients, blended colors: not because each was told to animate, but because there’s only one thing animating and they all read from it. Nothing can fall out of step when there’s nothing to fall out of step with.

Ten values are registered: nine colors and one percentage for the hero glow. The 650ms class is the one thing I kept from the three failed attempts: it’s still what switches the transition on, so the transition still only exists while the theme is actually changing.

What fell out

The rewrite deleted more than it added. A pile of per-component overrides (inverted rules for link colors, a button, text selection, a hero gradient, a preview border) turned out to be longhand for a color name changing, written that way back when the names themselves couldn’t animate. Once they could, every one was redundant, and every one had been a place where the light theme could quietly drift out of step with the dark one.

There’s a rule in the stylesheet now: anything themed that isn’t one of those names is a smell.

The degradation came out better than I expected, too. A browser that doesn’t support @property ignores the whole thing, never learns the names are colors, and flips the theme instantly. No fallback branch, no feature query, and the unsupported path is just the instant version of the same change. And because the whole block sits inside @media (prefers-reduced-motion: no-preference), that’s exactly what somebody who asked for less motion gets anyway.

The edition itself is still where it always was: reachable, never advertised. If you’ve read this far you’ve earned the hint: it’s the sequence every side-scroller taught you, and entering it again puts everything back.

All writing