· 11 min read

Deleting the most beautiful thing I built

A procedurally rendered sky drove the whole interface for months: sun position, clouds, glassmorphism, all of it. It looked best on the machine I built it on and worst on the one I actually use, so it's gone.

I have always loved how physical interfaces blend into their environment and shift with the light through the day. When I started building my own dashboard, I wanted the same thing: something that felt like an extension of nature rather than a layer on top of it, so that using it felt less like an escape into a screen and more like a continuation of the room it sat in.

The closest thing I know to that feeling is the dynamic photographic wallpapers macOS ships, the ones that shift frame by frame with the time of day. Apple sadly stopped expanding the set a few years ago, but I still run the Mojave dune background from it. I could have built something similar out of photos, but photos can’t be fully seamless between two points in time, and video was out of the question entirely. So I built a sky instead, computed rather than captured, so that every minute of the day had an exact frame instead of a cut between two fixed ones.

Here is roughly what it looked like.

09:00
Same sky math as original version, minus the clouds, the real lat/long, and the full-viewport canvas. Use the slider or press play to see it animate throughout the day.

This is about deleting what is, in my eyes, still one of the most beautiful things I’ve built, and why the dashboard got better once I did.

The design

It filled the whole background and scaled to any screen size, with accent colours derived from whatever the sky was doing at that moment rather than fixed to a static palette.

  1. A blue sky through the day, with a sun disc moving left to right on a curved arc, rising and setting according to the real sun’s position at my location. It also followed it’s real trajectory: in winter the sun sat lower and spent longer near the horizon than in summer.
  2. Stars faded in at night, brighter ones first, so twilight looked gradual instead of the whole field switching on at once. They rotated slowly across the sky over the course of a day, the same direction the real sky turns, with Polaris fixed at the centre since I’m in the northern hemisphere. I wanted real constellations too, but abandoned that: a lot of extra effort for a detail mostly hidden behind interface elements anyway.
  3. Cloud cover shifted the whole palette rather than sitting on top of it: more cloud meant lower saturation and a soft blur over detail like the sun and the stars.

All of it sat under glassmorphism. Every piece of interface, cards, navigation, all of it, was translucent and blurred with a faint glowing border, so it read as part of the sky rather than a layer stacked over it.

The sun grew slightly as it neared the horizon, a deliberate bit of fake atmospheric lensing. Colour was interpolated in OKLCH between a handful of fixed keyframes for dawn, midday, dusk and night, with the horizon hue at sunrise and sunset lerping toward white as it approached noon.

EnvironmentBackground.tsx ran to 769 lines by the end, fed by a 196-line EnvironmentContext that tracked sun and cloud state, and a 124-line ThemeContext that derived the entire interface palette, not just the background, from whatever the sky happened to be doing.

How it worked

Sun elevation was the one real driver. It was computed from my current time and location, and almost everything else read off that single number: the sun’s on-screen position came from plotting elevation onto the same curved arc rather than a straight line, and dark mode only dimmed the daytime colours, since night was already dark enough on its own without a separate treatment.

Cloud cover came from a small backend API that queried the actual weather and cached the result, so the frontend never made a cross-site request of its own. During development, a hidden debug panel let me scrub time, cloud cover and latitude independently, so I could preview any sky on demand instead of waiting for the real one to happen.

The problem

Performance. It was sluggish on mobile in a way that never showed up while I was testing on a desktop.

The cause was the combination of a lot of blur with a lot of rerenders, since the sky’s transitions were seamless rather than stepped. Because the actual content lived inside the glass rather than beside it, most of the screen was covered in blur at any given time, and something as simple as fast-scrolling a long list turned visibly janky. It’s the kind of cost that’s cheap to pay on a laptop and expensive on the phone that’s the actual client.

I should be precise about what I know here, because it matters for the rest of the post: I never profiled any of this. There is no trace, no frame timing, no before and after. What I have is that scrolling felt wrong on the phone and fine on the laptop, consistently enough that I stopped needing to check. Every claim below about it being faster now rests on the same kind of evidence, which is to say on my own thumb.

Dark mode had its own, smaller problem. Letting the day set the interface’s brightness for me worked about 90% of the time, but the other 10%, mostly around blue hour, it landed either too bright or too dark, with no way to override it if I needed to. Clouds added a further axis of complexity on top: one more variable with its own inputs, adding surface area to a component that was already large.

How it died

The first attempt was not to delete it. It was to save it by making it cheaper.

Two commits landed the same day, 29 June. 07a8fbf, “removed dynamic interface color”, stopped the theme deriving from the sky: styles.css lost 125 lines and gained 55, and the palette became fixed. Right after it, 557e5c6:

Reduced blur and removed sun for better performance.

— 557e5c6

That commit cut the blur back and deleted the 55-line Sun component. The message says performance, and the blur reduction genuinely was about performance, but the sun was not. Once the glass stopped being frosted, a bright disc sitting behind clear panels read as clutter rather than atmosphere. I wrote the defensible reason into the commit and kept the real one to myself, which is a habit worth noticing in your own history.

It didn’t work anyway. Without the frosted glass and without the sun, the elegance was gone and what was left looked like a cheap recreation of the thing I actually wanted. Reducing it had produced something both slower than the plain version and worse-looking than the full one, which is the worst place a compromise can land.

So I let go. The last commit, cd05e68 on 19 July, “Design update”, took the rest: all 769 lines of EnvironmentBackground.tsx, plus EnvironmentContext and ThemeContext alongside it.

The solution

The fix wasn’t to accept the slowness. It was to keep the idea and throw out the implementation that had grown around it. A sky that changes as the day changes is a good idea; a 769-line canvas rendering it behind translucent glass on every screen in the app was not. It was just the one I’d built first and kept adding to.

Weather was the first thing to go. Removing cloud cover took away a whole axis of state and a whole category of blur. What’s left is a single axis of change: the sun’s elevation, the same number that drove everything before, just without a scene rendered from it.

What replaced the renderer is useDaylight.ts, about 30 lines doing the same job with no scene at all: no canvas, no context tree, just a handful of CSS custom properties written straight onto the document.

tshooks/useDaylight.ts
useLayoutEffect(() => {
const el = document.documentElement;
let raf = 0;

const apply = () => {
  const now = new Date();
  const hour = now.getHours() + now.getMinutes() / 60;
  const vars = mode === "dark" ? darkVars() : lightVars(hour);
  for (const [k, v] of Object.entries(vars)) el.style.setProperty(k, v);
};

const schedule = () => {
  cancelAnimationFrame(raf);
  raf = requestAnimationFrame(apply);
};

apply();
const id = setInterval(schedule, 60_000);
document.addEventListener("visibilitychange", schedule);
// cleanup: clearInterval(id), cancelAnimationFrame(raf), remove listener
}, [mode]);

I would like to bring attention to four deliberate choices in this excerpt from the code:

  1. useLayoutEffect rather than useEffect, so the first write lands before the browser paints. With useEffect there is a visible flash of the wrong palette on mount, which is exactly the kind of detail the old renderer never had to think about because it painted its own background.
  2. The variables go straight onto documentElement, not into React state or context, so the per-minute refresh never re-renders the tree it’s updating. React never sees the change at all.
  3. The interval only schedules. The write itself happens inside requestAnimationFrame, so it lands on a frame boundary instead of at an arbitrary point in the render cycle.
  4. visibilitychange reuses the same scheduler, and it exists because of the previous point: requestAnimationFrame doesn’t run in a hidden tab, and background intervals get throttled. A phone woken up four hours later would otherwise still be showing the morning’s colours.

The result costs one style write a minute instead of a canvas repainting behind a scrolling list.

What replaced the sky feels less like weather and more like a well-made book: solid colour, clear type, a palette that moves without ever asking you to notice it moving. In light mode the accent colour shifts through the day the same way the old sun did, warm and low toward the edges, cool and bright at zenith. Dark mode stopped trying to be a dimmed version of daytime and became its own fixed thing: a dark background with a strong red accent, on all the time, with nothing left for blue hour to get wrong.

lightVars and darkVars produce a handful more tokens than the swatch below shows (surface, border, muted text among them), all derived from the same two interpolations. Background and accent are enough to see the shape of it:

Interface, not sky

Two colour stops, same as the sky: one for the background, one for the accent.

The accent hue and the background warmth each come from one interpolation across the day. Dark mode drops the hour entirely: one fixed charcoal, one fixed red.

Where it is now

The sky itself didn’t disappear, it just stopped being load-bearing. The demo at the top of this article is the whole of it now: the same math, the same sun, the same stars, on a canvas a few hundred pixels wide that sits idle until you press play, rather than running full-viewport behind every screen in the app. It’s smaller, it’s inert by default, and it costs nothing until someone actually asks to see it. The thing that used to be the product is now an illustration of the product.

The rest of the interface is plainer than it used to be. Tokens update off the same hour input automatically, there’s no debug panel needed to hunt down a broken contrast combination because there are only two states to check instead of a continuous field of them, and blue hour stopped being a special case.

The cost is real, though, and I don’t want to pretend otherwise. Nobody will ever open the dashboard and notice the light. The old version got that reaction from every person I showed it to (especially when the sun is close to the horizon), and this one gets nothing, because not being noticed is the entire design. I traded the only part of the interface that anyone ever remarked on for one that is right more often than it is remarkable.

Closing thoughts

The thing worth keeping apart is the vision from the artifact. The artifact was a simulated sky with weather and stars and a growing sun, and only the artifact was ever deletable.

But I want to be careful about how cleanly I claim the vision survived, because it didn’t, quite. What I set out to build was an interface you would notice changing, the way you notice a dynamic wallpaper. What I kept is an interface that changes without ever drawing attention to it. Those are not the same goal. The vision got quieter somewhere in the deleting, and I only caught the narrowing when I sat down to write this. I do think the narrower one is better. And at the same time I wish it was not.

Sunk cost isn’t the interesting failure here; rebuilding something repeatedly in the same direction isn’t unusual for something I cared about. The interesting failure is that I evaluated it on the wrong machine the entire time. Every hour of that component got judged on the laptop I built it on, over the network in the room I built it in. The thing it was actually for gets used on a phone most of the time. A component can be correct on the machine that wrote it and wrong on the machine that runs it, and nothing about writing the code tells you which one you’re looking at.

What I’d keep is the colour thinking, not the renderer. The OKLCH interpolation, the idea that a palette can move continuously across a day instead of switching between two fixed states, all of that survived intact in lightVars and darkVars. The canvas, the context tree, the glass, none of that was the point. It was just the first shape the idea took.

A note to close on: the deleted Sun was never actually deleted in that commit. The JSX was commented out, and it sat in the tree for three weeks before cd05e68 finally took it. Even once I’d decided, I left myself a way back in before I could commit to the version without one. Deleting something you’re proud of, it turns out, is a two-stage process for most people, and I don’t think I’m an exception.