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
Colour
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.

Size
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.

Finish
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

PropTypeDefaultWhat it does
labelstringnoneNames the choice, such as "Colour". The current option is shown after it.
options{ value: string; label: string; color?: string; unavailable?: boolean }[]noneThe variants. color is any CSS colour and is the only literal colour the part uses.
valuestringnoneThe selected value, when controlled.
defaultValuestringnoneThe first selected value, when uncontrolled.
onValueChange(value: string) => voidnoneCalled with the value of an available option when it is chosen.
namestringnoneForm 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.
...restComponentProps<"fieldset">noneEvery native fieldset attribute, including disabled and form.

Anatomy

root.eq-swatch-pickerThe fieldset. Kind is on data-kind.
legend.eq-swatch-picker-legend
current.eq-swatch-picker-currentThe chosen option's label, such as ": Forest".
options.eq-swatch-picker-options
option.eq-swatch-picker-optionA label per option; sold out sets data-unavailable.
input.eq-swatch-picker-inputThe native radio, transparent over the swatch.
swatch.eq-swatch-picker-swatchThe colour fill with its ink ring, or the text chip.
tick.eq-swatch-picker-tickDrawn on a small raised disc so it reads on any colour.
hidden text.eq-swatch-picker-hiddenColour names and "sold out" for screen readers.

Keyboard

TabMoves into the group, onto the selected option, and out again.
ArrowRightArrowDownMoves to the next option and selects it if it is available.
ArrowLeftArrowUpMoves to the previous option and selects it if it is available.
SpaceSelects 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index