Color Picker Swatch
A ring grows around the chosen swatch and travels to the next one, while the preview eases to the new color.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import {
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Color Picker Swatch
*
* A ring grows around the chosen swatch and travels to the next one as
* the choice changes, while the preview above eases from the old color to
* the new one instead of cutting to it.
*
* Self-contained: depends only on `react` and `motion`. Neutral surfaces
* are mixed from the inherited text color; the swatches themselves stay
* literal, because they are the content rather than chrome.
* Works with zero props; tune via `variant`, `swatches`, `defaultColor`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type Swatch = { name: string; value: string };
export type ColorPickerSwatchProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The palette offered. */
swatches?: readonly Swatch[];
/** Hex of the swatch selected on first render. */
defaultColor?: string;
/** Heading above the palette. */
label?: string;
/** Text shown inside the preview. */
previewLabel?: string;
/** Fires with the chosen swatch. */
onColorChange?: (swatch: Swatch) => void;
};
type VariantConfig = {
/** Spring that carries the ring from one swatch to the next. */
ring: { type: "spring"; stiffness: number; damping: number };
/** Milliseconds for the preview surface to reach the new color. */
blend: number;
/** Seconds for the hex readout to fade in. */
fade: number;
};
// Quality rule: color is not motion. The preview crosses to its new hue on
// a CSS transition so the animation loop stays transform-and-opacity only,
// and the ring — the one thing that actually moves — rides a spring at or
// above a 0.8 damping ratio, so it arrives on the swatch instead of
// hunting around it. The hex readout is text: it fades, never scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost instant. For a settings row where color is a detail.
subtle: {
ring: { type: "spring", stiffness: 640, damping: 50 },
blend: 160,
fade: 0.12,
},
// The ring is easy to follow between swatches. All-purpose.
default: {
ring: { type: "spring", stiffness: 520, damping: 42 },
blend: 240,
fade: 0.16,
},
// A longer blend and a looser ring, for a palette that is the point of
// the screen rather than one field on it.
playful: {
ring: { type: "spring", stiffness: 420, damping: 35 },
blend: 320,
fade: 0.2,
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_SWATCHES: readonly Swatch[] = [
{ name: "Indigo", value: "#5B5BD6" },
{ name: "Blue", value: "#3B82F6" },
{ name: "Teal", value: "#14B8A6" },
{ name: "Green", value: "#22A06B" },
{ name: "Amber", value: "#E0A03C" },
{ name: "Red", value: "#E5484D" },
{ name: "Pink", value: "#DB4F9E" },
{ name: "Slate", value: "#6B7280" },
];
export default function ColorPickerSwatch({
variant = "default",
swatches = DEFAULT_SWATCHES,
defaultColor = "#5B5BD6",
label = "Label color",
previewLabel = "Roadmap",
onColorChange,
}: ColorPickerSwatchProps) {
const initialIndex = Math.max(
swatches.findIndex((swatch) => swatch.value === defaultColor),
0
);
const [index, setIndex] = useState(initialIndex);
const [focused, setFocused] = useState(false);
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
const ringId = useId();
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const current = swatches[index] ?? swatches[0];
const select = (next: number, moveFocus: boolean) => {
const clamped = (next + swatches.length) % swatches.length;
setIndex(clamped);
onColorChange?.(swatches[clamped]);
if (moveFocus) buttonsRef.current[clamped]?.focus();
};
const onKeyDown = (event: ReactKeyboardEvent) => {
// Selection follows focus, the way a radio group behaves — which is
// also what makes the ring walk the palette under the arrow keys.
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
select(index + 1, true);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
event.preventDefault();
select(index - 1, true);
} else if (event.key === "Home") {
event.preventDefault();
select(0, true);
} else if (event.key === "End") {
event.preventDefault();
select(swatches.length - 1, true);
}
};
return (
<div style={{ width: 292, color: "inherit" }}>
{/* Preview. Background, border and dot all cross to the new color on
the same CSS transition, so the surface eases rather than cuts. */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "13px 14px",
borderRadius: 13,
border: `1px solid color-mix(in srgb, ${current.value} 45%, transparent)`,
background: `color-mix(in srgb, ${current.value} 16%, transparent)`,
transition: `background-color ${cfg.blend}ms ease-out, border-color ${cfg.blend}ms ease-out`,
}}
>
<span
aria-hidden
style={{
width: 13,
height: 13,
flex: "0 0 auto",
borderRadius: "50%",
background: current.value,
transition: `background-color ${cfg.blend}ms ease-out`,
}}
/>
<span style={{ flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600 }}>
{previewLabel}
</span>
{/* Re-keyed on the value so each hex fades in on its own. Opacity
only: a number that scales as it changes looks unstable. */}
<motion.span
key={current.value}
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ duration: reduceMotion ? 0 : cfg.fade, ease: "easeOut" }}
style={{
fontSize: 11.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.3,
}}
>
{current.value.toUpperCase()}
</motion.span>
</div>
<div
style={{
marginTop: 16,
marginBottom: 10,
fontSize: 11.5,
fontWeight: 650,
letterSpacing: 0.3,
opacity: 0.55,
}}
>
{label.toUpperCase()}
</div>
<div
role="radiogroup"
aria-label={label}
onKeyDown={onKeyDown}
style={{ display: "flex", gap: 10 }}
>
{swatches.map((swatch, swatchIndex) => {
const selected = swatchIndex === index;
return (
<button
key={swatch.value}
ref={(node) => {
buttonsRef.current[swatchIndex] = node;
}}
type="button"
role="radio"
aria-checked={selected}
aria-label={swatch.name}
// Roving tabindex: one stop for the whole palette, arrows
// inside it.
tabIndex={selected ? 0 : -1}
onClick={() => select(swatchIndex, false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={{
position: "relative",
width: 26,
height: 26,
flex: "0 0 auto",
padding: 0,
border: `1px solid ${tone(16)}`,
borderRadius: "50%",
background: swatch.value,
cursor: "pointer",
outline: "none",
}}
>
{selected && (
<motion.span
aria-hidden
// One ring element shared across the palette: it grows
// into place on the first render and travels between
// swatches after that.
layoutId={`${ringId}-swatch-ring`}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.62 }
}
animate={{ opacity: 1, scale: 1 }}
transition={reduceMotion ? { duration: 0 } : cfg.ring}
style={{
position: "absolute",
inset: -5,
borderRadius: "50%",
border: `2px solid ${swatch.value}`,
boxShadow: focused
? `0 0 0 3px color-mix(in srgb, ${swatch.value} 26%, transparent)`
: "0 0 0 0 transparent",
// Focus is painted on the ring so the keyboard state
// is visible without a second outline fighting it.
transition: "box-shadow 150ms ease-out",
}}
/>
)}
</button>
);
})}
</div>
<div
role="status"
aria-live="polite"
style={{ marginTop: 12, fontSize: 11.5, opacity: 0.5 }}
>
{current.name} selected
</div>
</div>
);
}About this pattern
A palette answers two questions at once: which one is chosen, and what choosing it does. So a single ring is shared across the whole row — it grows into place on the first render and then travels between swatches on a damped spring, which makes the change legible even when the two colors are close neighbours. The preview above crosses to the new hue on a CSS transition rather than an animation frame loop, because color is a state change and not motion; easing it over a quarter of a second is what stops a palette from strobing while someone walks it with the arrow keys. Selection follows focus, so the keyboard produces exactly the same motion as the pointer.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
A swatch row where the active color is marked by a surrounding ring.
Related patterns
- Currency Input FormatSeparators fade in where they belong as the amount groups itself, and the caret holds its place.
- Date Picker OpenThe calendar unfolds from the field and the days arrive in a wave, today already marked.
- Field Reorder DragA row lifts onto a shadow while the rows around it part to make room — by pointer, and by arrow key from a grabbed state.