Chromatic glow
Wanted a glow where the light blooms out soft and the colors pull apart at the edges. So I built it from the ground up.
It is a real bloom: the word is blurred at a few sizes and added back together, then split into a warm copy and a cool copy that shift a little in opposite ways. That shift is what makes the rainbow edge. Move over it and the split follows your cursor. Type your own word and pick the two colors below.
Playground
Word
Glow
Colours
Code
// Chromatic Glow presets + Remix. Each preset is its own colour world (a distinct
// background paired with a matching warm/cool/fringe palette). Remix keeps the RGB-split
// mechanic but rolls a fresh warm/cool pair, a matching background, and a split amount.
import { CHROMA_DEFAULTS, type ChromaParams } from "./engine";
export type ChromaPreset = { id: string; name: string; params: Partial<ChromaParams> };
// Each preset is its OWN world: a distinct background hue paired with a matching but
// unique glow palette (warm / cool / fringe) and its own split + bloom feel. No two share
// a background family. The first stays the default (the card renders it).
export const CHROMA_PRESETS: ChromaPreset[] = [
{
id: "goldnavy",
name: "Gold / Navy",
// warm gold vs. electric blue on a DEEP NAVY wall (the default)
params: { warm: [1.0, 0.82, 0.25], cool: [0.28, 0.46, 1.0], red: [1.0, 0.34, 0.5], split: 9.5, bloom: 1.68, core: 1.08, noise: 0.16, bg: [0.05, 0.07, 0.19] },
},
{
id: "emberteal",
name: "Ember / Teal",
// hot orange-red vs. cyan on a near-BLACK CHARCOAL wall with a warm cast
params: { warm: [1.0, 0.42, 0.12], cool: [0.18, 0.92, 0.86], red: [1.0, 0.86, 0.3], split: 7.5, bloom: 1.45, core: 1.0, noise: 0.14, bg: [0.13, 0.09, 0.07] },
},
{
id: "limemagenta",
name: "Lime / Magenta",
// acid lime vs. hot magenta on a DEEP VIOLET wall
params: { warm: [0.72, 1.0, 0.2], cool: [1.0, 0.24, 0.72], red: [0.4, 0.85, 1.0], split: 10.5, bloom: 1.55, core: 1.05, noise: 0.18, bg: [0.12, 0.05, 0.18] },
},
{
id: "icefire",
name: "Ice / Fire",
// ice blue vs. coral red on a dark TEAL-BLACK wall
params: { warm: [1.0, 0.5, 0.42], cool: [0.5, 0.82, 1.0], red: [0.7, 1.0, 0.9], split: 8.5, bloom: 1.6, core: 1.1, noise: 0.12, bg: [0.05, 0.13, 0.14] },
},
{
id: "creampress",
name: "Cream",
// LIGHT mode: warm cream wall, dark word, a soft peach + violet split haze
params: { warm: [1.0, 0.64, 0.22], cool: [0.5, 0.42, 1.0], red: [1.0, 0.36, 0.44], split: 9, bloom: 1.5, core: 1.0, noise: 0.1, bg: [0.95, 0.92, 0.83], invert: true },
},
{
id: "mintpress",
name: "Mint",
// LIGHT mode: cool pale-mint wall, dark word, a teal + rose split haze
params: { warm: [1.0, 0.42, 0.62], cool: [0.16, 0.78, 0.72], red: [0.5, 0.6, 1.0], split: 9.5, bloom: 1.55, core: 1.0, noise: 0.1, bg: [0.86, 0.95, 0.9], invert: true },
},
];
export function defaultChromaParams(): ChromaParams {
return { ...CHROMA_DEFAULTS, ...CHROMA_PRESETS[0].params };
}
// rgb 0..1 <-> #hex for the ColorControl (which speaks hex)
export const toHex = (c: [number, number, number]) =>
"#" + c.map((v) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, "0")).join("");
export const fromHex = (h: string): [number, number, number] => {
const n = h.replace("#", "");
return [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2) || "0", 16) / 255) as [number, number, number];
};
// A vivid, glowy random colour: a saturated hue lifted a little so it never reads dim
// on black. Returns rgb 0..1.
function vividHue(hue: number, lift = 0.15): [number, number, number] {
const c = 1;
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const seg = [
[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x],
][Math.floor(hue / 60) % 6];
return [seg[0] * (1 - lift) + lift, seg[1] * (1 - lift) + lift, seg[2] * (1 - lift) + lift];
}
// A background tint that SUITS a glow palette: take the warm+cool average hue, keep a
// touch of that colour, but crush it very dark + low-saturation so it always reads as a
// deep neutral wall the glow can sit on (never a bright or clashing panel).
function suitedBg(warm: [number, number, number], cool: [number, number, number]): [number, number, number] {
// Average the two glow colours, boost that hue's saturation, then set it to a low
// (dark) value. The result is a clearly-tinted deep wall (navy / wine / plum / moss),
// not a flat near-black — but still dark enough for the glow to read.
const avg = [(warm[0] + cool[0]) / 2, (warm[1] + cool[1]) / 2, (warm[2] + cool[2]) / 2];
const max = Math.max(avg[0], avg[1], avg[2], 0.001);
const floor = 0.05; // darkest channel never goes fully black
const peak = 0.17; // brightest channel of the wall (keeps it dark)
// normalize to push saturation up, then scale into [floor, peak]
return [0, 1, 2].map((i) => floor + (avg[i] / max) * (peak - floor)) as unknown as [number, number, number];
}
// Remix: a fresh complementary warm/cool pair (opposite sides of the wheel), a random
// third fringe, a matching dark background, and varied split/bloom.
export function remixChromaParams(): Partial<ChromaParams> {
const h = Math.random() * 360;
const warm = vividHue(h);
const cool = vividHue((h + 150 + Math.random() * 60) % 360);
const red = vividHue((h + 40 + Math.random() * 40) % 360, 0.1);
// ~1 in 3 remixes is a LIGHT-mode variant: a light wall (a pale tint of the palette
// hue) with the word pressed in dark. Otherwise the usual dark wall + glowing word.
const invert = Math.random() < 0.34;
const bg = invert ? suitedLightBg(warm, cool) : suitedBg(warm, cool);
return {
warm,
cool,
red,
bg,
invert,
split: 4 + Math.random() * 8, // 4 - 12 px
bloom: 1.0 + Math.random() * 0.4,
noise: 0.06 + Math.random() * 0.14,
};
}
// A LIGHT wall that suits the palette: a very pale tint of the average glow hue (mostly
// white, a hint of colour) so the dark pressed word + coloured fringe read cleanly.
function suitedLightBg(warm: [number, number, number], cool: [number, number, number]): [number, number, number] {
const avg = [(warm[0] + cool[0]) / 2, (warm[1] + cool[1]) / 2, (warm[2] + cool[2]) / 2];
const max = Math.max(avg[0], avg[1], avg[2], 0.001);
// mostly white (~0.9), with ~8% of the normalized hue mixed in
return [0, 1, 2].map((i) => 0.88 + (avg[i] / max) * 0.08) as unknown as [number, number, number];
}
Credits
CompanyStudy
DateJul 14, 2026
TagsWebGL, Glow, Bloom
MIT → free to copy