Forms
PaintMixer
A colour field on an artist's palette. Each press drops a dab into the well, it swirls in, and the colour is what real pigment would give: yellow and blue make green, not grey. Undo a dab or clean the well.
Download
- React
- Tailwind CSS v4
- motion
components/ui/paint-mixer.tsx
"use client";
import { useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
export type Paint = {
name: string;
color: string;
/** How strongly it tints a mix. White needs a lot, black very little. */
power?: number;
};
type PaintMixerProps = {
/** Five paints work best. */
paints?: Paint[];
/** Indexes of paints already in the well at start. */
defaultDabs?: number[];
/** Form field name. Sends the hex colour. */
name?: string;
/** Called with the new hex colour, or "" when the well is clean. */
onChange?: (hex: string) => void;
/** Read out by screen readers. */
label?: string;
/** Set the width here. Everything scales with it. */
className?: string;
};
type Drop = { id: number; x: number; y: number; color: string; turn: number };
const PAINTS: Paint[] = [
{ name: "Madder Red", color: "#d62a54", power: 1 },
{ name: "Cadmium Yellow", color: "#f8c82a", power: 1.6 },
{ name: "Ultramarine", color: "#2f55b8", power: 1 },
{ name: "Titanium White", color: "#f8f6f0", power: 11 },
{ name: "Ivory Black", color: "#1a1712", power: 0.4 },
];
// Paint seats on the 520 x 330 palette, and the mixing well.
const SEATS = [
[112, 92],
[206, 52],
[312, 50],
[404, 84],
[462, 160],
];
const WELL = { x: 262, y: 178, r: 74 };
const BLOBS = ["58% 42% 55% 45% / 52% 60% 40% 48%", "46% 54% 42% 58% / 60% 44% 56% 40%", "54% 46% 60% 40% / 44% 56% 44% 56%", "42% 58% 48% 52% / 54% 42% 58% 46%", "56% 44% 46% 54% / 46% 58% 42% 54%"];
const SPIRAL = (() => {
let d = "";
for (let t = 0; t <= 1; t += 0.02) {
const a = t * Math.PI * 5.2;
const r = 8 + t * 54;
d += `${t ? "L" : "M"}${(WELL.x + Math.cos(a) * r).toFixed(1)} ${(WELL.y + Math.sin(a) * r).toFixed(1)}`;
}
return d;
})();
const channels = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
const toByte = (c: number) => Math.round(255 * (c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055));
/* Kubelka-Munk, the sum real pigment follows: each paint absorbs and scatters light, so yellow and blue give green, not grey. */
function mix(dabs: number[], paints: Paint[]) {
if (!dabs.length) return "";
const out = [0, 1, 2].map((ch) => {
let k = 0;
let s = 0;
for (const i of dabs) {
const r = Math.max(channels(paints[i].color)[ch], 0.002);
const power = paints[i].power ?? 1;
k += (power * (1 - r) ** 2) / (2 * r);
s += power;
}
const q = k / s;
return 1 + q - Math.sqrt(q * q + 2 * q);
});
return `#${out.map((c) => toByte(c).toString(16).padStart(2, "0")).join("")}`;
}
/* PaintMixer: a colour field on an artist's palette. Each press drops a dab of real paint into the well, it swirls in, and the colour you get is what the pigments would give. */
export function PaintMixer({ paints = PAINTS, defaultDabs = [0, 1, 1], name, onChange, label = "Mix a colour", className = "w-full max-w-lg" }: PaintMixerProps) {
const reduce = useReducedMotion();
const next = useRef(0);
const [dabs, setDabs] = useState(defaultDabs);
const [drops, setDrops] = useState<Drop[]>([]);
const hex = useMemo(() => mix(dabs, paints), [dabs, paints]);
const update = (list: number[]) => {
setDabs(list);
onChange?.(mix(list, paints));
};
const add = (i: number) => {
update([...dabs, i]);
if (reduce) return;
next.current += 1;
const [x, y] = SEATS[i % SEATS.length];
setDrops((all) => [...all, { id: next.current, x, y, color: paints[i].color, turn: next.current * 137 }]);
};
const small = "rounded-[0.7cqw] bg-[#f3e6c8] px-[1.8cqw] py-[0.6cqw] font-mono text-[2.9cqw] text-[#4c3b22] shadow-[0_0.3cqw_0_#a07c4e] transition-transform active:translate-y-[0.3cqw] active:shadow-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white disabled:opacity-50 disabled:shadow-none";
return (
<div role="group" aria-label={label} className={`relative @container aspect-[520/330] ${className}`}>
<svg aria-hidden viewBox="0 0 520 330" className="absolute inset-0 size-full drop-shadow-[0_1.4cqw_1.4cqw_rgba(0,0,0,0.45)]">
<defs>
<linearGradient id="pm-wood" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#d9a767" />
<stop offset="0.55" stopColor="#c08a4d" />
<stop offset="1" stopColor="#a4713a" />
</linearGradient>
<clipPath id="pm-well">
<circle cx={WELL.x} cy={WELL.y} r={WELL.r} />
</clipPath>
</defs>
<path
fillRule="evenodd"
fill="url(#pm-wood)"
d="M40 170C30 70 140 14 270 14C400 14 510 70 510 165C510 230 470 300 380 306C320 310 300 280 245 285C190 290 170 315 110 312C60 310 46 240 40 170ZM105 212a25 25 0 1 0 .1 0Z"
/>
<path d="M70 120C120 60 220 34 300 38M60 170C130 90 260 66 420 96M130 290C200 246 300 262 440 250" fill="none" stroke="#8a5a2c" strokeOpacity="0.28" strokeWidth="1.4" />
<path d="M105 212a25 25 0 1 0 .1 0" fill="none" stroke="#7d5228" strokeWidth="2" />
<circle cx={WELL.x} cy={WELL.y} r={WELL.r + 4} fill="#a87a45" />
<circle cx={WELL.x} cy={WELL.y} r={WELL.r} fill="#cfa46b" />
<motion.circle
cx={WELL.x}
cy={WELL.y}
initial={false}
animate={{ fill: hex || "#cfa46b", r: hex ? WELL.r - 5 : 0 }}
transition={reduce ? { duration: 0 } : { fill: { duration: 1, delay: 0.3 }, r: { type: "spring", stiffness: 180, damping: 13 } }}
/>
<g clipPath="url(#pm-well)">
{drops.map((d) => (
<g key={d.id}>
<motion.circle
r="9"
fill={d.color}
initial={{ cx: d.x, cy: d.y, opacity: 1 }}
animate={{ cx: WELL.x, cy: WELL.y, opacity: [1, 1, 0] }}
transition={{ duration: 0.34, ease: "easeIn" }}
/>
<motion.path
d={SPIRAL}
fill="none"
stroke={d.color}
strokeWidth="9"
strokeLinecap="round"
transform={`rotate(${d.turn} ${WELL.x} ${WELL.y})`}
initial={{ pathLength: 0, opacity: 1 }}
animate={{ pathLength: 1, opacity: 0 }}
transition={{ pathLength: { duration: 0.9, delay: 0.25, ease: "easeOut" }, opacity: { duration: 0.8, delay: 0.75 } }}
onAnimationComplete={() => setDrops((all) => all.filter((x) => x.id !== d.id))}
/>
</g>
))}
</g>
{hex && <ellipse cx={WELL.x - 26} cy={WELL.y - 34} rx="20" ry="9" fill="#fff" fillOpacity="0.28" transform={`rotate(-24 ${WELL.x - 26} ${WELL.y - 34})`} />}
</svg>
{paints.slice(0, SEATS.length).map((paint, i) => (
<motion.button
key={paint.name}
type="button"
onClick={() => add(i)}
aria-label={`Add ${paint.name}`}
title={paint.name}
whileTap={reduce ? undefined : { scale: 0.86 }}
className="absolute size-[12.5cqw] -translate-x-1/2 -translate-y-1/2 shadow-[inset_-0.6cqw_-0.8cqw_1cqw_rgba(0,0,0,0.3),inset_0.5cqw_0.6cqw_0.8cqw_rgba(255,255,255,0.5),0_0.4cqw_0.6cqw_rgba(0,0,0,0.3)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
style={{ left: `${(SEATS[i][0] / 520) * 100}%`, top: `${(SEATS[i][1] / 330) * 100}%`, background: paint.color, borderRadius: BLOBS[i % BLOBS.length] }}
/>
))}
<div className="absolute top-[69%] right-[5%] flex w-[29%] flex-col items-end gap-[1.2cqw]">
<output className="w-full -rotate-2 bg-[#f2e8b8]/95 px-[1.6cqw] py-[0.8cqw] text-center font-mono text-[3.4cqw] font-semibold text-[#3a3122] tabular-nums shadow-[0_0.3cqw_0.5cqw_rgba(0,0,0,0.3)]">{hex || "clean"}</output>
<div className="flex gap-[1cqw]">
<button type="button" onClick={() => update(dabs.slice(0, -1))} disabled={!dabs.length} className={small}>
Undo
</button>
<button type="button" onClick={() => update([])} disabled={!dabs.length} className={small}>
Clean
</button>
</div>
</div>
{name && <input type="hidden" name={name} value={hex} />}
<p aria-live="polite" className="sr-only">
{hex ? `Colour ${hex}, ${dabs.length} dabs` : "Well is clean"}
</p>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/paint-mixer.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/paint-mixer-demo.tsx
import { PaintMixer } from "@/components/ui/paint-mixer"; export default function PaintMixerExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_40%,#39414a,#14181d_80%)] p-2"> <PaintMixer className="w-[min(150cqh,94cqw,580px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| paints | { name, color, power? }[] | 5 paints | Five work best. power is tinting strength. |
| defaultDabs | number[] | [0, 1, 1] | Paint indexes already in the well. |
| name | string | - | Form field name. Sends the hex colour. |
| onChange | (hex: string) => void | - | Called with the new hex colour, or "" when clean. |
| label | string | "Mix a colour" | Read out by screen readers. |
| className | string | "w-full max-w-lg" | Set the width here. Everything scales with it. |