Writing

Half the text lagged during a theme switch

When the site switched themes, labels changed color on time and their values arrived late. Two fades were running where I thought there was one.

5 min read

  • CSS
  • @property
  • Animation

Two lines of text changed color in the same theme switch. One of them kept arriving late.

They sit in the facts rail on the case-study pages, as small monospace labels with their values underneath. This site also has a second edition, a light one with an inverted palette. Switching between them changes the whole page over about six tenths of a second.

During that switch the labels changed color on time, and the values showed up a beat behind. They were late enough that the rail looked like somebody was repainting it by hand, one row at a time.

Why the values lagged

The original code did the obvious thing. It 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, fade there over six tenths of a second. CSS calls that fade a transition, and the ease at the end of the rule names its speed curve. The theme switch is built out of these fades.

The labels named their own color, and the values had none, so they took whatever their parent was using. That difference caused the bug.

An element that names its color fades once. When the theme switches, the color it names changes, and the element fades from the old color to the new one over 600ms.

An element that inherits its color gets something much worse. Its parent is still fading, so the value it inherits changes each frame. This element also has a transition on color. So each of those frames starts a fresh 600ms fade toward a target that’s moved again by the next frame.

You’ve built a low-pass filter and pointed it at a signal that was already smooth. The result isn’t a clean doubling of the duration, but it’s stretched and delayed enough to stand out next to text that fades directly. It’s a fade chasing a fade.

Accepting that both elements were behaving correctly took me longest. The values were running two fades that happened to look like one.

The three fixes I shipped first

I shipped all three in one afternoon, in this order.

The first fix was hand-sorting. I wrote out the elements that should fade. The list ran html, body, header, footer, section, article, div, the headings, p, a, span and code, plus four classes. Each one got a 600ms color transition.

That works, and it’s unmaintainable. Someone has to sort each themed element into “names its color” or “inherits it.” They have to sort it again the moment anyone touches a selector. People won’t keep that up, and there’s no way to notice when they stop. My list was already missing nav, ul, li, dl, dt, dd, figure and the dots on the preview windows. All of them snapped while the rest of the page faded.

The second fix was !important, and it’s the part I’d most like back. My rule was firing on page load. The site’s fonts faded in on each refresh, because the rule didn’t say “only while the theme is changing.” So I scoped it to a class that JavaScript adds for 650ms. Then I put !important on the transition to be sure the scoped copy won.

I wrote it in two minutes. Three hours passed before I noticed that !important also outranked the prefers-reduced-motion rule. That rule was supposed to switch the fades off. Somebody who had asked their operating system for less motion got the full 600ms fade anyway.

The third fix gave up on the list. It put the transition on html.actium-theme-transitioning *, which at least stops pretending to be selective. It also makes the original problem universal, because each inheriting element in the document now chases its ancestor. And it animates background colors, borders and shadows that didn’t need it.

All three fixes try to manage the second fade instead of removing it.

Animating the color names

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

But CSS doesn’t know those names hold colors. To CSS, --fg: #ebeae6 is a piece of text that happens to look like a color. Changing it swaps the text instantly, and whatever points at it jumps.

@property changes that. It lets you tell the browser what kind of value a name holds. 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 fade moves off the elements and onto the names:

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 fade per color, for the whole page. Anything that points at those names follows along:

  • text that names its color
  • text that inherits one
  • borders
  • gradients
  • blended colors

None of them was told to animate. They all read from those same fades, so they can’t fall out of step.

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

What else changed

The rewrite deleted more than it added. It removed a pile of per-component overrides, each an inverted rule for one of these:

  • link colors
  • a button
  • text selection
  • a hero gradient
  • a preview border

Each override did by hand what a changing color name now does. I wrote them that way back when the names couldn’t animate. Once the names could, the overrides were redundant. Each had also been a place where the light edition could drift out of step with the dark one.

A comment in the stylesheet now warns that any themed value that isn’t one of those names won’t fade with the rest.

The unsupported case came out better than I expected, too. A browser that doesn’t support @property ignores all of it. It doesn’t learn that the names are colors, so it switches the theme instantly. There’s no fallback branch and no feature query. It’s the instant version of the same change.

The transition rule also sits inside @media (prefers-reduced-motion: no-preference). So somebody who asked for less motion gets that same instant switch.

To see the light edition, enter the sequence every side-scroller taught you. Entering it again brings the dark edition back.

All writing