Commerce
Swatch picker
Chooses a product variant from colour swatches or text options such as sizes, as a native radio group.
- Built on
<input type="radio"><fieldset><legend>- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.6 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { SwatchPicker } from "@/components/equip/swatch-picker";
export default function Demo() {
return (
<SwatchPicker
label="Colour"
defaultValue="forest"
options={[
{ value: "forest", label: "Forest", color: "#2f5d46" },
{ value: "clay", label: "Clay", color: "#b8643f" },
{ value: "oat", label: "Oat", color: "#e6dcc8" },
{ value: "ink", label: "Ink", color: "#1d2433", unavailable: true },
]}
/>
);
}Your versionchange its options in Look, This part
Examples
Text options
Sold-out sizes stay in the tab order and are read as sold out, so nobody wonders where size L went.
Code
import { SwatchPicker } from "@/components/equip/swatch-picker";
export default function Example() {
return (
<SwatchPicker
kind="text"
label="Size"
name="size"
defaultValue="m"
options={[
{ value: "xs", label: "XS" },
{ value: "s", label: "S" },
{ value: "m", label: "M" },
{ value: "l", label: "L", unavailable: true },
{ value: "xl", label: "XL" },
]}
/>
);
}Colour and size together
Each picker is its own native radio group; the form receives finish and capacity.
Code
"use client";
import { useState } from "react";
import { SwatchPicker } from "@/components/equip/swatch-picker";
const finishes = [
{ value: "graphite", label: "Graphite", color: "#3b3d40" },
{ value: "silver", label: "Silver", color: "#d9dadc" },
{ value: "white", label: "White", color: "#ffffff" },
];
export default function Example() {
const [finish, setFinish] = useState("silver");
return (
<form style={{ display: "grid", gap: 16 }}>
<SwatchPicker label="Finish" name="finish" options={finishes} value={finish} onValueChange={setFinish} />
<SwatchPicker
kind="text"
label="Capacity"
name="capacity"
defaultValue="512"
options={[
{ value: "256", label: "256 GB" },
{ value: "512", label: "512 GB" },
{ value: "1024", label: "1 TB", unavailable: true },
]}
/>
</form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the choice, such as "Colour". The current option is shown after it. |
options | { value: string; label: string; color?: string; unavailable?: boolean }[] | none | The variants. color is any CSS colour and is the only literal colour the part uses. |
value | string | none | The selected value, when controlled. |
defaultValue | string | none | The first selected value, when uncontrolled. |
onValueChange | (value: string) => void | none | Called with the value of an available option when it is chosen. |
name | string | none | Form field name. Generated when left out so the radios still group. |
kind | "color" | "text" | "color" | Round colour swatches or text chips. "colour" is accepted too. |
...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute, including disabled and form. |
Anatomy
| root | .eq-swatch-picker | The fieldset. Kind is on data-kind. |
|---|---|---|
| legend | .eq-swatch-picker-legend | |
| current | .eq-swatch-picker-current | The chosen option's label, such as ": Forest". |
| options | .eq-swatch-picker-options | |
| option | .eq-swatch-picker-option | A label per option; sold out sets data-unavailable. |
| input | .eq-swatch-picker-input | The native radio, transparent over the swatch. |
| swatch | .eq-swatch-picker-swatch | The colour fill with its ink ring, or the text chip. |
| tick | .eq-swatch-picker-tick | Drawn on a small raised disc so it reads on any colour. |
| hidden text | .eq-swatch-picker-hidden | Colour names and "sold out" for screen readers. |
Keyboard
| Tab | Moves into the group, onto the selected option, and out again. |
|---|---|
| ArrowRightArrowDown | Moves to the next option and selects it if it is available. |
| ArrowLeftArrowUp | Moves to the previous option and selects it if it is available. |
| Space | Selects the focused option if it is available. |
Notes
- Sold-out options stay focusable, carry aria-disabled and are read as "sold out", so they can be discovered rather than silently skipped.
- Arrowing onto a sold-out option moves focus but keeps the previous choice.
- The group's accessible name is the label alone; the chosen option is announced by the radio itself.
- Each colour swatch shows its name as a tooltip on hover and is named by it for assistive tech.
- Swatch fills get a hairline edge, so white on a white page and black on a dark one still read as a swatch.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/swatch-picker.tsx108 lines
"use client";
import { useId, useState, type ComponentProps, type CSSProperties } from "react";
import "./swatch-picker.css";
export type SwatchOption = {
value: string;
label: string;
/** Any CSS colour. Only used when kind is "color". */
color?: string;
/** Sold out: still focusable and announced, but it cannot be chosen. */
unavailable?: boolean;
};
export type SwatchPickerProps = Omit<ComponentProps<"fieldset">, "defaultValue" | "onChange" | "children"> & {
/** Names the choice, such as "Colour" or "Size". */
label: string;
options: SwatchOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Form field name. One is generated when left out, so the radios still group. */
name?: string;
/** "colour" is still accepted and means "color". */
kind?: "color" | "text" | "colour";
};
export function SwatchPicker({
label,
options,
value,
defaultValue,
onValueChange,
name,
kind = "color",
className,
...rest
}: SwatchPickerProps) {
const autoName = useId();
const [inner, setInner] = useState(defaultValue);
const selected = value ?? inner;
const current = options.find((o) => o.value === selected);
const choose = (option: SwatchOption) => {
if (option.unavailable) return;
if (value === undefined) setInner(option.value);
onValueChange?.(option.value);
};
return (
<fieldset
{...rest}
className={["eq-swatch-picker", className].filter(Boolean).join(" ")}
data-kind={kind === "text" ? "text" : "color"}
>
<legend className="eq-swatch-picker-legend">
{label}
{current && (
<span className="eq-swatch-picker-current" aria-hidden="true">
: {current.label}
</span>
)}
</legend>
<div className="eq-swatch-picker-options">
{options.map((option) => (
<label
key={option.value}
className="eq-swatch-picker-option"
data-unavailable={option.unavailable || undefined}
style={option.color ? ({ "--_swatch": option.color } as CSSProperties) : undefined}
title={kind !== "text" ? option.label : undefined}
>
<input
className="eq-swatch-picker-input"
type="radio"
name={name ?? autoName}
value={option.value}
checked={option.value === selected}
aria-disabled={option.unavailable || undefined}
onChange={() => choose(option)}
/>
<span className="eq-swatch-picker-swatch" aria-hidden={kind !== "text" || undefined}>
{kind !== "text" ? (
<span className="eq-swatch-picker-tick">
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6.25l2 2 4-4.5" pathLength={1} />
</svg>
</span>
) : (
option.label
)}
</span>
{kind !== "text" && <span className="eq-swatch-picker-hidden">{option.label}</span>}
{option.unavailable && <span className="eq-swatch-picker-hidden">, sold out</span>}
</label>
))}
</div>
</fieldset>
);
}components/equip/swatch-picker.css221 lines
@layer equip {
.eq-swatch-picker {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-swatch-picker:disabled {
opacity: 0.45;
}
.eq-swatch-picker:disabled .eq-swatch-picker-option {
cursor: not-allowed;
}
.eq-swatch-picker-legend {
margin-bottom: var(--eq-space-2);
padding: 0;
font-weight: var(--eq-weight-strong);
}
.eq-swatch-picker-current {
color: var(--eq-ink-2);
font-weight: var(--eq-weight);
}
.eq-swatch-picker-options {
display: flex;
flex-wrap: wrap;
gap: var(--eq-space-2);
}
.eq-swatch-picker-option {
position: relative;
display: inline-grid;
cursor: pointer;
}
.eq-swatch-picker-option[data-unavailable] {
cursor: not-allowed;
}
/* The native radio covers the swatch, so it takes the click, focus and arrow keys. */
.eq-swatch-picker-input {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.eq-swatch-picker-swatch {
position: relative;
display: grid;
place-items: center;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-swatch-picker-option:active:not([data-unavailable]) .eq-swatch-picker-swatch {
scale: var(--eq-press);
}
.eq-swatch-picker-input:focus-visible + .eq-swatch-picker-swatch {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* Colour: an ink ring, a clear gap, then the fill. */
.eq-swatch-picker[data-kind="color"] .eq-swatch-picker-swatch {
width: 32px;
height: 32px;
padding: 2px;
border: 2px solid transparent;
border-radius: var(--eq-radius-full);
background: var(--_swatch, var(--eq-tint-strong)) content-box;
}
/* A hairline inside the fill, so white on white and black on black still have an edge. */
.eq-swatch-picker[data-kind="color"] .eq-swatch-picker-swatch::before {
content: "";
position: absolute;
inset: 2px;
border-radius: inherit;
box-shadow: inset 0 0 0 1px var(--eq-line);
}
.eq-swatch-picker[data-kind="color"] .eq-swatch-picker-input:checked + .eq-swatch-picker-swatch {
border-color: var(--eq-ink);
}
@media (hover: hover) {
.eq-swatch-picker[data-kind="color"]
.eq-swatch-picker-option:hover
.eq-swatch-picker-input:not(:checked)
+ .eq-swatch-picker-swatch {
border-color: var(--eq-line-strong);
}
.eq-swatch-picker[data-kind="text"]
.eq-swatch-picker-option:not([data-unavailable]):hover
.eq-swatch-picker-input:not(:checked)
+ .eq-swatch-picker-swatch {
background: var(--eq-tint);
}
}
/* The tick sits on its own small disc so it reads on any colour. */
.eq-swatch-picker-tick {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: var(--eq-radius-full);
background: var(--eq-raised);
color: var(--eq-ink);
scale: 0;
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-swatch-picker-tick svg {
width: 12px;
height: 12px;
}
.eq-swatch-picker-tick path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
}
.eq-swatch-picker-input:checked + .eq-swatch-picker-swatch .eq-swatch-picker-tick {
scale: 1;
transition: scale var(--eq-dur-move) var(--eq-spring);
}
.eq-swatch-picker-input:checked + .eq-swatch-picker-swatch .eq-swatch-picker-tick path {
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
/* Text: a chip per option. */
.eq-swatch-picker[data-kind="text"] .eq-swatch-picker-swatch {
min-width: var(--eq-h-m);
height: var(--eq-h-m);
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
font-weight: var(--eq-weight-strong);
white-space: nowrap;
}
.eq-swatch-picker[data-kind="text"] .eq-swatch-picker-input:checked + .eq-swatch-picker-swatch {
border-color: var(--eq-solid);
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
/* Sold out: a diagonal strike, and the option stays reachable so it can be heard. */
.eq-swatch-picker-option[data-unavailable] .eq-swatch-picker-swatch::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
to top right,
transparent calc(50% - 2px),
var(--_strike-edge) calc(50% - 2px),
var(--_strike-edge) calc(50% - 0.75px),
var(--_strike) calc(50% - 0.75px),
var(--_strike) calc(50% + 0.75px),
var(--_strike-edge) calc(50% + 0.75px),
var(--_strike-edge) calc(50% + 2px),
transparent calc(50% + 2px)
);
}
.eq-swatch-picker[data-kind="color"] .eq-swatch-picker-option[data-unavailable] .eq-swatch-picker-swatch {
--_strike: var(--eq-ink);
--_strike-edge: var(--eq-raised);
background-color: transparent;
background-image: linear-gradient(
color-mix(in srgb, var(--_swatch, var(--eq-tint-strong)) 45%, transparent),
color-mix(in srgb, var(--_swatch, var(--eq-tint-strong)) 45%, transparent)
);
background-clip: content-box;
}
.eq-swatch-picker[data-kind="color"] .eq-swatch-picker-option[data-unavailable] .eq-swatch-picker-swatch::after {
inset: 2px;
}
.eq-swatch-picker[data-kind="text"] .eq-swatch-picker-option[data-unavailable] .eq-swatch-picker-swatch {
--_strike: var(--eq-line-strong);
--_strike-edge: transparent;
border-style: dashed;
color: var(--eq-ink-3);
}
.eq-swatch-picker-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}