Home / Snippets / UI Components /

Custom radio

Custom-styled radio buttons with an animated selection dot — no JavaScript, fully keyboard-accessible.

Preferred theme
Widely Supported
uino-js

Quick implementation

/* HTML:
<label class="radio-label">
  <input class="radio-input" type="radio" name="group" value="option" />
  Option label
</label> */

.radio-label {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  cursor: pointer;
  user-select: none;
}

.radio-input {
  appearance: none;
  -webkit-appearance: none;
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid oklch(0.45 0.04 260);
  border-radius: 50%;
  background: oklch(0.13 0.02 260);
  position: relative;
  flex-shrink: 0;
  cursor: pointer;
  transition: border-color 0.2s ease;
}

.radio-input::after {
  content: '';
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: oklch(0.52 0.22 265);
  transform: scale(0);
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.radio-input:checked {
  border-color: oklch(0.52 0.22 265);
}

.radio-input:checked::after {
  transform: scale(1);
}

.radio-input:focus-visible {
  outline: 2px solid oklch(0.72 0.19 265);
  outline-offset: 3px;
}

.radio-input:hover:not(:checked) {
  border-color: oklch(0.72 0.19 265);
}

@media (prefers-reduced-motion: reduce) {
  .radio-input,
  .radio-input::after {
    transition: none;
  }
}

Prompt this to your LLM

Includes role, constraints, two framework variants, and edge cases to handle.

You are a senior frontend engineer specialising in accessible form controls.

Goal: Custom-styled radio buttons using appearance: none and ::after pseudo-elements for the selection dot, with a spring-like scale animation on check. No JavaScript.

Technical constraints:
- Strip browser styles with appearance: none; -webkit-appearance: none.
- Use ::after with transform: scale(0) → scale(1) and cubic-bezier(0.34, 1.56, 0.64, 1) for a spring pop.
- The ::after inset must be 3px on all sides to create the visual ring gap.
- Use oklch() for all colors — border, dot fill, and focus ring.
- Pair each input with a <label> using display: flex and gap — never float or absolute-position labels.
- Group radios in a <fieldset> with a <legend> for screen reader context.
- Wrap all transitions in @media (prefers-reduced-motion: reduce).

Framework variant (pick one):
A) Vanilla HTML + CSS — label wraps the input directly.
B) React component — accepts name, value, label, checked, and onChange props; renders accessible fieldset/legend wrapper when options array is passed.

Edge cases to handle:
- Disabled state: reduce opacity, set cursor: not-allowed on label, suppress hover border change.
- Keyboard navigation: :focus-visible ring must be visible on the custom element, not just when mouse-clicked.
- RTL layouts: gap and flex direction should work without mirroring changes.
- Long labels: text should wrap without pushing the radio circle out of alignment (align-items: flex-start for multiline).

Return HTML + CSS.

Why this matters in 2026

Default browser radio buttons look inconsistent across operating systems and are impossible to theme with CSS alone. By applying appearance: none you take full ownership of the visual, while keeping the native <input type="radio"> element — which means you get browser-managed keyboard navigation, :checked state, and screen-reader announcements for free. The result is a component that looks designed and behaves natively.

The logic

appearance: none removes the OS-rendered control, leaving a blank element you style from scratch. The circle outline is achieved with border-radius: 50% and a border. The inner dot is a ::after pseudo-element that starts at transform: scale(0) and scales to scale(1) when the :checked pseudo-class is active — no JavaScript toggle required. A cubic-bezier with values above 1 creates a spring overshoot that makes the dot feel tactile. The :hover:not(:checked) selector lightens the border only on unchecked options, giving interactive affordance without noise.

Accessibility & performance

Wrapping inputs in a <label> makes the entire row — not just the circle — a click target, which is critical for touch devices. A <fieldset> and <legend> group the options so screen readers announce the group name before each choice. The :focus-visible rule ensures the focus ring only appears during keyboard navigation, not mouse clicks, eliminating the "ugly box" complaint without removing accessibility. All animated properties are transform and opacity — compositor-friendly and GPU-accelerated — so the check animation runs without triggering layout or paint.