VariantPicker

Choose one variant of a product, such as a size, a colour or a material: a radio group of chips or colour swatches, or the system Select for a long list.

Commerce VariantPicker.jsx VariantPicker.d.ts VariantPicker.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

VariantPicker is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { VariantPicker } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Choose one variant of a product, such as a size, a colour or a material: a radio group of chips or colour swatches, or the system Select for a long list.

Use it when

  • A product comes in options and the customer picks one before adding it to the cart.
  • Some options are out of stock but should stay visible, so people know they exist.
  • Colours are best shown as colours: variant="swatches".

Don't use it when

  • More than one can be chosen, such as toppings or add-ons. Use CheckboxGroup.
  • It is a filter on a product list, which usually allows several values. Use Tags or a CheckboxGroup.
  • The choice is not about a product, such as a plan or a setting. Use RadioGroup.

Example

const [size, setSize] = React.useState();
<VariantPicker
  label="Size"
  value={size}
  onChange={setSize}
  options={[
    { value: "s", label: "S" },
    { value: "m", label: "M" },
    { value: "l", label: "L", note: "Low stock" },
    { value: "xl", label: "XL", disabled: true },
  ]}
/>

<VariantPicker
  label="Colour"
  variant="swatches"
  value={colour}
  onChange={setColour}
  options={[
    { value: "fern", label: "Fern", color: "#5b7a6a" },
    { value: "chalk", label: "Chalk", color: "#ffffff" },
    { value: "linen", label: "Linen", image: "/img/linen-swatch.jpg" },
  ]}
/>

<VariantPicker label="Waist" variant="select" value={waist} onChange={setWaist} options={waists} />

Variants

VariantWhat it is for
chipsDefault. Labelled buttons at least 44px square, for sizes, materials and anything with a short text label. A note shows as a second line.
swatches44px circles of each option's color or image, for colours and fabrics. The chosen one has a ring. The label is the accessible name and a tooltip, and the legend shows it ("Colour: Chalk").
selectThe system Select, for a long list (more than about a dozen) or long labels. showSelected doesn't apply: the select shows its value.

Unavailable options (disabled: true) stay in place. A chip is struck through with a dashed border; a swatch is crossed. showSelected (on by default) prints the chosen label after the legend: "Size: M".

Composition

  • On a product page, one picker per dimension, stacked under the price and above the quantity and the add button.
  • Swatch colours come from product data. They are content, not design tokens, so a literal colour value in options is right.
  • select renders Select, and so Field for its label. In that variant, extra props go to the native <select>; in the others they go to the root.
  • The picker only reports a choice; which variants are in stock, and what the price becomes, is your app's state.

Tokens

Tier 3, in tokens/component/commerce.css, each repeated under .dark:

  • --dt-variant-bg (--dt-surface-base), --dt-variant-bg-hover (--dt-surface-subtle), --dt-variant-fg (--dt-text-primary), --dt-variant-border (--dt-border-default): a resting chip. The border also marks a hovered swatch.
  • --dt-variant-selected-bg (--dt-surface-selected), --dt-variant-selected-fg (--dt-text-on-selected), --dt-variant-selected-border (--dt-border-selected): the chosen chip.
  • --dt-variant-unavailable-fg (--dt-text-disabled), --dt-variant-unavailable-border (--dt-border-disabled): an unavailable chip.
  • --dt-variant-note-color (--dt-text-secondary): a chip's note.
  • --dt-swatch-ring (--dt-border-selected): the ring around the chosen swatch.
  • --dt-swatch-border (--dt-border-strong): the hairline around every swatch, so a white one shows on a white surface.

Sizes come from --dt-size-touch-target (44px), type from --dt-text-label-md-*, and the transition from --dt-motion-micro, which is instant under reduced motion.

Accessibility

  • Chips and swatches are a role="radiogroup" named by the visible label, with a role="radio" button per option and aria-checked on the chosen one.
  • One tab stop (roving tabindex): the chosen option, or the first available one when nothing is chosen. Arrow keys move to the next available option and choose it, skipping unavailable ones and wrapping; Home and End go to the first and last available. Space and Enter choose the focused option. Arrows follow the reading direction in right-to-left pages.
  • An unavailable option has aria-disabled="true" and is named with "unavailable" ("XL, unavailable"; unavailableLabel translates it). It can't be chosen, and the arrows pass over it.
  • A note is part of the name: "L, Low stock".
  • Focus shows the system focus ring. Every option is at least 44px square.
  • In select, the native select does the keyboard work. Select can't disable an option, so an unavailable one reads "XL (unavailable)" and is ignored if picked: the controlled value stays.
  • The chosen label in the legend is hidden from assistive technology, since the checked radio already says it.

Content

  • label is the dimension, sentence case and short: "Size", "Colour", "Material".
  • Option labels are as short as the product allows: "M", "EU 42", "Fern".
  • A note is two or three words: "Low stock", "+$8", "Ships in 2 weeks".

Props

The typed contract, from VariantPicker.d.ts.

import * as React from "react";

/** One choice in a VariantPicker. */
export interface VariantPickerOption {
  /** What onChange reports, e.g. "m" or "sand". Unique within the picker. */
  value: string;
  /** What people see and hear: "M", "Sand". A swatch shows it as a tooltip and in the legend. */
  label: string;
  /** For swatches: any CSS colour from the product data. It is content, not a design token. */
  color?: string;
  /** For swatches: an image URL, such as a fabric close-up, drawn in the circle instead of color. */
  image?: string;
  /** Out of stock or not offered with the other choices. It stays visible, crossed out, is skipped by the arrow keys and cannot be chosen. */
  disabled?: boolean;
  /** A short extra line: "Low stock", "+$10". Shown under a chip's label and added to every variant's accessible name. */
  note?: string;
}

/** Choose one variant of a product, such as a colour, a size or a material. */
export interface VariantPickerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue" | "children"> {
  /** What is being chosen, e.g. "Size" or "Colour". Names the radio group, or labels the select. */
  label: string;
  /** The choices, in order. */
  options: VariantPickerOption[];
  /** The chosen option's value. The picker is controlled; leave it undefined for nothing chosen yet. */
  value?: string;
  /** Called with the chosen option's value. Never called with a disabled option. */
  onChange: (value: string) => void;
  /**
   * chips are labelled buttons, for sizes and materials. swatches are circles of each option's
   * color or image, for colours. select is the system Select, for a long list. @default "chips"
   */
  variant?: "chips" | "swatches" | "select";
  /** Shows the chosen label after the legend: "Size: M". Chips and swatches only. @default true */
  showSelected?: boolean;
  /** The select's empty first option. @default `Choose ${label.toLowerCase()}` */
  placeholder?: string;
  /** Added to a disabled option's accessible name and, in a select, its text. @default "unavailable" */
  unavailableLabel?: string;
}

export declare function VariantPicker(props: VariantPickerProps): React.JSX.Element;

Tokens it reads

Every custom property VariantPicker.jsx resolves, read from the source: 12 component, 163 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-swatch-bordercomponentvar(--dt-border-strong)
--dt-swatch-ringcomponentvar(--dt-border-selected)
--dt-variant-bgcomponentvar(--dt-surface-base)
--dt-variant-bg-hovercomponentvar(--dt-surface-subtle)
--dt-variant-bordercomponentvar(--dt-border-default)
--dt-variant-fgcomponentvar(--dt-text-primary)
--dt-variant-note-colorcomponentvar(--dt-text-secondary)
--dt-variant-selected-bgcomponentvar(--dt-surface-selected)
--dt-variant-selected-bordercomponentvar(--dt-border-selected)
--dt-variant-selected-fgcomponentvar(--dt-text-on-selected)
--dt-variant-unavailable-bordercomponentvar(--dt-border-disabled)
--dt-variant-unavailable-fgcomponentvar(--dt-text-disabled)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-border-width-strongsemanticvar(--dt-dim-hair-2)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-size-touch-targetsemanticvar(--dt-dim-11)
--dt-space-inline-2xssemanticvar(--dt-dim-1)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-surface-basesemanticvar(--dt-color-white)
--dt-surface-sunkensemanticvar(--dt-color-neutral-100)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";
import { Select } from "../forms/Select.jsx";

const role = (name) => ({
  fontFamily: `var(--dt-text-${name}-family)`,
  fontSize: `var(--dt-text-${name}-size)`,
  lineHeight: `var(--dt-text-${name}-line)`,
  fontWeight: `var(--dt-text-${name}-weight)`,
  letterSpacing: `var(--dt-text-${name}-tracking)`,
});

/* A cross over an unavailable swatch, drawn twice so it shows on a black
   swatch and a white one alike: a wide stroke in the surface colour under a
   thin one in the text colour. */
function Cross() {
  return (
    <svg
      viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" strokeLinecap="round"
      style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}
    >
      <path d="M5 19 19 5" strokeWidth="4" style={{ stroke: "var(--dt-surface-base)" }} />
      <path d="M5 19 19 5" strokeWidth="1.5" style={{ stroke: "var(--dt-text-secondary)" }} />
    </svg>
  );
}

export function VariantPicker({
  label,
  options = [],
  value,
  onChange,
  variant = "chips",
  showSelected = true,
  placeholder,
  unavailableLabel = "unavailable",
  style,
  ...rest
}) {
  const labelId = React.useId();
  const refs = React.useRef({});
  const [hover, setHover] = React.useState(null);
  const selected = options.find((o) => o.value === value);
  const nameOf = (o) => [o.label, o.note, o.disabled ? unavailableLabel : null].filter(Boolean).join(", ");

  if (variant === "select") {
    return (
      <Select
        label={label}
        value={value == null ? "" : value}
        placeholder={placeholder || `Choose ${label.toLowerCase()}`}
        options={options.map((o) => ({ value: o.value, label: o.disabled || o.note ? `${o.label} (${[o.note, o.disabled ? unavailableLabel : null].filter(Boolean).join(", ")})` : o.label }))}
        onChange={(e) => {
          /* Select renders options as strings, so an unavailable one can be
             picked in the native list; it is ignored here and the controlled
             value snaps back. */
          const o = options.find((x) => x.value === e.target.value);
          if (o && !o.disabled && o.value !== value && onChange) onChange(o.value);
        }}
        style={style}
        {...rest}
      />
    );
  }

  const swatches = variant === "swatches";
  const count = options.length;
  const firstEnabled = options.findIndex((o) => !o.disabled);
  /* One tab stop: the chosen option, or the first available one. */
  const tabStop = selected && !selected.disabled ? selected.value : firstEnabled >= 0 ? options[firstEnabled].value : null;

  const choose = (o) => {
    if (!o || o.disabled) return;
    if (o.value !== value && onChange) onChange(o.value);
    const el = refs.current[o.value];
    if (el) el.focus();
  };

  /* The next available option from i in direction dir, wrapping. */
  const step = (i, dir) => {
    for (let k = 1; k <= count; k++) {
      const j = (((i + dir * k) % count) + count) % count;
      if (!options[j].disabled) return options[j];
    }
    return null;
  };

  const onKeyDown = (e, i) => {
    const rtl = !!(e.currentTarget.closest && e.currentTarget.closest('[dir="rtl"]'));
    const fwd = rtl ? "ArrowLeft" : "ArrowRight";
    const back = rtl ? "ArrowRight" : "ArrowLeft";
    let next = null;
    if (e.key === fwd || e.key === "ArrowDown") next = step(i, 1);
    else if (e.key === back || e.key === "ArrowUp") next = step(i, -1);
    else if (e.key === "Home") next = step(-1, 1);
    else if (e.key === "End") next = step(count, -1);
    else return;
    e.preventDefault();
    choose(next);
  };

  const option = (o, i) => {
    const checked = o.value === value;
    const off = !!o.disabled;
    const hovered = hover === o.value && !off && !checked;
    const common = {
      ref: (el) => { refs.current[o.value] = el; },
      type: "button",
      role: "radio",
      "aria-checked": checked,
      "aria-disabled": off || undefined,
      "aria-label": nameOf(o),
      tabIndex: o.value === tabStop ? 0 : -1,
      onClick: () => choose(o),
      onKeyDown: (e) => onKeyDown(e, i),
      onMouseEnter: () => setHover(o.value),
      onMouseLeave: () => setHover(null),
    };

    if (swatches) {
      return (
        <button
          key={o.value}
          {...common}
          title={nameOf(o)}
          style={{
            flex: "none", boxSizing: "border-box",
            width: "var(--dt-size-touch-target)", height: "var(--dt-size-touch-target)",
            padding: "var(--dt-space-inset-2xs)", margin: 0,
            border: `var(--dt-border-width-strong) solid ${checked ? "var(--dt-swatch-ring)" : hovered ? "var(--dt-variant-border)" : "transparent"}`,
            borderRadius: "var(--dt-radius-pill)", background: "transparent",
            cursor: off ? "not-allowed" : "pointer",
            transition: "border-color var(--dt-motion-micro)",
          }}
        >
          <span
            aria-hidden="true"
            style={{
              position: "relative", display: "block", boxSizing: "border-box", width: "100%", height: "100%",
              borderRadius: "var(--dt-radius-pill)", overflow: "hidden",
              backgroundColor: o.color || "var(--dt-surface-sunken)",
              backgroundImage: o.image ? `url(${JSON.stringify(o.image)})` : undefined,
              backgroundSize: "cover", backgroundPosition: "center",
              border: "var(--dt-border-width-default) solid var(--dt-swatch-border)",
            }}
          >
            {off && <Cross />}
          </span>
        </button>
      );
    }

    return (
      <button
        key={o.value}
        {...common}
        style={{
          display: "inline-flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
          boxSizing: "border-box", minWidth: "var(--dt-size-touch-target)", minHeight: "var(--dt-size-touch-target)",
          padding: "var(--dt-space-inset-2xs) var(--dt-space-inset-sm)", margin: 0,
          border: `var(--dt-border-width-default) ${off ? "dashed" : "solid"} ${
            checked ? "var(--dt-variant-selected-border)" : off ? "var(--dt-variant-unavailable-border)" : "var(--dt-variant-border)"
          }`,
          borderRadius: "var(--dt-radius-control)",
          background: checked ? "var(--dt-variant-selected-bg)" : hovered ? "var(--dt-variant-bg-hover)" : "var(--dt-variant-bg)",
          color: checked ? "var(--dt-variant-selected-fg)" : off ? "var(--dt-variant-unavailable-fg)" : "var(--dt-variant-fg)",
          cursor: off ? "not-allowed" : "pointer",
          transition: "background var(--dt-motion-micro), border-color var(--dt-motion-micro)",
          ...role("label-md"),
        }}
      >
        <span style={{ textDecoration: off ? "line-through" : "none", whiteSpace: "nowrap" }}>{o.label}</span>
        {o.note && (
          <span style={{ ...role("body-xs"), color: off ? "var(--dt-variant-unavailable-fg)" : "var(--dt-variant-note-color)", whiteSpace: "nowrap" }}>
            {o.note}
          </span>
        )}
      </button>
    );
  };

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-xs)", minWidth: 0, ...style }} {...rest}>
      <span style={{ ...role("label-md"), color: "var(--dt-text-primary)" }}>
        <span id={labelId}>{label}</span>
        {showSelected && selected && (
          <span aria-hidden="true">
            {": "}
            <span style={{ color: "var(--dt-text-secondary)", fontWeight: "var(--dt-text-body-sm-weight)" }}>{selected.label}</span>
          </span>
        )}
      </span>
      <div
        role="radiogroup"
        aria-labelledby={labelId}
        style={{ display: "flex", flexWrap: "wrap", gap: swatches ? "var(--dt-space-inline-2xs)" : "var(--dt-space-inline-xs)" }}
      >
        {options.map(option)}
      </div>
    </div>
  );
}