Skip to content
ssntl/ui

Swatch

A variant picker of named radios with 44px targets, placed under the preview it changes.

Finish: Blue

Installation

$ pnpm dlx shadcn@latest add @ssntl/swatch

Usage

tsx
import { Swatch, SwatchGroup } from "@/components/ui/swatch"

<SwatchGroup aria-label="Color">
  <Swatch name="color" value="blue" color="var(--blue-600)" aria-label="Blue" defaultChecked />
  <Swatch name="color" value="black" color="var(--neutral-950)" aria-label="Black" />
</SwatchGroup>
  • Touch visibility: place the swatches below the preview they change, so a finger never covers the result.
  • Each swatch is a named radio (aria-label): the color alone means nothing to a screen reader. Show the chosen name in text too.
  • A 44px target around a 32px dot; the selected one gets a 2px ring with a gap, not just a border change.
  • Pass a system color (var(--blue-600)) or a product's real color; it goes in an inline style because it is data.

Code

components/ui/swatch.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

// A variant picker: color, finish, pattern. Each swatch is a native radio
// with a 44px target around a 32px dot, named with aria-label because the
// color alone means nothing to a screen reader. Touch visibility: put the
// group below the preview it changes, so the finger never covers the result.
function SwatchGroup({
  className,
  ...props
}: React.ComponentProps<"fieldset">) {
  return (
    <fieldset
      data-slot="swatch-group"
      className={cn("flex min-w-0 flex-wrap items-center gap-1", className)}
      {...props}
    />
  )
}

function Swatch({
  color,
  className,
  style,
  ...props
}: Omit<React.ComponentProps<"input">, "type" | "color"> & {
  /** Any CSS color; it is data, so it goes in an inline style. */
  color: string
  "aria-label": string
}) {
  return (
    <label
      data-slot="swatch"
      className="grid size-11 shrink-0 cursor-pointer place-items-center rounded-full has-disabled:cursor-not-allowed has-disabled:opacity-40"
    >
      <input
        type="radio"
        style={{ ...style, backgroundColor: color }}
        className={cn(
          "size-8 cursor-pointer appearance-none rounded-full outline -outline-offset-1 outline-black/10 dark:outline-white/10",
          "ring-offset-2 ring-offset-card transition-[box-shadow] duration-(--duration-fast) ease-out checked:ring-2 checked:ring-foreground",
          "focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring",
          className
        )}
        {...props}
      />
    </label>
  )
}

export { Swatch, SwatchGroup }