Episode 2 of 6

Styling Radio Buttons

Build beautiful custom radio buttons using CSS — hide the native input, create a custom circle with pseudo-elements, and animate the checked state.

Styling Radio Buttons

Native radio buttons are tiny, hard to click, and look different in every browser. In this episode you will build beautiful custom radio buttons using only CSS. The technique is to hide the native <input type="radio"> and replace it with a styled <span> that reacts to the :checked state.

The HTML Structure

<label class="radio-label">
    <input type="radio" name="gender" value="male">
    <span class="radio-custom"></span>
    Male
</label>

<label class="radio-label">
    <input type="radio" name="gender" value="female">
    <span class="radio-custom"></span>
    Female
</label>

The <label> wraps both the hidden input and the custom span. Because the input is inside the label, clicking anywhere on the label — including the custom span and text — toggles the radio button. No for attribute needed.

Step 1: Hide the Native Radio Button

.radio-label input[type="radio"] {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
    pointer-events: none;
}

We hide the native input with opacity: 0 and position: absolute instead of display: none. This keeps the input accessible to screen readers and keyboard navigation — display: none would remove it from the accessibility tree entirely.

Step 2: Style the Custom Circle

.radio-label {
    display: flex;
    align-items: center;
    gap: 10px;
    cursor: pointer;
    font-size: 15px;
    color: #374151;
    padding: 8px 0;
    user-select: none;
}

.radio-custom {
    width: 22px;
    height: 22px;
    border: 2px solid #d1d5db;
    border-radius: 50%;
    position: relative;
    flex-shrink: 0;
    transition: border-color 0.2s ease;
}

The .radio-custom span is styled as an empty circle — a square with border-radius: 50%. The flex-shrink: 0 prevents the circle from shrinking when the label text is long.

Step 3: Create the Inner Dot

.radio-custom::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%) scale(0);
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: #4A90D9;
    transition: transform 0.2s ease;
}

The inner dot is a pseudo-element centered inside the circle. It starts at scale(0) so it is invisible. When the radio button is checked, we will scale it up to scale(1) for a smooth "pop in" animation.

Step 4: The Checked State

.radio-label input[type="radio"]:checked + .radio-custom {
    border-color: #4A90D9;
}

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

The adjacent sibling combinator (+) selects the .radio-custom span immediately after the checked input. When checked, the border turns blue and the inner dot scales up from 0 to 1, creating a satisfying pop-in effect.

How the CSS Selector Chain Works

PartMeaning
input[type="radio"]Selects the hidden radio input
:checkedOnly when the input is selected
+The immediate next sibling element
.radio-customThe styled circle span
::afterThe inner dot pseudo-element

Step 5: Hover and Focus States

/* Hover */
.radio-label:hover .radio-custom {
    border-color: #9ca3af;
}

/* Focus (keyboard navigation) */
.radio-label input[type="radio"]:focus-visible + .radio-custom {
    outline: 2px solid #4A90D9;
    outline-offset: 2px;
}

/* Checked + hover */
.radio-label input[type="radio"]:checked + .radio-custom:hover {
    border-color: #357ABD;
}

The :focus-visible pseudo-class shows a focus ring only when the user navigates with the keyboard — not when they click with a mouse. This provides accessible focus indication without the visual noise of a focus ring on every click.

Step 6: Disabled State

.radio-label input[type="radio"]:disabled + .radio-custom {
    border-color: #e5e7eb;
    background: #f9fafb;
    cursor: not-allowed;
}

.radio-label input[type="radio"]:disabled + .radio-custom::after {
    background: #d1d5db;
}

.radio-label:has(input:disabled) {
    color: #9ca3af;
    cursor: not-allowed;
}

Disabled radio buttons get muted colors to indicate they cannot be interacted with. The :has() selector styles the entire label when it contains a disabled input.

Color Variations

/* Success */
.radio-success input:checked + .radio-custom {
    border-color: #10b981;
}
.radio-success input:checked + .radio-custom::after {
    background: #10b981;
}

/* Danger */
.radio-danger input:checked + .radio-custom {
    border-color: #ef4444;
}
.radio-danger input:checked + .radio-custom::after {
    background: #ef4444;
}

/* Warning */
.radio-warning input:checked + .radio-custom {
    border-color: #f59e0b;
}
.radio-warning input:checked + .radio-custom::after {
    background: #f59e0b;
}

Radio Button Group Layout

/* Horizontal layout */
.radio-group {
    display: flex;
    gap: 24px;
}

/* Vertical layout */
.radio-group-vertical {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

/* Card-style radio buttons */
.radio-card {
    padding: 16px;
    border: 2px solid #e5e7eb;
    border-radius: 8px;
    transition: border-color 0.2s ease, background 0.2s ease;
}

.radio-card:has(input:checked) {
    border-color: #4A90D9;
    background: #eff6ff;
}

Key Takeaways

  • Hide the native input with opacity: 0 and position: absolute — never display: none — for accessibility
  • The adjacent sibling combinator (+) connects the hidden input's :checked state to the visible custom element
  • Use transform: scale(0) to scale(1) for a smooth "pop in" animation on the inner dot
  • Use :focus-visible instead of :focus for keyboard-only focus rings
  • Wrapping the input inside a <label> makes the entire label area clickable
  • Always style disabled and hover states for a complete, production-ready component