Chromatic goo
AdvancedDrag 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.tsxDrag 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
selectapplies 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:
npx shadcn@latest add https://brunodelorence.com/r/chromatic-goo.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultState | GooState | — | 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. |
autoplay | boolean | true | Let 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. |
className | string | — | 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.
// 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 (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;/* * 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 = {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 (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 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.