Skip to content
← home

Two hours of saying that's not right

7 min read

Lately I've been doing a few design projects for Neon, so their name sits in the first paragraph of my bio. It seemed like a waste to leave that one plain. Hover it now and it lights up the way a neon sign does: an ugly, uneven stutter for about three quarters of a second, then a steady green glow that dips now and then if you leave your cursor there. Move away and it dies instantly.

I didn't write any of the CSS below. An agent did, over about two hours, while I described what I wanted and told it what was wrong with what came back. I want to walk through that honestly, because "AI wrote it" covers up the part that actually took the time.

Neon

Leave it lit and watch. The idle loop dips it every few seconds.

The finished thing at 2.25rem. Strike it, then leave it lit and wait.

Round one: it looked deliberate

I asked for the Neon link to sit in "a little pill, or a code line looking thing," matching the mono chip next to my name. That's what I got, and it was wrong the moment I looked at it.

A badge announces itself. You see a container and you know something's going to happen before you touch it, which kills the whole appeal: unlit glass should look like nothing at all. That took me about four seconds to judge and would have taken a model a very long time to guess, because I'd asked for the pill in the first place.

"Take it out of the badge, maybe." Second version came back as a plain underlined link with a dim mark. Right.

Round two: the ghost

To fade a glow in, the agent stacked a second copy of the word on top of the first and animated its opacity:

<span className="label">
  Neon
  <span aria-hidden className="glow">Neon</span>
</span>

That renders fine at a glance. I zoomed to 4x and saw two copies of "Neon" a couple of pixels apart.

Absolutely positioned text doesn't share a baseline with the inline text underneath it. top: 0 is the top of a box, not a baseline, and the two disagree once line-height and a translated inline image get involved. At normal size it reads as slightly bold, which is exactly the kind of wrong that ships.

The offered fix was to nudge the offset. I didn't want that, because two copies of a word can always drift at some font size or zoom level or fallback font nobody tested. Get rid of the second copy instead.

What it replaced it with

This is the part I couldn't have written, and it's genuinely good. @property lets you register a custom property with a type, which means the browser can interpolate it:

@property --neon-on {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

0 is dark glass, 1 is fully lit, and everything visible is an expression of that one number: letter color, the glow, the mark's saturation, the underline.

.neon-chip__label {
  text-shadow:
    0 0 0.05em color-mix(in oklab, #fff       calc(var(--neon-on) * 85%),  transparent),
    0 0 0.16em color-mix(in oklab, var(--neon) calc(var(--neon-on) * 100%), transparent),
    0 0 0.38em color-mix(in oklab, var(--neon) calc(var(--neon-on) * 65%),  transparent),
    0 0 0.78em color-mix(in oklab, var(--neon) calc(var(--neon-on) * 38%),  transparent);
}

Four layers, not one, because light doesn't fall off in a single step. The near-white core is what sells it: real neon has a blown-out white center where the tube is brightest and your eye can't resolve the color. One text-shadow reads as a drop shadow no matter what color you make it.

The mark gets the same treatment through filter, and sits at saturate(0.3) brightness(0.72) when the sign is off. If I could keep one detail it'd be that one. A green icon at rest just looks like a green icon. Desaturate it and it looks like unlit glass, so the moment it saturates you've shown a state change.

The green is #34d59a, pulled out of Neon's own favicon rather than eyeballed. Their SVG ships two fills, one for light mode and one for dark. I took the dark one.

Round three: make it flicker properly

The first strike was a smooth ramp, and a fade gives it away. If the glow comes up evenly you've written a CSS transition and everyone can tell. Gas ionising in a tube isn't smooth. It catches, drops out, catches harder, half-lights, then settles.

@keyframes neon-strike {
  0%   { --neon-on: 0; }
  5%   { --neon-on: 1; }
  7%   { --neon-on: 0.15; }
  11%  { --neon-on: 0.95; }
  15%  { --neon-on: 0.1; }
  18%  { --neon-on: 0.85; }
  24%  { --neon-on: 0.35; }
  29%  { --neon-on: 1; }
  36%  { --neon-on: 0.7; }
  44%  { --neon-on: 1; }
  100% { --neon-on: 1; }
}

Eleven stops across 760ms. The values matter less than the rhythm, and the gaps are uneven on purpose, because evenly spaced dips read as a strobe.

Neonirregular strike
Neonlinear fade
Same glow, same duration, same colors. The only difference is the shape of the ramp.

Then I asked whether it'd be cool if the sign flickered a little when you left your cursor on it, which turned into a second animation that takes over when the strike finishes:

animation:
  neon-strike 760ms var(--ease-out-quart) forwards,
  neon-idle   7.3s 760ms linear infinite;

Both animate --neon-on, and the later one in the list wins while it's active, so the handoff happens with no gap. It's 7.3 seconds long and spends under 3% of that below full brightness: one shallow dip, one double-blink, one small waver. A sign flickering every second reads as broken and drags your eye out of the sentence it's sitting in. The 7.3 isn't a round number so it never syncs with anything else on the page.

There's no exit transition, and I'd defend that harder than anything else here. Cut the current and a real tube is just gone.

Round four: fine at 54px, mush at 20px

I sent a screenshot and said the neon looked broken. The agent measured everything, found --neon-on reaching 1 and the fill computing to near-white, and blew the text up to 54px where it rendered perfectly. Which was the answer, just not the one either of us expected: the effect wasn't broken, it was mis-scaled.

The glow radii were 3/7/15/30px, tuned by eye at display size. Under 20px body text those wide halos flood the counters of "e" and "o" and the word turns to mush. In em the bloom holds its proportion at any size, which is why the values above are 0.05/0.16/0.38/0.78em.

Round five: the one it couldn't see

Then the letters went gray. Green glow, correct underline, gray text inside it.

The agent could not reproduce this. It measured the fill as oklab(0.982), near-white, and told me so twice. In Chromium it was genuinely fine. I kept saying the gray was on top until we stopped measuring and started suspecting.

The fill was the only part derived through color-mix(in oklab, ... calc(var(--neon-on) * 100%), currentcolor). That stacks three newish features: a registered property, calc() inside a color-mix percentage, and oklab interpolation. When a browser can't parse it the declaration is dropped and the element falls back to its inherited color, which is exactly the muted gray of the surrounding sentence. The shadows parsed fine, so you got a green halo wrapped around gray text.

The fix removed the technique rather than patching it. The fill is now a real color animated in the keyframes, interpolating between two variables. Keyframe color interpolation is as old as CSS animations.

0%   { --neon-on: 0;    color: var(--neon-ink-off); }
5%   { --neon-on: 1;    color: var(--neon-core); }
7%   { --neon-on: 0.15; color: var(--neon-ink-off); }

--neon-on still drives the shadows and the mark, since those demonstrably render everywhere. The fragile technique is gone from the one place where failing meant "looks broken" instead of "looks slightly weaker."

What the split actually was

It wrote every line of CSS in this post, and most of it is better than what I'd have written. The @property approach didn't occur to me. Neither did four-layer falloff.

What it couldn't do was tell that any of it was wrong. The badge announcing itself, the arc bowing the wrong way, the underline drawing heavier than it settled, the glow being too big at the only size that ships: each of those was me looking at the screen and saying that's not right, and the agent going off to find out why. Twice the bug was invisible from its side entirely, once because it needed a human to zoom to 4x and once because it needed a browser the agent wasn't running.

Reviewing is the job now. It's less typing and about the same amount of judgment, and the judgment is the part that was ever scarce.

Two more worth writing down

BugCause
The mark orphaned itself at the end of a line, with "Neon" wrapping to the next oneDropping the badge also dropped the wrapper holding them together. white-space: nowrap fixed it, and it does more than look right: the glow assumes a single line box.
Every CSS change appeared to do nothing, twiceStale .next build. The stylesheet URL hash never changed, so the browser was handed a file that predated the rules while we both stared at correct source. Running next build against a running dev server did it.

The second one cost the most time across the whole session, and it's the least interesting bug in the post. Trust the artifact the browser gets, not the file you just wrote.

Get new posts by email. No spam, unsubscribe anytime.