# Select Color Swatches — React Select Component

> A radio group of colour swatches where a ring springs from one swatch to the next and the colour name rolls over; arrow keys move the selection.

- Category: [Selects](https://pixelpapa.com/components/selects.md)
- Tags: select, color, swatches, radio, product
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/selects/select-color-swatches

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/select-color-swatches.json"
```

The file is written to `components/pixelpapa/select-color-swatches.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { Check } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

export type Swatch = { value: string; label: string; /** Any CSS colour. */ color: string; /** Use a dark tick on light colours. */ light?: boolean };

type ColorSwatchSelectProps = {
  options: Swatch[];
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  label: string;
  className?: string;
};

export function ColorSwatchSelect({ options, value, defaultValue, onChange, label, className }: ColorSwatchSelectProps) {
  const [inner, setInner] = useState(defaultValue ?? options[0]?.value);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const uid = useId();
  const current = value ?? inner;
  const index = Math.max(0, options.findIndex((o) => o.value === current));

  function choose(i: number) {
    const next = options[(i + options.length) % options.length];
    if (value === undefined) setInner(next.value);
    onChange?.(next.value);
    refs.current[(i + options.length) % options.length]?.focus();
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, number> = {
      ArrowRight: index + 1,
      ArrowDown: index + 1,
      ArrowLeft: index - 1,
      ArrowUp: index - 1,
      Home: 0,
      End: options.length - 1,
    };
    if (e.key in moves) {
      e.preventDefault();
      choose(moves[e.key]);
    }
  }

  return (
    <div className={className}>
      <p id={`${uid}-label`} className="flex items-center gap-1.5 text-sm">
        <span className="font-medium text-foreground">{label}</span>
        <span className="relative inline-flex h-5 overflow-hidden text-muted-foreground">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={options[index]?.label}
              initial={{ y: 14, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: -14, opacity: 0 }}
              transition={{ type: "spring", stiffness: 420, damping: 30 }}
              className="leading-5"
            >
              {options[index]?.label}
            </motion.span>
          </AnimatePresence>
        </span>
      </p>
      <div role="radiogroup" aria-labelledby={`${uid}-label`} onKeyDown={onKeyDown} className="mt-2.5 flex flex-wrap gap-3">
        {options.map((o, i) => {
          const on = i === index;
          return (
            <motion.button
              key={o.value}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={on}
              aria-label={o.label}
              tabIndex={on ? 0 : -1}
              whileHover={{ scale: 1.1 }}
              whileTap={{ scale: 0.9 }}
              onClick={() => choose(i)}
              className="relative flex size-9 items-center justify-center rounded-full outline-offset-4"
              style={{ backgroundColor: o.color }}
            >
              <span className="absolute inset-0 rounded-full ring-1 ring-foreground/10 ring-inset" />
              {on && (
                <motion.span
                  layoutId={`${uid}-ring`}
                  transition={{ type: "spring", stiffness: 420, damping: 28 }}
                  className="absolute -inset-1.5 rounded-full border-2 border-foreground"
                />
              )}
              <motion.span
                initial={false}
                animate={{ scale: on ? 1 : 0, rotate: on ? 0 : -60 }}
                transition={{ type: "spring", stiffness: 520, damping: 24 }}
                className={o.light ? "text-black/80" : "text-white"}
              >
                <Check className="size-4" strokeWidth={3} />
              </motion.span>
            </motion.button>
          );
        })}
      </div>
    </div>
  );
}

const colours: Swatch[] = [
  { value: "ink", label: "Ink", color: "oklch(0.32 0.04 265)" },
  { value: "moss", label: "Moss", color: "oklch(0.56 0.1 150)" },
  { value: "clay", label: "Clay", color: "oklch(0.64 0.14 45)" },
  { value: "butter", label: "Butter", color: "oklch(0.89 0.11 95)", light: true },
  { value: "lilac", label: "Lilac", color: "oklch(0.76 0.1 300)", light: true },
  { value: "tide", label: "Tide", color: "oklch(0.6 0.13 235)" },
];

export default function SelectColorSwatchesDemo() {
  const [colour, setColour] = useState("moss");
  const active = colours.find((c) => c.value === colour) ?? colours[0];

  return (
    <div className="flex w-full max-w-xl flex-col items-center gap-7 rounded-2xl border border-border bg-card p-6 shadow-sm sm:flex-row">
      {/* A notebook drawn with CSS; its cover follows the selection. */}
      <div aria-hidden className="flex h-56 w-full shrink-0 items-center justify-center rounded-xl bg-muted sm:w-52">
        <div
          className="relative h-40 w-28 rounded-r-xl rounded-l-[4px] shadow-xl shadow-black/20 transition-colors duration-500"
          style={{ backgroundColor: active.color }}
        >
          <span className="absolute inset-y-0 left-2.5 w-px bg-black/15" />
          <span className="absolute inset-y-0 right-4 w-1.5 bg-black/20" />
          <span className="absolute top-6 left-6 h-8 w-12 rounded-[3px] bg-white/85" />
          <span className="absolute inset-0 rounded-r-xl bg-linear-to-br from-white/20 to-transparent" />
        </div>
      </div>
      <div className="w-full min-w-0">
        <p className="text-xs font-medium tracking-wide text-primary uppercase">Field notebook</p>
        <h3 className="mt-1 text-xl font-semibold tracking-tight text-card-foreground">Dot grid, 144 pages</h3>
        <p className="mt-1 text-sm text-muted-foreground">Lay-flat binding with an elastic closure.</p>
        <ColorSwatchSelect className="mt-5" label="Cover" options={colours} value={colour} onChange={setColour} />
        <button
          type="button"
          className="mt-6 h-10 w-full rounded-xl bg-primary text-sm font-medium text-primary-foreground"
        >
          Add to bag · $24
        </button>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Select Color Swatches" component from PixelPapa UI to this project.

What it is: A radio group of colour swatches where a ring springs from one swatch to the next and the colour name rolls over; arrow keys move the selection.
Category: Selects. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/select-color-swatches.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/select-color-swatches.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/select-color-swatches" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More selects

- [Select Animated Dropdown](https://pixelpapa.com/components/selects/select-animated-dropdown.md): A custom select whose menu springs open with staggered rows and a highlight that glides between options; arrow keys, Home/End, Enter, Escape and typeahead all work.
- [Select Avatar People](https://pixelpapa.com/components/selects/select-avatar-people.md) (Pro): An assignee picker with gradient initials avatars and presence dots; the chosen avatar pops into the trigger and the list filters from a built-in search.
- [Select Cascading Menu](https://pixelpapa.com/components/selects/select-cascading-menu.md) (Pro): A two-level select: pick a group on the left and its children slide in on the right; arrow keys move within a column and Left/Right step between levels.
- [Select Combobox Search](https://pixelpapa.com/components/selects/select-combobox-search.md) (Pro): A searchable combobox that filters as you type, bolds the matching letters, slides a highlight under the active row and restores the selection on Escape.
- [Select Multi Chips](https://pixelpapa.com/components/selects/select-multi-chips.md): A multi-select whose picks become chips that spring into the field and reflow when removed; type to filter, Enter toggles, Backspace removes the last chip.
