CSS Color Swatch Picker
Radio buttons styled as color circles with a :checked ring indicator, and no JavaScript.
A CSS color swatch picker is a radio group wearing circles. Each <input type="radio"> is paired with a <label> sized to 32 pixels, rounded to a circle with border-radius: 50%, and filled with the color it represents. input:checked + label puts a ring around whichever one is selected, and because they all share a name the browser keeps exactly one chosen.
The selection ring is an outline rather than a border, which matters more than it sounds. An outline is drawn outside the box and takes no space, so adding one does not resize the circle or shift the row. Two more versions follow: one that gives every swatch a name and puts the group back in the tab order, and one that draws a tick inside the selected chip so the choice is not signalled by color alone.
Two swatch groups
Pick a color
Product variant
HTML
<p class="swatch-label">Pick a color</p>
<div class="swatch-group">
<input type="radio" name="swatch1" id="sw1" checked>
<label for="sw1" style="background: #ff6b6b"></label>
<input type="radio" name="swatch1" id="sw2">
<label for="sw2" style="background: #ff9040"></label>
<input type="radio" name="swatch1" id="sw3">
<label for="sw3" style="background: #ffd444"></label>
<input type="radio" name="swatch1" id="sw4">
<label for="sw4" style="background: #b8ff57"></label>
<input type="radio" name="swatch1" id="sw5">
<label for="sw5" style="background: #38bdf8"></label>
<input type="radio" name="swatch1" id="sw6">
<label for="sw6" style="background: #c084fc"></label>
<input type="radio" name="swatch1" id="sw7">
<label for="sw7" style="background: #f472b6"></label>
</div>
<p class="swatch-label">Product variant</p>
<div class="swatch-group swatch-group--large">
<input type="radio" name="swatch2" id="sw8" checked>
<label for="sw8" style="background: #1a1a2e"></label>
<input type="radio" name="swatch2" id="sw9">
<label for="sw9" style="background: #f4f1eb"></label>
<input type="radio" name="swatch2" id="sw10">
<label for="sw10" style="background: #2d4a22"></label>
<input type="radio" name="swatch2" id="sw11">
<label for="sw11" style="background: #4a1f1f"></label>
</div>
CSS
.swatch-label {
font-size: .82rem;
color: #88888f;
font-weight: 500;
margin-bottom: .4rem;
}
.swatch-group {
display: flex;
gap: .6rem;
flex-wrap: wrap;
}
.swatch-group input[type="radio"] {
display: none;
}
.swatch-group label {
box-sizing: border-box;
display: block;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
border: 3px solid transparent;
outline: 2px solid transparent;
outline-offset: 2px;
transition: outline-color .15s, transform .15s;
}
.swatch-group label:hover {
transform: scale(1.1);
}
/* The label is the input's next sibling, so :checked can reach it */
.swatch-group input[type="radio"]:checked + label {
outline-color: #f0f0f0;
}
/* Bigger circles for a product variant picker */
.swatch-group--large label {
width: 42px;
height: 42px;
}
Other ways to build it
Named swatches that work with a keyboard
Two changes make this group usable without a mouse. The radios are clipped to a single pixel rather than removed, which keeps them in the tab order with arrow key navigation intact, and each label carries its color name as clipped text so the group announces as Coral, Amber, Lime and so on. The focus ring uses the accent color while the selection ring stays white, so tabbing through looks different from choosing. The caption underneath is driven by :has(), one rule per color. Tab into the row and press the arrow keys.
Choose a finish
CoralAmberLimeSkyViolet
HTML
<div class="swatch-named">
<p class="swatch-label" id="named-label">Choose a finish</p>
<div class="swatch-group swatch-a11y" role="radiogroup" aria-labelledby="color-swatch-named-label">
<input type="radio" name="color-swatch-named" id="named-1" checked>
<label for="named-1" style="background: #ff6b6b"><span class="swatch-name">Coral</span></label>
<input type="radio" name="color-swatch-named" id="named-2">
<label for="named-2" style="background: #ff9040"><span class="swatch-name">Amber</span></label>
<input type="radio" name="color-swatch-named" id="named-3">
<label for="named-3" style="background: #b8ff57"><span class="swatch-name">Lime</span></label>
<input type="radio" name="color-swatch-named" id="named-4">
<label for="named-4" style="background: #38bdf8"><span class="swatch-name">Sky</span></label>
<input type="radio" name="color-swatch-named" id="named-5">
<label for="named-5" style="background: #c084fc"><span class="swatch-name">Violet</span></label>
</div>
<p class="swatch-readout"><span>Coral</span><span>Amber</span><span>Lime</span><span>Sky</span><span>Violet</span></p>
</div>
CSS
/* clipped, not display:none, so arrow keys still reach the group */
.swatch-a11y input[type="radio"] {
display: block;
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
/* the name is the accessible name, hidden the same way */
.swatch-name {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* focus is the accent ring, selection stays white */
.swatch-a11y input:focus-visible + label {
outline-color: #b8ff57;
outline-width: 3px;
}
.swatch-readout span {
display: none;
}
.swatch-named:has(#f1:checked) .swatch-readout span:nth-child(1) {
display: inline;
}
A tick inside the selected swatch
A single white ring in a row of colors is a weak signal, and it is a color difference on top of a set of color differences. A tick drawn inside the chosen chip is a shape, so it survives a color vision difference and a low contrast screen. The mark is the same two-border trick used for custom checkboxes, and filter: drop-shadow() traces it in dark so it stays readable on a pale swatch as well as a dark one.
Cover color
HTML
<div>
<p class="swatch-label" id="tick-label">Cover color</p>
<div class="swatch-group swatch-a11y swatch-tick" role="radiogroup" aria-labelledby="color-swatch-tick-label">
<input type="radio" name="color-swatch-tick" id="tick-1" checked>
<label for="tick-1" style="background: #f4f1eb"><span class="swatch-name">Bone</span></label>
<input type="radio" name="color-swatch-tick" id="tick-2">
<label for="tick-2" style="background: #ffd444"><span class="swatch-name">Mustard</span></label>
<input type="radio" name="color-swatch-tick" id="tick-3">
<label for="tick-3" style="background: #2d4a22"><span class="swatch-name">Forest</span></label>
<input type="radio" name="color-swatch-tick" id="tick-4">
<label for="tick-4" style="background: #1a1a2e"><span class="swatch-name">Midnight</span></label>
</div>
</div>
CSS
.swatch-tick label {
position: relative;
width: 40px;
height: 40px;
}
.swatch-tick input[type="radio"]:checked + label::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 7px;
height: 12px;
border: 2.5px solid #fff;
border-top: none;
border-left: none;
transform: translate(-60%, -65%) rotate(45deg);
/* stacked shadows trace the tick, so it reads on a pale chip too */
filter: drop-shadow(0 0 1px rgba(0, 0, 0, .95))
drop-shadow(0 0 2px rgba(0, 0, 0, .8))
drop-shadow(0 1px 1px rgba(0, 0, 0, .6));
}
How it works
Each radio input is hidden with display: none, and its paired <label> is styled as a colored circle. The :checked selector reaches the hidden input, and because the label is its immediate sibling, input:checked + label adds a visible outline ring to mark the selection, with no JavaScript involved.
Using outline for the ring is a deliberate choice. A border occupies space in the box model, so adding a three pixel border to the selected swatch would either shrink the circle or push its neighbours along, and the whole row would jump every time somebody changed their mind. An outline is painted outside the border box and contributes nothing to layout, so it can appear and disappear with no movement at all.
The ring animates because the outline is always there, declared as outline: 2px solid transparent on every label, with only its color changing on :checked. This is the same rule that governs a scaling dot on custom radio buttons: a property can only transition between two values it actually has, so the starting state has to exist before the transition can run.
The colors are set with inline style attributes rather than in the stylesheet, which is right here. A swatch's color is content, not presentation: it comes from the product, the theme or the palette, so it belongs with the markup that lists the options. Writing seven classes into a stylesheet for seven colors means editing CSS every time a product gains a shade.
box-sizing: border-box on the label is what keeps the three pixel transparent border inside the 32 pixel circle rather than adding to it. Without it, a swatch measures 38 pixels across while its stylesheet says 32, and any layout doing arithmetic on that number comes out wrong.
There is a native alternative worth knowing about. <input type="color"> opens the operating system's color picker and gives access to the entire spectrum, which is right for a design tool and wrong for a product page where only four finishes exist. Swatches are for a fixed set of choices, the same reasoning that separates a radio group from a styled select.
CSS properties used
outline- Draws the selection ring outside the box, so it takes no space and nothing shifts when the selection moves.
outline-offset- Pushes the ring away from the circle so the two read as separate rather than as a thicker chip.
border-radius50%turns the square label into a circle. The same value on a non square box gives an ellipse.:checked- Reads the selected radio. Paired with
+it styles the label that follows the input. box-sizingborder-boxkeeps the transparent border inside the declared 32 pixels instead of adding to it.:has()- Lets a readout elsewhere on the page react to which swatch is selected, without any script.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
:checked | 4 | 3.5 | 3.2 | 12 |
outline | 4 | 2 | 3.1 | 15 |
flexbox | 21 | 28 | 6.1 | 12 |
filter | 18 | 35 | 6 | 79 |
:has() | 105 | 121 | 15.4 | 105 |
Figures come from Can I Use, with :checked and the adjacent sibling combinator both under the CSS3 selectors entry. outline-offset shipped later than outline itself but is available in every browser in current use, and a browser without it draws the ring flush against the circle rather than failing. Only the readout in the first variant needs :has(), and without it the swatches still work: the caption simply stops updating.
Accessibility notes
The main demo has two defects worth naming. display: none on the radios removes the whole group from the tab order, so the picker cannot be operated without a mouse. And the labels are empty, so even a reachable swatch has no accessible name: a screen reader announces seven unlabelled radio buttons with nothing to choose between them. Both variants below fix both.
Give every swatch a name in text. Put it inside the label, clip it visually with position: absolute; width: 1px; clip-path: inset(50%), and the group announces as Coral, Amber, Sky and so on while still looking like plain circles. A color with no name is unusable for anyone who cannot see it, and color names are what people say out loud anyway.
The selection ring is a color difference, and a single white ring around one chip in a row of seven is easy to miss at a glance. The second variant adds a tick inside the selected swatch, which is a shape rather than a color, so the choice survives a color vision difference or a low contrast screen. The tick carries a drop-shadow so it reads on pale and dark chips alike.
Keyboard focus needs its own indicator, distinct from the selection ring. Clipping the input keeps it focusable, and input:focus-visible + label can then set the ring to the accent color while the selected swatch keeps the white one. Without that separation, tabbing through the group looks identical to selecting from it.
A 32 pixel circle is above the 24 by 24 minimum WCAG 2.2 asks for, but the flex gap is what actually keeps the targets apart. Reducing the gap to nothing makes two adjacent swatches easy to hit by mistake even though each one measures large enough on its own.
What you can build with it
- Product variant pickers. Four or five finishes on a product page, with the swatch showing the color rather than describing it.
- Theme selection in settings. A short list of accent colors, where every option can be seen next to the others.
- Label and tag colors. Picking a color for a project or a calendar entry from a fixed palette.
- Highlighter and annotation tools. A small palette that has to stay visible while the reader works, which a color dialog cannot do.
- Avatar and profile accents. A handful of preset colors, which avoids the free choice problem where somebody picks an unreadable one.
Mistakes worth avoiding
- Hiding the radios with
display: none. Nothing in the group can be reached with a keyboard, and screen readers find no control at all. - Leaving the labels empty. Every swatch is announced as an unnamed radio button, so the choice is impossible to make without sight.
- Using
borderfor the selection ring. The circle changes size when it is selected and the whole row shifts sideways. - Forgetting
box-sizing: border-box. The transparent border adds to the width, so a swatch declared at 32 pixels renders at 38. - Relying on the ring alone. One white outline in a row of colors is a weak signal, and a second cue such as a tick costs four declarations.
Frequently asked questions
Why use outline instead of border for the selected swatch?
How do I make a color swatch picker keyboard accessible?
display: none. Clip them instead with position: absolute; width: 1px; height: 1px; clip-path: inset(50%), which keeps them focusable, then forward the focus ring to the label with input:focus-visible + label. Arrow keys then move through the group the way they do on any radio set.How should I name a color swatch for screen readers?
title or an image alt for this, and do not rely on the hex value, which tells a listener nothing.Should I use input type=color instead?
<input type="color"> opens the operating system's picker and gives the whole spectrum, which suits a design tool. A product page offering four finishes needs a fixed set, and that is a radio group.How do I show the name of the selected color?
:has(). Write every name into a readout element, hide them all, and reveal the matching one with a rule such as .picker:has(#sw2:checked) .readout span:nth-child(2) { display: inline }. It is one rule per color and needs no script.