How a six-stop spectral sequence, a quadratic lightness formula, and a synchronous inline script combine to give the interface a colour that shifts continuously across the day.
The colour system is built on six RGB anchor stops, each pinned to a specific hour of the 24-hour cycle. Every other colour in the interface derives from interpolation over these six values. The anchors were chosen to trace the spectral character of natural ambient light — not with meteorological precision, but with perceptual plausibility.
Two properties of this sequence are worth noting. First, the closed cycle: hours 0 and 21 share the exact same RGB value. The transition from 21:00 through midnight to 07:00 interpolates from indigo through deep indigo back to orange-red, which matches how night actually moves toward dawn. There is no discontinuity at midnight.
Second, noon is violet, not white. Scientifically, overhead noon light is broad-spectrum and close to neutral. But chromatic accuracy was not the goal — visual interest and arc were. Violet at 14:00 creates a distinctive midday peak that makes the full day feel like a journey, not a dial between warm and cool.
The stops were calibrated empirically using the colour tool (/colour-tool), which provides a 24-hour scrubber and shows the interpolated spectrum in real time. RGB linear interpolation, rather than a perceptually uniform space like OKLCH, was chosen because these particular anchors produce pleasant intermediate colours under straight channel lerp — and because the implementation simplicity is worth keeping.
For a given hour h ∈ [0, 24), the accent colour is computed by locating the pair of bounding anchor stops and linearly interpolating between them in RGB space:
The returned floating-point values are rounded to integer at use time. The parameter h is the local clock hour expressed as a real number — 9.5 for 09:30, 23.75 for 23:45 — so the interpolation is continuous and smooth across the full day.
Drag below to see the interpolated accent at any hour. Notice the segment boundaries at 07:00, 10:00, 14:00, 18:00, and 21:00 — the gradient changes rate at each stop, since each segment has a different slope.
The wordmark gradient and several highlight elements use a colour that should sit in contrast with the accent. The obvious approach — rotating the accent hue by 180° to find its complement — works on a colour wheel, but produces perceptually wrong results in practice. At dawn, the 180° complement of orange-red is a teal-green that feels out of place. At midnight, the complement of indigo is a muddy yellow.
A narrower rotation of 40° was chosen empirically. This produces a complement that reads as related to the accent — in the same chromatic family — while still creating visible separation. The angular gap decreases slowly from 40° at dawn to 30° by night:
This function is not clamped. For overnight hours (h < 7), the shift exceeds 40°, increasing the separation slightly as the accent drifts through the deep indigo. At dawn (h = 7) it's exactly 40°; at dusk (h = 21) it's 30°.
Rotating the hue alone is insufficient. The accent colour at dawn has a lightness of around 0.42. A complement with the same lightness, rendered on a dark background at L ≈ 0.05, has poor contrast — it may not read well as a graphic element. You need the opposite colour to be noticeably lighter than the accent.
But not uniformly lighter. At dawn, the palette should feel warm and cohesive — like a single light source. You do not want a stark contrast pair at 07:00. At midnight, the palette should feel dark and high-contrast. The lightness of the opposite should increase as you move away from dawn in either direction.
This is the problem that the SL dyn ease starlight formula solves.
The name is an acronym: saturation-invariant, lightness-dynamic, quadratic-eased, bidirectional, full diurnal cycle. The formula computes the opposite colour's lightness as a function of both the accent's natural lightness and the hour.
A normalised time parameter t measures distance from dawn in a bidirectional sense. It is zero at dawn (h = 7) and increases toward 1.0 in both directions — toward midnight before dawn, and toward dusk after it:
The continuity at h = 7 is by construction: the pre-dawn branch evaluates to 1 − 7/7 = 0, and the post-dawn branch evaluates to (7 − 7) / 11 = 0. Both branches join smoothly at dawn, where t = 0.
Key values: t = 1.0 at midnight (h = 0), t = 0.0 at dawn (h = 7), t = 1.0 at dusk (h = 18), t = 1.0 for all hours after 18:00.
At t = 0: L_o = L_a — the opposite matches the accent's natural lightness. At t = 1: L_o = 0.88, regardless of the accent. Between those extremes, the quadratic factor t² keeps the opposite close to the accent through most of the working day and concentrates the lightening toward evening and overnight.
Why t² rather than t? A linear parameter would start brightening the opposite immediately after dawn, giving the noon palette a noticeably lighter opposite than the morning. The quadratic curve keeps the opposite naturalistically close to the accent through midday — when the interface is at its most vibrant and the accent itself is lighter — and then brightens more abruptly toward the high-contrast night aesthetic.
The maximum lightness is 0.88, not 1.0. Pure white (L = 1.0) would lose all chromatic identity — it could be any hue. At L = 0.88, the opposite colour retains its hue and saturation while achieving approximately 4.5:1 contrast against the base background at L = 0.05, which is the WCAG AA threshold for normal text. The value was verified against all hues present in the accent sequence; 0.88 is the minimum lightness that consistently passes across all of them.
S_o = S_a. The opposite colour inherits the accent's saturation exactly. This keeps the two in the same chromatic family — the wordmark gradient reads as a coherent colour, not a desaturated grey.
The complete opposite colour computation, in production code:
Drag the slider to explore the formula across all 24 hours. At dawn (h = 7), accent and opposite are nearly identical in lightness. At midnight and in the evening, the opposite brightens toward 0.88 while the accent stays at its natural level.
Once the accent hue is known, all background and border colours are computed from it using fixed saturation and lightness parameters. No independent colour specification is needed for each surface role — the entire interface shares a single hue that shifts with the hour, and the surface system scales with it.
Each surface is computed as hslToRgb(H_a, s_role, l_role) where H_a is the accent hue and the saturation and lightness are predetermined per role:
Adjacent layers differ by ΔL ≈ 0.03–0.05 — enough perceptual separation to distinguish layered surfaces without disrupting the chromatic coherence. The saturation values vary slightly by role: surface layers use slightly higher saturation (0.38) than the base background (0.28) to ensure they read as distinctly coloured rather than slightly grey. Borders use lower lightness saturation to remain visually recessive.
Writing computed colour values to the DOM in client-side JavaScript after page load produces a flash-of-unstyled-content: the browser renders one frame with the CSS default values before the script runs, then jumps to the computed theme. When the computed theme is dramatically different from the default — as it is here, where the default is a generic purple and the computed value is midnight indigo at 23:00 — this flash is very visible.
The solution is to move the colour computation into a synchronous inline <script> in the HTML <head>, placed before any stylesheets or body content. Browsers execute inline scripts synchronously during HTML parsing — the parser halts, the script runs to completion, and its DOM writes are committed before the browser constructs the render tree or performs any paint. The page's first frame contains the correct colours.
The CSS :root uses registered @property declarations for each custom property, which enables CSS transitions. If a CSS transition is active when the inline script writes new values, the browser will animate from the @property initial-value to the computed value on first paint — a brief but visible animation from the wrong colour to the right one.
The solution is a double requestAnimationFrame callback:
Two frames are required because the first rAF fires before the browser has committed the paint with transition suppressed. The second fires after that paint has completed, at which point restoring transitions will not cause any animation of the already-committed values. Subsequent JavaScript updates to the custom properties will then animate normally.
Because the inline script reads the client's local time at execution, no server-side knowledge of the client time zone is required. The approach works with fully static deploys — no cookies, no edge personalisation, no additional request. The landing page uses a fixed h = 7 (dawn) for the pre-paint pass to present a consistent chromatic snapshot to all visitors, then runs a live animation cycle in JavaScript to demonstrate the full spectrum.
After the initial paint, a periodic timer running at 60-second intervals re-evaluates the colour computation and updates the CSS custom properties in place. Because @property custom properties are tracked by the CSS transition system, the 1-second linear transition on :root smoothly interpolates between successive one-minute colour values. No React re-renders, no DOM traversal — just setProperty and CSS handles the animation.
The accent colour at overnight hours (21:00–07:00) has a natural lightness of around 0.40–0.42. Used directly as a text colour on the dark background at L = 0.05, this fails the WCAG AA contrast threshold of 4.5:1 by a material margin. The wordmark gradient and large display uses the opposite colour (which is already high-L at night), but inline text rendered in the accent colour — time stamps, active state labels, keyboard shortcuts — needs a separate solution.
A CSS custom property --text-accent enforces a minimum lightness floor while preserving the accent hue and saturation:
The floor of 0.62 is the minimum lightness that achieves approximately 4.5:1 contrast against the dark background surface across all hues in the accent sequence. Below L = 0.62, some hues fall below the threshold; above it, all pass. At most daytime hours, --text-accent equals --accent exactly — the floor only activates during the overnight indigo period.
This means the --text-accent variable can safely replace --accent everywhere text colour is needed on dark backgrounds, without affecting the daytime appearance.
The full production implementation spans three locations:
All three share the same formula constants: the six STOPS array, the hue shift function, the bidirectional t parameter, and the 0.88 lightness ceiling. Any change to the formula needs to be propagated to all three locations.
The interactive calibration environment at /colour-tool provides a 24-hour spectrum view, per-hour opposite colour inspection, and a draggable chart for manual tuning of the opposite colour at each hour. The manual-colour-ramp.txt file in the project root records a reference calibration run that informed the final formula constants.