All patterns

Color Picker Swatch

A ring grows around the chosen swatch and travels to the next one, while the preview eases to the new color.

formsenergeticpremiuminteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

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.

280 lines · react + motion only
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.

Label or tag colorTheme accent pickerCalendar category colorBrand customisation panel

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    A swatch row where the active color is marked by a surrounding ring.

Related patterns