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.