Custom Styling Radio Buttons: The Modern Way
Tags: css, designsystem, webdev, a11y
Published: 2023-09-02 16:39:05

**TL;DR:**

I made a thing. Maybe you think it's cool? [See code here](https://gist.github.com/adbutterfield/260249153051827d2dda78c39fba3259).

_For people who read, please continue._

Radio buttons have been around for quite some time, and as a staple of UI, developers often want to style them to better match the theme or style of their applications. The traditional way to customize radio buttons usually involves extra HTML elements or wrapping the radio input with a label, coupled with the use of `:before` and `:after` pseudo-elements. However, with the advancements in modern CSS, we can now style them with just the `input` element alone.

## The Traditional Approach

In the past, custom styling radio buttons usually involved:

- Additional HTML elements (like `div` or `span`) as overlays.
- Utilizing the sibling selector combined with `label` elements.
- Leveraging `:before` and `:after` pseudo-elements to create custom designs.

While these methods are tried and true, they can be cumbersome and add unnecessary weight to the DOM.

## The Pseudo-Element Limitation on Input Elements

An often overlooked, but crucial detail when styling form elements, particularly `input`, is their limitation with `:before` and `:after` pseudo-elements. Historically, these pseudo-elements have been used extensively to add decorative content or visual cues to elements. However, `input` elements are "replaced elements", and as per the CSS specification, replaced elements and their pseudo-elements do not have a visual box and thus cannot be styled or receive generated content using `:before` and `:after` (see [here](https://www.w3.org/TR/CSS21/conform.html#replaced-element) and [here](https://www.w3.org/TR/CSS21/generate.html#content)).

This limitation often led designers and developers to resort to workarounds, like wrapping `input` elements inside other elements or using sibling selectors combined with `label` elements. While these methods work, they introduce unnecessary complexity and can bloat the DOM.

By leaning into modern CSS, as I outline below, we sidestep the need for these workarounds entirely. We embrace a more streamlined approach, focusing on the input element itself, and doing away with the need for extra HTML or pseudo-elements.

## Embracing Modern CSS

Modern CSS has introduced several properties and selectors that make styling more straightforward, cleaner, and maintainable.

Let's break down the new approach, step by step:

### 1. Define Color Variables

Using the `:root` selector, we can define global CSS variables. This makes it easier to change the color scheme without having to search and replace values throughout the CSS.

Of course it's up to you with how creative you want to be with the colors. But at minimum you will need 4 different colors:

- Border color
- Checked color
- Hover color
- Disabled background color

It's pretty common these days to two tones of blue and two tones of gray (as I did here).

```css
:root {
  --radio-border-color: #8b8c89;
  --radio-checked-color: #274c77;
  --radio-hover-color: #a3cef1;
  --radio-disabled-bg-color: #d9d9d9;
}
```

### 2. Base Styling

The core styling for our radio button. We use the `appearance` property set to `none` to remove the default appearance of the radio button.

```css
input[type="radio"] {
  box-sizing: border-box;
  width: 20px;
  height: 20px;
  margin: 6px;
  padding: 0;
  border: 2px solid var(--radio-border-color);
  border-radius: 50%;
  appearance: none;
  background-color: transparent;
  outline: none;
}
```

Of course the `appearance` property isn't exactly new. In fact, it actually has quite a rich history...

### Side quest: A Brief History of the appearance property

#### Early Days & Vendor Prefixes (Late 1990s - Early 2000s):

In the late 1990s and early 2000s, web browsers had implemented proprietary properties to allow for the customization of form controls. That era was marked by vendor prefixes:

- `-webkit-appearance` was for WebKit browsers (like Chrome and Safari)
- `-moz-appearance` was for Firefox

#### Discouraged Use (Mid-2000s):

By the mid-2000s, as more developers had begun to explore the web's capabilities, the non-standard nature of the appearance property had led to its discouraged use. The cross-browser inconsistency had meant that web developers often had to rely on hacks and workarounds.

#### Standardization Efforts (Late 2000s - Early 2010s):

By the late 2000s, the CSS Working Group had started discussions about the appearance property's potential standardization. It had been considered for inclusion in the CSS UI (User Interface) specification drafts. The property was formally introduced in the CSS Basic User Interface Module Level 3 (CSS3 UI) around 2012.

#### Modern Usage (Mid-2010s - Present):

From the mid-2010s onwards, browsers had started aligning their implementations with the CSS3 UI specification. The use of vendor prefixes had started to decline, with many modern browsers having begun to support the unprefixed appearance property. By 2019, the widespread support for appearance: none had solidified its position as a reliable tool for web developers.

_Now back to your regularly scheduled article._

### 3. Styling the Checked State

The `:not()` pseudo-class combined with `:checked` is used to style radio buttons when they're checked, but not disabled. I'm a big fan of using pseudo-classes like this to be really explicit when these styles are applied, instead of letting things cascade and just overwriting them farther down the stylesheet.

The `radial-gradient` creates the appearance of a selected inner circle. Instead of reaching for SVGs (I'm looking at you Material UI), or using wrappers and whatever else, we can draw that circle with just straight up CSS!

```css
input[type="radio"]:not(:disabled):checked {
  border-color: var(--radio-checked-color);
  background-color: var(--radio-checked-color);
  background-clip: content-box;
  padding: 2px;
  background-image: radial-gradient(
    circle,
    var(--radio-checked-color) 0%,
    var(--radio-checked-color) 50%,
    transparent 60%,
    transparent 100%
  );
}
```

### 4. Styling for Disabled State

For the disabled state, we only need to add a background color, and cover the case when a radio can be checked && disabled.

```css
input[type="radio"]:disabled {
  background-color: var(--radio-disabled-bg-color);
}

input[type="radio"]:disabled:checked {
  background-image: radial-gradient(
    circle,
    var(--radio-border-color) 0%,
    var(--radio-border-color) 50%,
    transparent 50%,
    transparent 100%
  );
}
```

That covers the basic states of the input. Now time to move on to the interactions.

### 5. Enhancing Hover Effects

The `@media (hover: hover)` media query ensures that hover effects are applied only on devices that support hovering, like desktops. This detail is easy to overlook, especially if you haven't worked with CSS recently.

```css
@media (hover: hover) {
  input[type="radio"]:not(:disabled):hover {
    background-color: var(--radio-hover-color);
    outline: 6px solid var(--radio-hover-color);
  }
}
```

### 6. Focus Indication with :focus-visible

The `:focus-visible` pseudo-class is a modern approach to style the focus state only when it's relevant (like when using keyboard navigation). No longer do you have to annoy users (and of course get annoyed yourself) by showing a focus ring to users who don't need it.

```css
input[type="radio"]:focus-visible {
  background-color: var(--radio-hover-color);
  outline: 6px solid var(--radio-hover-color);
}
```

In the current code, both hover/focus have the same style. This may not be the case though for you. Always make sure to follow accessibility best practices when picking colors and width of hover/focus ring effects.

### 7. Adding the Finishing Touches

Refinements can make a big difference in user experience. There's a couple things we can add to polish this thing up a bit:

#### Smooth Transitions:

A slight transition can make state changes feel more natural.

```diff
input[type="radio"] {
  box-sizing: border-box;
  width: 20px;
  height: 20px;
  margin: 6px;
  padding: 0;
  border: 2px solid var(--radio-border-color);
  border-radius: 50%;
  appearance: none;
  background-color: transparent;
  outline: none;
+ transition: outline 0.1s;
}
```

#### Subtle Scaling:

A hint of scaling can indicate interaction, provided it's not overdone.

```diff
@media (hover: hover) {
  input[type="radio"]:not(:disabled):hover {
    background-color: var(--radio-hover-color);
    outline: 6px solid var(--radio-hover-color);
+   transform: scale(1.05);
  }
}

input[type="radio"]:focus-visible {
  background-color: var(--radio-hover-color);
  outline: 6px solid var(--radio-hover-color);
+ transform: scale(1.05);
}
```

#### Honoring User Preferences:

With the prefers-reduced-motion media query, we ensure that our design respects users who prefer reduced motion.

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

  input[type="radio"]:focus-visible {
    transform: scale(1);
  }
}

@media (prefers-reduced-motion: reduce) and (hover: hover) {
  input[type="radio"]:not(:disabled):hover {
    transform: scale(1);
  }
}
```

And that's about it for the code.

You can find the [full code here](https://gist.github.com/adbutterfield/260249153051827d2dda78c39fba3259) ([and here](https://gist.github.com/adbutterfield/260249153051827d2dda78c39fba3259#file-radio-scss) if you use scss or some preprocessor with nested styles, but it's only 4 lines shorter, so who needs it anyways?).

## Accessibility Considerations for Custom Radio Button Styling

In the world of web design, aesthetics often take center stage. However, it's super important that our designs are not just visually appealing but also accessible to all, including persons with disabilities. Ensuring that your custom-styled elements remain user-friendly and compliant with accessibility standards is both a responsibility and a mark of excellent web design.

There are several important things to think about if you want to use my approach, or use any other custom radio button implementation.

### Contrast

Ensure the colors you choose meet the minimum contrast ratios set by the Web Content Accessibility Guidelines (WCAG). Tools like the WebAIM Color Contrast Checker can help evaluate your color choices.

### Label Associations

Wrap the input with a label to increase the clickable/tappable area, making it easier for everyone, especially users with motor impairments. Ensure the label contains descriptive text that clearly indicates the choice the radio button represents.

```html
<label>
  <input type="radio" name="choice" value="option1" />
  Descriptive Option Text
</label>
```

### Focus Styles

The custom radio button uses `:focus-visible` to show a distinct style when the radio is focused. It’s crucial to ensure that the focus style is clearly visible to aid keyboard-only users in navigating and making selections.

### Size Matters

Aim for a minimum size of 44x44 pixels for the clickable/tappable area, as recommended by the WCAG. This size is not only beneficial for users with motor impairments but also improves the experience for mobile users. The current size in these styles is only 20x20 pixels, so you will want to have the `label` wrapping the `input` at least 44x44 pixels.

### Be Sensible with Animations

While subtle transitions and animations (like scaling) can enhance user experience, avoid overly complex or distracting animations. Some users are sensitive to motion. Always include a provision for users who have set a preference for reduced motion.

### Semantic HTML

Ensure that the HTML structure is semantic. While styling is important, maintaining the semantic integrity of elements like `input` and `label` ensures assistive technologies can interpret and convey the content effectively. Of course you can always `aria-hidden="true"` to unnecessary markup, but more code === more maintenance overhead && more bytes you're shipping to users' browsers, so it's something to think about.

### ARIA Attributes

While native HTML elements, when used correctly, often don't require additional ARIA attributes, always be on the lookout for scenarios where they might enhance accessibility. For instance, if you have a group of radio buttons, the group could be wrapped in a `fieldset` with a `legend` describing the set.

### Testing

Of course, it's always best to test your designs with real users, including those who rely on assistive technologies. Manual testing, while time-consuming, often surfaces issues automated tools might miss.

## Parting words

Modern CSS offers a cleaner, more streamlined approach to customizing radio buttons without the need for extra HTML or convoluted CSS. Embracing these techniques allows for efficient, maintainable, and user-friendly designs.

Always think about WHY you want to create a custom radio button in the first place. If the reason is to offer a better experience to users, then be sure to think about ALL users; think about accessibility. By focusing on accessibility from the outset, designers and developers can ensure a more inclusive web experience for all users, regardless of their abilities or how they access content.

And if improving user experience is really your goal, make sure to keep things as performant as you can. I don't think we're really optimizing early here or anything. It's a simple matter of less code means less bugs, less bytes sent down the wire, and hopefully after reading this, less css in js in your application or design system.