Palette
Colours that go together. Shuffle shrinks the bars into merging beads, swaps colour and springs back out.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// Palette: colours that go together. Shuffle shrinks the bars into merging beads, swaps colour and springs back out.
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
import { animate, useReducedMotion } from "framer-motion";
import { Shuffle } from "lucide-react";
export type PaletteCue = "push";
export type PaletteProps = {
/** How many colours, 3..5. Ignored when `defaultValue` is given. */
count?: number;
/** The first palette as hex strings (3..5). Without it a fixed starter ramp shows. */
defaultValue?: string[];
/** Corner radius of the bars in px. */
corner?: number;
/** Morph speed, 0..100 (higher is faster). */
morph?: number;
/** Accessible name of the shuffle button. */
label?: string;
/** Called with the new hex colours when a shuffle lands. */
onChange?: (hexes: string[]) => void;
/** Sound hook: "push" when a shuffle starts. */
onSound?: (cue: PaletteCue) => void;
className?: string;
style?: CSSProperties;
};
type Lch = [number, number, number];
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
const between = (lo: number, hi: number) => lo + Math.random() * (hi - lo);
// OKLab -> linear sRGB, through the two published matrices
const LMS_FROM_LAB = [[1, 0.3963377774, 0.2158037573], [1, -0.1055613458, -0.0638541728], [1, -0.0894841775, -1.291485548]];
const RGB_FROM_LMS = [[4.0767416621, -3.3077115913, 0.2309699292], [-1.2684380046, 2.6097574011, -0.3413193965], [-0.0041960863, -0.7034186147, 1.707614701]];
const apply = (m: number[][], v: number[]) => m.map((row) => row[0] * v[0] + row[1] * v[1] + row[2] * v[2]);
function linearRgb([L, C, H]: Lch) {
const rad = (H * Math.PI) / 180;
const lms = apply(LMS_FROM_LAB, [L, C * Math.cos(rad), C * Math.sin(rad)]).map((v) => v ** 3);
return apply(RGB_FROM_LMS, lms);
}
const fits = (rgb: number[]) => rgb.every((v) => v > -0.0005 && v < 1.0005);
const toHex = ([L, C, H]: Lch) => {
let chroma = C;
while (chroma > 0.01 && !fits(linearRgb([L, chroma, H]))) chroma -= 0.004; // pull chroma in until it fits sRGB
const encode = (v: number) => (v <= 0.0031308 ? 12.92 * v : 1.055 * Math.abs(v) ** (1 / 2.4) - 0.055);
return "#" + linearRgb([L, chroma, H]).map((v) => Math.round(clamp01(encode(v)) * 255).toString(16).padStart(2, "0")).join("");
};
type Recipe = (i: number, n: number, hue: number, dir: number) => Lch;
const RECIPES: Record<string, Recipe> = {
ramp: (i, n, hue, dir) => [0.9 - (i / (n - 1)) * 0.48, 0.12 + Math.sin((i / (n - 1)) * Math.PI) * 0.12, hue + i * between(14, 26) * dir],
spread: (i, _n, hue, dir) => [between(0.66, 0.8), 0.24, hue + i * between(40, 70) * dir],
pair: (i, n, hue) => [i >= n / 2 ? between(0.46, 0.56) : between(0.8, 0.88), 0.22, hue + (i % 2 ? between(160, 190) : 0)],
triad: (i, _n, hue, dir) => [between(0.58, 0.82), 0.22, hue + (i % 3) * 120 * dir + between(-8, 8)],
accent: (i, n, hue, dir) => (i === n - 1 ? [0.52, 0.26, hue + 180 * dir] : [between(0.8, 0.87), 0.18, hue + i * 18 * dir]),
};
/** Deterministic first palette so server and client render the same colours; every press after that is random. */
const starter = (n: number) => Array.from({ length: n }, (_, i) => toHex([0.9 - (i / (n - 1)) * 0.48, 0.12 + Math.sin((i / (n - 1)) * Math.PI) * 0.12, (238 + i * 20) % 360]));
const BAR_W = 224;
const BAR_H = 58;
const MARGIN = 10;
const GAP = 4;
const BTN_W = 30;
const BEAD = BAR_H * 0.66;
const HOLD = 0.4;
// 0..HOLD eases into the bead state; HOLD..1 comes back with an overshoot
const beadness = (t: number) => {
if (t <= 0) return 0;
if (t < HOLD) return 1 - (1 - t / HOLD) ** 3;
const u = (t - HOLD) / 0.6 - 1;
return 1 - (1 + 2.7 * u ** 3 + 1.7 * u ** 2);
};
export function Palette({ count = 4, defaultValue, corner = 14, morph = 50, label = "Generate a new palette", onChange, onSound, className, style }: PaletteProps) {
const n = Math.min(5, Math.max(3, Math.round(defaultValue?.length ?? count)));
const [colors, setColors] = useState(() => (defaultValue && defaultValue.length === n ? defaultValue : starter(n)));
const [turns, setTurns] = useState(0);
const calm = !!useReducedMotion();
const filterId = `pal-${useId().replace(/:/g, "")}`;
const slab = useRef<HTMLDivElement>(null);
const beads = useRef<HTMLDivElement>(null);
const blur = useRef<SVGFEGaussianBlurElement>(null);
const bars = useRef<(HTMLSpanElement | null)[]>([]);
const flight = useRef<{ stop: () => void } | null>(null);
const mood = useRef<{ hue: number; last: string | null } | null>(null);
if (colors.length !== n) setColors(starter(n));
useEffect(() => () => flight.current?.stop(), []);
const barW = (BAR_W - GAP * (n - 1)) / n;
const radius = Math.min(BAR_H / 2, Math.max(0, corner));
const slabRadius = radius + MARGIN * Math.min(1, radius / 14);
const place = (i: number, s: number) => {
const w = mix(barW, BEAD, s);
const h = mix(BAR_H, BEAD, s);
return { width: `${w}px`, height: `${h}px`, top: `${(BAR_H - h) / 2}px`, borderRadius: `${mix(radius, h / 2, s)}px`, transform: `translateX(${(i * (barW + GAP) + (barW - w) / 2).toFixed(2)}px)` };
};
// progress is painted straight onto the DOM; React only hears about the start and the end
const paint = (u: number, from: string[], to: string[]) => {
const k = clamp01(u);
const s = beadness(k);
const soft = Math.sin(Math.PI * k) * 5;
blur.current?.setAttribute("stdDeviation", soft.toFixed(3));
if (beads.current) beads.current.style.filter = soft > 0.05 ? `url(#${filterId})` : "";
if (slab.current) slab.current.style.scale = String(1 - 0.035 * s);
bars.current.forEach((el, i) => el && Object.assign(el.style, place(i, Math.max(0, s)), { background: k >= HOLD ? to[i] : from[i] }));
};
const shuffle = () => {
if (flight.current) return;
onSound?.("push");
setTurns((x) => x + 1);
const m = (mood.current ??= { hue: Math.random() * 360, last: null });
m.hue = (m.hue + between(60, 300)) % 360;
const names = Object.keys(RECIPES).filter((k) => k !== m.last);
const pick = names[Math.floor(Math.random() * names.length)];
m.last = pick;
const dir = Math.random() < 0.5 ? -1 : 1;
const next = Array.from({ length: n }, (_, i) => RECIPES[pick](i, n, m.hue, dir))
.sort((a, b) => b[0] - a[0])
.map(([L, C, H]) => toHex([L, C, ((H % 360) + 360) % 360]));
if (calm) {
setColors(next);
onChange?.(next);
return;
}
const from = colors;
const speed = 1.6 - (Math.min(100, Math.max(0, morph)) / 100) * 1.2;
flight.current = animate(0, 1, {
duration: (760 * speed) / 1000,
ease: "linear",
onUpdate: (u) => paint(u, from, next),
onComplete: () => {
flight.current = null;
paint(0, next, next);
setColors(next);
onChange?.(next);
},
});
};
return (
<div className={`pal-root${className ? ` ${className}` : ""}`} style={{ width: BAR_W + MARGIN * 2 + 6 + BTN_W + MARGIN * 2, ...style }}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden>
<defs>
<filter id={filterId} colorInterpolationFilters="sRGB">
<feGaussianBlur ref={blur} in="SourceGraphic" stdDeviation={0} result="b" />
<feColorMatrix in="b" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 26 -12" result="g" />
<feComposite in="SourceGraphic" in2="g" operator="atop" />
</filter>
</defs>
</svg>
<div ref={slab} className="pal-slab" style={{ padding: MARGIN, borderRadius: slabRadius }}>
<div className="pal-bars" style={{ width: BAR_W, height: BAR_H }}>
<div ref={beads} className="pal-beads" aria-hidden>
{colors.map((c, i) => (
<span key={i} ref={(el) => void (bars.current[i] = el)} className="pal-bar" style={{ background: c, ...place(i, 0) }} />
))}
</div>
</div>
</div>
<div className="pal-slab pal-side" style={{ padding: MARGIN, borderRadius: slabRadius }}>
<button type="button" className="pal-go" aria-label={label} onClick={shuffle} style={{ width: BTN_W, height: BAR_H, borderRadius: radius }}>
<Shuffle size={18} strokeWidth={2} style={{ rotate: `${turns * 180}deg` }} />
</button>
</div>
</div>
);
}
/* Palette. Prefix pal-. */
.pal-root, .pal-root * { box-sizing: border-box; }
:where(.pal-root) button {
background: none;
border: 0;
color: inherit;
font: inherit;
margin: 0;
padding: 0;
}
:where(.pal-root) :focus-visible { outline: 2px solid var(--ts-focus, #3446eb); outline-offset: 2px; }
.pal-root {
--pal-ease: cubic-bezier(0.22, 1, 0.36, 1);
align-items: stretch;
display: flex;
font-family: var(--ts-font, system-ui, sans-serif);
gap: 6px;
position: relative;
}
.pal-slab {
align-items: center;
background: var(--ts-raised);
box-shadow: var(--ts-edge);
display: flex;
justify-content: center;
}
.pal-side {
flex: none;
transition: scale 0.22s var(--pal-ease);
}
.pal-side:has(.pal-go:active) {
scale: 0.96;
}
.pal-bars {
flex: none;
position: relative;
}
.pal-beads {
inset: 0;
position: absolute;
}
.pal-bar {
left: 0;
position: absolute;
top: 0;
}
.pal-go {
color: var(--ts-fg);
cursor: pointer;
display: grid;
place-items: center;
transition: color 0.16s var(--pal-ease);
}
.pal-go:hover {
color: color-mix(in srgb, var(--ts-fg) 55%, var(--ts-raised));
}
.pal-go:focus-visible {
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px rgba(var(--ts-fg-rgb), 0.45);
outline: 0;
}
.pal-go svg {
transition: rotate 0.6s var(--pal-ease);
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Palette } from "./Palette";
import "./Palette.css";
export default function Demo() {
return <Palette />;
}