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/swatchUsage
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
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 }