Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Chromatic goo

Advanced

Drag a colour onto a card: it lands like a drop of liquid and rolls across, repainting each element as the wave passes.

chromatic-goo/chromatic-goo.tsx
Live · interactive
Theme

Drag a colour from the palette onto a card, or press a colour and then a card.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Showing off a theme or brand colour picker where the change itself is the moment: onboarding, a landing page, a design-tool demo.
  • Applying an accent to one surface at a time, so people compare cards side by side before they commit.
  • A playful settings screen seen once or twice, where a one-second wave is a reward, not a wait.
  • Teaching that a theme is a set of tokens: every element on the card changes as the wavefront reaches it.

When not to use

  • A theme setting changed often or in a hurry. A plain swatch row or a select applies it at once.
  • Light, dark or system appearance. Use a segmented control: that choice is not a colour to drag.
  • More than a handful of colours, or a custom one. Use a colour picker with a hex field.
  • Recolouring a whole page: the wave is drawn per card, on a canvas the size of the card.
  • Where drag and drop is the only way people would find it. Keep the press-a-colour, press-a-card path visible in the copy.

You can add this component with the shadcn CLI or by hand:

Into components/chromatic-goo of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/chromatic-goo.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

theme-lab.tsx
import { ChromaticGoo } from './chromatic-goo';// The palette and two cards, with a demo that plays while it is on screen.// The colours are in themes.ts and the cards' content in cards.tsx: change them there.export default function ThemeLab() {  return <ChromaticGoo />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A theme switcher you play by hand. Grab a colour from the palette and drop it on a card: a drop bursts where it lands, opens into a wobbly ring and rolls across the card shedding bubbles, and each element takes the new colour the moment the wavefront reaches it.

  • Each card is themed by CSS custom properties: the icon, the figures and the primary button read an accent and a tint (themes.ts).
  • When a colour lands, the card holds two faces. Underneath, the old look, drained to grey at once; on top, the new look, clipped by a clip-path polygon that follows the ring’s edge frame by frame, so each element recolours as the wavefront crosses it.
  • The wave is drawn on a canvas over the card, only while it plays: a closed curve whose radius grows with time and wobbles on a few slow sine waves, with a white crest over a fringe of the new colour. The white fades as it travels, so the ring takes on the colour.
  • A soft glow trails the ring, drawn at a quarter of its size and stretched, which blurs it for almost nothing. Bubbles bulge out of the crest, pinch off as small rings, drift and fade.
  • Every frame depends only on the time since the drop, so the canvas and the clip always agree. Nothing runs at rest: the canvas gives its pixels back when the wave is done.
  • The stage, cards, text and controls are light-dark() tokens, so it follows the page’s color-scheme, or the colorScheme prop. The palette and the themes keep their colours exactly; on light only the figures and icons deepen the theme colour, so they stay legible. The wave reads its foam from the canvas’s CSS colour as it draws each frame.
PropTypeDefaultDescription
defaultStateGooState—Opens in this state instead of grey, with the demo off: each card’s colour, a colour picked and waiting for a card, a colour held over a card mid-drag, or a wave stopped part-way across one.
onChange(card: string, color: ColorId) => void—Called when the reader puts a colour on a card, with the card’s id (analytics or team) and the colour.
autoplaybooleantrueLet a ghost cursor drag colours onto the cards while the lab is visible and untouched.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

chromatic-goo.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type MouseEvent,  type PointerEvent,} from 'react';import { CARDS, CardFace } from './cards';import { SWATCHES, swatchOf, toRgb, type ColorId } from './themes';import { createWave, playWave, stillWave } from './wave';import { useAutoplay } from './use-autoplay';import styles from './chromatic-goo.module.css';/* * Chromatic goo: a theme switcher you play by hand. Grab a colour from the palette and drop it on * a card: it lands like a drop of liquid, a white blob that opens into a ring and rolls across the
chromatic-goo.module.css
/* Chromatic goo (chromatic-goo.tsx): a stage with a palette of colours and two cards. The   layout follows the component's own width (a container query): from 700px the palette stands on   the left of the cards side by side; narrower, it is a row above the cards, stacked.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). The palette's colours and the cards' themes   (themes.ts) are the same on both. */.root {  /* The stage and its edge (none on dark), and the cards on it; the cards' edge. */  --surface: #f2f2f5;  --edge: rgb(0 0 0 / 0.05);  --surface-2: #fff;  --line: rgb(0 0 0 / 0.08);  /* The titles; the cards' text, their figures' labels, and the palette's label. */  --ink: #141416;  --ink-muted: #66666e;  --ink-faint: #6c6c74;  --label: #6c6c74;  /* The secondary button, and its label; the primary's label, on the theme's colour. */  --secondary: #f0f0f3;  --secondary-ink: #4a4a51;  --accent-ink: #fff;
wave.ts
/* * The wave a colour makes as it lands on a card, drawn on a <canvas> laid over the card. * * The drop is a white blob that opens into a ring and rolls out across the card. The ring is a * closed curve round the drop point: a radius that grows with time (`ringRadius`), wobbled by a * few slow sines so its edge looks liquid, and bulging wherever a bubble forms (`edgeDistances`). * It is drawn as a white crest over a fringe of the new colour, with a band of the colour behind * it; the white fades as the ring travels, so the ring takes on the colour. The bubbles pinch off * the crest as small white rings, drift on and fade. * * A frame is a function of the time since the drop alone: nothing carries over from one frame to * the next, so a slow frame costs nothing, and the card's new face can be clipped to the very same * edge (`revealClip`): the colours change exactly where the wavefront is. * * The white is the foam: a CSS token (--foam), the canvas's own colour, read as each frame is * drawn, so it follows the stylesheet and its colour scheme. */export type Rgb = readonly [red: number, green: number, blue: number];/** One of the sines that wobble the ring's edge. */type Ripple = { lobes: number; phase: number; drift: number; weight: number };type Bubble = {
cards.tsx
import type { ReactNode, Ref } from 'react';import { themeStyle, type Theme } from './themes';import styles from './chromatic-goo.module.css';/* * The playground's two cards and what is on them. `CardFace` draws a card's content in a theme; * while a colour lands, the playground stacks two faces in the card: the old look underneath and * the new one on top, which the wave reveals. * * The card itself is a button (the place a colour drops), so a face holds phrasing content only: * spans, laid out as blocks by the CSS. */export type Card = {  id: string;  title: string;  text: string;  icon: ReactNode;  stats: readonly { value: string; label: string }[];  actions: readonly [primary: string, secondary: string];};function ChartIcon() {  return (
themes.ts
import type { CSSProperties } from 'react';/* * The palette's six colours, and how a theme reaches a card: as CSS custom properties on the * card's face. `--accent` colours the icon and the figures, `--accent-fill` the primary button; * the CSS mixes the icon tile's tint from `--accent`. */export type ColorId = 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'pink';export type Swatch = { id: ColorId; name: string; hex: string };export const SWATCHES: readonly Swatch[] = [  { id: 'red', name: 'Red', hex: '#e5533d' },  { id: 'orange', name: 'Orange', hex: '#ef923a' },  { id: 'green', name: 'Green', hex: '#5dbb6e' },  { id: 'blue', name: 'Blue', hex: '#5c9bf0' },  { id: 'purple', name: 'Purple', hex: '#7655ee' },  { id: 'pink', name: 'Pink', hex: '#d24fa6' },];export function swatchOf(id: ColorId): Swatch {  return SWATCHES.find((swatch) => swatch.id === id) ?? SWATCHES[0];}
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Colours and cards are labelled buttons: press a colour (Enter, Space or a tap), then a card; pressing the colour again puts it back, and Escape cancels a pick or a drag.
  • Theme changes are announced to screen readers: “Analytics is now orange.”
  • Only the colour dots take touch-action: none, so swiping anywhere else still scrolls the page.
  • Under reduced motion there is no wave and no demo: the new colour cross-fades in.