Ambient hue follows focus
The background is not a theme, it is a number. Whatever has focus hands over its hue, one registered custom property tweens to it, and the whole screen changes mood without any element being recoloured.
How it works
- One gradient on
body, four stops, every stop written ashsl(var(--h) S L). Saturation and lightness are fixed per stop; only the hue moves. That is why a purple sky and a teal sky feel like the same place at a different hour rather than two themes. - 🩸
@property --h { syntax: "<number>" }is the entire mechanism. Without registration a custom property has no interpolation type,transition: --h 900msis silently ignored, and the sky snaps. The readout in the corner shows the interpolated value climbing — that is the proof it animates. - The page never chooses a colour. Each focusable thing carries
data-h; focusing it copies that number into--h. Adding a fourth context is one attribute, not a stylesheet change. - The focused card lifts 6px and goes opaque white; the others stay at 35% white over the sky. Same element, one class — the card does not know what hue the sky is.
- Hover and click both drive focus, so the effect works with a pointer and with a tap;
pointerenterfires once per crossing, not per pixel.
Numbers
- Sky stops
- 16% / 44% / 80% lightness · 55 / 62 / 50 saturation · paper @90%
- Transition
- --h 900ms cubic-bezier(.22,.61,.36,1)
- Hues
- 275 · 190 · 228
- Card
- #ffffff59 rest → #ffffffe0 focused · lift 6px · 500ms
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build an 'ambient hue follows focus' panel in vanilla HTML/CSS/JS. Register the hue: @property --h { syntax: '<number>'; inherits: true; initial-value: 275 }. On body set --h: 275, transition: --h 900ms cubic-bezier(.22,.61,.36,1), and background: linear-gradient(180deg, hsl(var(--h) 55% 16%) 0%, hsl(var(--h) 62% 44%) 34%, hsl(var(--h) 50% 80%) 66%, #f5f4f9 90%). Place three 190×210 cards (radius 26, #ffffff59 with a 1px #ffffff80 border and backdrop-filter blur(20px)), each with a data-h attribute (275, 190, 228), a chip, a colour dot showing hsl(var(--c) 62% 44%), a title and a subtitle. On pointerenter and click, mark that card focused (background #ffffffe0, translateY(-6px), 500ms) and write its data-h into --h on the body. Show a monospace readout in the corner that reads the computed --h every animation frame and prints it rounded, so the interpolation is visible. Never set any colour on any element from JavaScript except that one number. Under prefers-reduced-motion drop the transitions.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ambient hue follows focus</title>
<style>
/* 🩸 Register the hue as a <number>. An unregistered custom property has no
interpolation type, so `transition: --h` is ignored and the sky snaps. */
@property --h { syntax: "<number>"; inherits: true; initial-value: 275; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
--h: 275;
transition: --h 900ms cubic-bezier(.22,.61,.36,1);
/* The whole ambience is this one gradient. Nothing else is coloured. */
background: linear-gradient(180deg,
hsl(var(--h) 55% 16%) 0%,
hsl(var(--h) 62% 44%) 34%,
hsl(var(--h) 50% 80%) 66%,
#f5f4f9 90%);
color: #17151f; overflow: hidden; -webkit-font-smoothing: antialiased;
}
.wrap { height: 100%; display: grid; grid-template-rows: auto 1fr auto; padding: 22px 24px 18px; }
.top { display: flex; justify-content: space-between; align-items: baseline; color: #fff; }
.top h1 { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.top .read { font: 500 11px/1 ui-monospace, Menlo, monospace; color: #ffffffb0; }
.top .read b { color: #fff; font-weight: 600; }
.row { display: flex; gap: 12px; align-items: center; justify-content: center; }
.card {
width: 190px; height: 210px; padding: 16px; border-radius: 26px; cursor: pointer;
background: #ffffff59; border: 1px solid #ffffff80; color: #17151f;
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 8px;
transition: background-color 500ms, transform 500ms cubic-bezier(.22,.61,.36,1), box-shadow 500ms;
}
.card:hover, .card.on { background: #ffffffe0; color: #17151f; transform: translateY(-6px); box-shadow: 0 24px 40px -24px #1a123a80; }
.chip { align-self: flex-start; padding: 3px 9px; border-radius: 99px; font-size: 10px; font-weight: 500; border: 1px solid currentColor; opacity: .8; }
.card h3 { margin-top: auto; font-size: 17px; font-weight: 600; letter-spacing: -.01em; }
.card p { font-size: 11px; opacity: .7; }
/* the swatch: each card carries its hue as data and shows it as a dot */
.card i { width: 16px; height: 16px; border-radius: 50%; background: hsl(var(--c) 62% 44%); box-shadow: 0 0 0 3px #ffffff80; }
.foot { text-align: center; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #17151f80; }
@media (prefers-reduced-motion: reduce) { body, .card { transition: none; } .foot { display: none; } }
</style>
<div class="wrap">
<div class="top"><h1>Mind Space</h1><div class="read">--h <b id="rh">275</b></div></div>
<div class="row" id="row">
<div class="card on" data-h="275"><span class="chip">Family</span><i style="--c:275"></i><h3>Relaxation Ideas</h3><p>Activities for couples</p></div>
<div class="card" data-h="190"><span class="chip">Family</span><i style="--c:190"></i><h3>Spring Trip</h3><p>Complete vacation planning</p></div>
<div class="card" data-h="228"><span class="chip">Work</span><i style="--c:228"></i><h3>Build Brain.ai</h3><p>Tasks to-do · 6</p></div>
</div>
<div class="foot">hover a card · the sky follows it</div>
</div>
<script>
/* The page never picks a colour. It asks the focused thing what hue it
carries and writes that ONE number; the gradient does the rest. */
const cards = [...document.querySelectorAll(".card")];
const rh = document.getElementById("rh");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
function focus(card) {
cards.forEach((c) => c.classList.toggle("on", c === card));
document.body.style.setProperty("--h", card.dataset.h);
}
cards.forEach((c) => { c.addEventListener("pointerenter", () => focus(c)); c.addEventListener("click", () => focus(c)); });
// readout shows the interpolated value, which is what proves it animates
(function loop() {
rh.textContent = Math.round(parseFloat(getComputedStyle(document.body).getPropertyValue("--h")));
if (!reduce) requestAnimationFrame(loop);
})();
</script>Where it breaks
- Hue interpolation goes the short way round the wheel: 275 → 190 passes through blue (fine), but 350 → 20 would pass through red at full saturation. If two contexts sit across the wheel, interpolate in OKLCH or step through a neutral.
- Text on the sky must be checked at every hue, not the one you designed with. Lightness is pinned per stop here so contrast is stable; the moment saturation or lightness varies per context that guarantee is gone.
Seen in
@海森堡 (Douyin) · “自然人工智能设计探索的思维空间” · 0:03–0:11