Teseria
All elements

Palette

Colours that go together. Shuffle shrinks the bars into merging beads, swaps colour and springs back out.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

Palette.tsx174 lines
"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.css63 lines
/* 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);
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Palette } from "./Palette";
import "./Palette.css";

export default function Demo() {
  return <Palette />;
}