Home / Snippets / UI Components /
Custom radio
Custom-styled radio buttons with an animated selection dot — no JavaScript, fully keyboard-accessible.
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.