CSS Custom Radio Buttons

Styled radio buttons without any images or JavaScript.

Published May 19, 2026 Beginner 7 min read

A custom radio button in CSS is built the same way as a custom checkbox. appearance: none removes the dial the operating system draws, leaving an empty box that takes a border, a background and a border radius like any other element. The inner dot is a ::after pseudo-element, centered with absolute positioning, and it appears when the input matches :checked.

What separates radios from checkboxes is the group. Inputs sharing a name are one set, and the browser handles the exclusivity, the arrow key navigation and the roving tab stop without any help. Two more versions follow: a segmented control where each option is a button rather than a dial, and one where the dot scales in instead of appearing instantly.

Styled radio group

HTML

<div class="radio-group">
  <label class="radio-label"><input type="radio" name="plan" checked> Free plan</label>
  <label class="radio-label"><input type="radio" name="plan"> Pro plan</label>
  <label class="radio-label"><input type="radio" name="plan"> Enterprise</label>
</div>

CSS

.radio-group {
  display: flex;
  flex-direction: column;
  gap: .5rem;
}

.radio-label {
  display: inline-flex;
  align-items: center;
  gap: .65rem;
  margin: .25rem 0;
  font-size: .95rem;
  cursor: pointer;
  user-select: none;
}

/* appearance: none clears the native dial, position: relative holds the dot */
input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  border: 2px solid #2a2a2d;
  border-radius: 50%;
  cursor: pointer;
  transition: border-color .2s;
}

input[type="radio"]:checked {
  border-color: #b8ff57;
}

input[type="radio"]:checked::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #b8ff57;
}

Other ways to build it

Segmented control

Day, week and month is a radio group whether or not it looks like one, so building it from radios means the arrow keys, the single tab stop and the announcement all arrive already working. The input is clipped rather than removed, the visible button is a sibling span, and input:checked + span fills it. The container carries role="radiogroup" and an aria-label, which does the job a <fieldset> and <legend> would do on a stacked form. Tab into it and press the arrow keys.

HTML

<div class="segmented" role="radiogroup" aria-label="Chart range">
  <label class="seg">
    <input type="radio" name="range" value="day" checked>
    <span>Day</span>
  </label>
  <!-- week and month follow -->
</div>

CSS

.segmented {
  display: inline-flex;
  gap: 3px;
  padding: 3px;
  border: 2px solid #2a2a2d;
  border-radius: 10px;
  background: #1c1c1e;
}

/* clipped, not display:none, so arrow keys still work */
.seg input[type="radio"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  border: none;
  overflow: hidden;
  clip-path: inset(50%);
}

.seg span {
  display: block;
  padding: .45rem 1rem;
  border-radius: 7px;
  font-size: .9rem;
  color: #88888f;
  transition: background .2s, color .2s;
}

.seg input:checked + span {
  background: #b8ff57;
  color: #0c0c0d;
}

.seg input:focus-visible + span {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
}

Dot that scales in

A pseudo-element declared only inside a :checked rule cannot animate, because it does not exist in the other state and the browser has nothing to interpolate from. Declaring the ::after on the input itself at scale(0) gives the transition a starting value, and :checked only has to change the scale. Note that the scale multiplies onto the centering translate, so both have to appear in the same transform value or the dot flies off center.

HTML

<div class="radio-group radio-anim">
  <label class="radio-label"><input type="radio" name="custom-radio-anim" checked> Compact</label>
  <label class="radio-label"><input type="radio" name="custom-radio-anim"> Comfortable</label>
  <label class="radio-label"><input type="radio" name="custom-radio-anim"> Spacious</label>
</div>

CSS

/* declared always, so there is a value to transition FROM */
.radio-anim input[type="radio"]::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #b8ff57;
  transform: translate(-50%, -50%) scale(0);
  transition: transform .2s ease;
}

.radio-anim input[type="radio"]:checked::after {
  transform: translate(-50%, -50%) scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .radio-anim input[type="radio"]::after {
    transition: none;
  }
}

How it works

Same technique as custom checkboxes. appearance:none strips the default styling, and the ::after pseudo-element draws the inner dot, centered with absolute positioning plus translate. The dot only appears when :checked, which the browser manages natively.

The dot is centered with top: 50%, left: 50% and transform: translate(-50%, -50%). The percentage offsets place its top left corner at the middle of the input, and the translate pulls it back by half its own width and height. This works whatever size the dot is, which is the reason to prefer it over hard coded pixel offsets: change the dot from 10px to 12px and nothing else needs recalculating.

The border radius does the shape work twice. 50% on the input turns the 20 pixel square into a circle, and 50% again on the ::after box turns the dot into a smaller circle. A radio drawn with square corners is a checkbox as far as most readers are concerned, so the roundness carries real meaning here rather than being decoration.

A pseudo-element that is created by the :checked rule cannot transition. It does not exist in the unchecked state, so there is no starting value for the browser to interpolate from, and the dot snaps into place. The fix is to declare the ::after unconditionally with transform: scale(0) and let :checked scale it to 1. The second variant below does that, and it is the general answer whenever a pseudo-element appears to ignore its transition.

Only one radio in a group can be checked, and if none of them start checked the group has no selected value at all. That state matches :indeterminate in CSS, which is worth knowing because it lets you style a group that has not been answered yet without adding a class. Marking one option checked in the HTML avoids the empty state, though on anything that records a real preference an unanswered default is often the honest choice.

The reset here is the same one behind custom checkboxes and a toggle switch. A radio group is also the state machine under a star rating, where five radios and a sibling combinator produce a control that looks nothing like a set of dials.

CSS properties used

appearance
none removes the native dial. Without it the input ignores width, border and background in most browsers.
border-radius
50% on both the input and the dot. This is what separates a radio from a checkbox visually.
:checked
Matches the selected radio in the group. The browser enforces that only one can match at a time.
transform
translate(-50%, -50%) centers the dot regardless of its size. Adding scale() is what makes the dot animatable.
:indeterminate
Matches every radio in a group where nothing is selected yet, which is a way to style an unanswered question.
:has()
Styles a wrapper from the state of the radio inside it, which is how the segmented control fills a whole button.

Browser support

FeatureChromeFirefoxSafariEdge
appearance838015.483
:checked43.53.212
:indeterminate395110.179
transform43.53.112
:focus-visible86415.486

Figures come from Can I Use. appearance without a prefix arrived recently enough that -webkit-appearance: none still earns its place for Safari 15.3 and older. A browser that understands neither line falls back to the native radio, which looks wrong next to the rest of the form but still works. :indeterminate matching an unselected radio group is the part most often assumed to be unsupported, and it has in fact been available across all four browsers since 2017.

Accessibility notes

Because the input is never hidden, focus, arrow key navigation and the checked announcement all come from the browser. A radio group is a single tab stop: Tab moves into the group, the arrow keys move between options and select as they go, and Tab moves out. That behavior is easy to break and impossible to rebuild convincingly, which is the argument for restyling the native control rather than replacing it with div elements.

Wrap a radio group in a <fieldset> with a <legend>. The legend is the question, and each label is an answer. Without it a screen reader announces the options with no idea what is being asked, so Free plan, Pro plan and Enterprise arrive with no question attached. The segmented control below does the same job with role="radiogroup" and an aria-label, which is the alternative when a fieldset would break the layout.

appearance: none can leave the default focus ring faint or badly placed, since it was drawn against a widget that no longer exists. Declare an explicit :focus-visible outline with an offset that clears the circle. On the segmented control the ring is forwarded to the visible button with input:focus-visible + span, because the input itself is clipped to a single pixel there.

Do not use color alone to show which option is selected. The demo keeps the dot as well as the border color change, and the segmented control fills the entire button rather than just tinting its text. Either way there is a second cue that survives a color vision difference or a low contrast screen.

What you can build with it

  • Plan and pricing pickers. One choice out of three or four, where the selection has to be obvious at a glance.
  • Shipping and payment options. Each option carries a price and a delivery estimate, so the label needs more room than a native radio allows.
  • Segmented controls in a toolbar. Day, week and month views. Visually a button group, structurally a radio group, which means the keyboard behavior is already correct.
  • Survey and quiz answers. Grouped in a fieldset with the question as the legend, which is what makes the form readable without sight.
  • Theme and density settings. Compact, comfortable, spacious. Mutually exclusive by definition, which is what a radio group is for.

Mistakes worth avoiding

  • Giving each radio a different name. They stop behaving as a group entirely, so several can be selected at once and arrow keys do nothing.
  • Transitioning a ::after that is only declared inside the :checked rule. There is no start value to animate from, so the dot appears instantly no matter what duration is set.
  • Centering the dot with fixed pixel offsets. It looks right at one size and drifts off center the moment the input or the dot is resized.
  • Omitting the fieldset and legend. The options are announced without the question, which makes a form with several radio groups impossible to follow by ear.
  • Hiding the input with display: none to build a segmented control. Arrow key navigation and focus both disappear, and a keyboard user cannot change the selection at all.

Frequently asked questions

Why will my radio button dot not animate?
The ::after is declared inside the :checked rule, so it does not exist until the radio is selected and the browser has no starting value to transition from. Declare the pseudo-element on the input itself with transform: scale(0), then let :checked change it to scale(1).
How do I style a radio group that has nothing selected?
Use :indeterminate. Every radio in a group where no option is checked matches it, so input[type="radio"]:indeterminate { border-color: #ff6b6b } marks an unanswered question without any script or extra class.
Can radio buttons look like a row of buttons?
Yes, and it is the right structure for a segmented control. Clip the input so it stays focusable, put the visible button in a sibling span, and style that span with input:checked + span. The keyboard behavior comes free, which is exactly what a hand built version of the same control usually gets wrong.
Should I use radio buttons or a select dropdown?
Radios when there are up to about five options and comparing them matters, since all the choices are visible at once. A select when the list is long, or when the control has to stay compact on a crowded form.
How do I let someone clear a radio selection?
You cannot, with radios alone. Once a group has a selection there is no way to return it to empty without script. Add an explicit None or No preference option as a fifth radio, which is clearer for everyone anyway.