DEV Community

Cover image for Pure CSS Custom Styled Radio Buttons
Stephanie Eckles
Stephanie Eckles

Posted on • Edited on • Originally published at moderncss.dev

Pure CSS Custom Styled Radio Buttons

This is the eighteenth post in a series examining modern CSS solutions to problems I've been solving over the last 13+ years of being a frontend developer. Visit ModernCSS.dev to view the whole series and additional resources.

Using a combination of the following properties, we can create custom, cross-browser, theme-able, scalable radio buttons in pure CSS:

  • currentColor for theme-ability
  • em units for relative sizing
  • radial-gradient vs. :before for the :checked indicator
  • CSS grid layout to align the input and label

Head's up: A lot of these styles overlap with the episode on custom checkbox styles which you might be interested in reading next!


Now available: my egghead video course Accessible Cross-Browser CSS Form Styling. You'll learn to take the techniques described in this tutorial to the next level by creating a themable form design system to extend across your projects.

Radio Button HTML

There are two appropriate ways to layout radio buttons in HTML.

The first wraps the input within the label. This implicitly associates the label with the input that its labeling, and also increases the hit area to select the radio.

<label>
  <input type="radio" name="radio" />
  Radio label text
</label>
Enter fullscreen mode Exit fullscreen mode

The second is to have the input and label be siblings and use the for attribute set to the value of the radio's id to create the association.

<input type="radio" name="radio" id="radio1" />
<label for="radio1">Radio label text</label>
Enter fullscreen mode Exit fullscreen mode

Our technique will work with either setup, although we're going to select the wrapping label method to prevent including an extra div.

The base HTML for our demo including classes and two radios - necessary to test :checked vs. un-checked states - is the following:

<label class="radio">
  <span class="radio__input">
    <input type="radio" name="radio">
    <span class="radio__control"></span>
  </span>
  <span class="radio__label">Radio 1</span>
</label>

<label class="radio">
  <span class="radio__input">
    <input type="radio" name="radio">
    <span class="radio__control"></span>
  </span>
  <span class="radio__label">Radio 2</span>
</label>
Enter fullscreen mode Exit fullscreen mode

For groups of radio buttons, it is also necessary to provide the same name attribute.

Here's how the native HTML elements in Chrome appear:

native radio buttons in Chrome

Common Issues with Native Radio Buttons

The primary issue that causes developers to seek a custom styling solution for radio buttons is the variance in their appearance between browsers which is increased when including mobile browsers as well.

As an example, here are radio buttons as shown on Mac versions of Firefox (left), Chrome (middle), and Safari (right):

radio buttons in Firefox, Chrome, Safari

The second issue is the inability of native radio buttons to scale with font-size alone. Here's this failure demonstrated again in those browsers, same order:

radio buttons in Firefox, Chrome, Safari with no text scaling

Our solution will accomplish the following goals:

  • scale with the font-size provided to the label
  • gain the same color as provided to the label for ease of theme-ability
  • achieve a consistent, cross-browser design style, including :focus state
  • maintain keyboard accessibility

Theme Variable and box-sizing Reset

There are two base CSS rules that must be placed first in our cascade.

First, we create a custom variable called --color which we will use as a simple way to easily theme our radio buttons.

:root {
  --color: rebeccapurple;
}
Enter fullscreen mode Exit fullscreen mode

Next, we use the universal selector to reset the box-sizing method used to border-box. This means that padding and border will be included in the calculation of any elements computed final size instead of increasing the computed size beyond any set dimensions.

*,
*:before,
*:after {
  box-sizing: border-box;
}
Enter fullscreen mode Exit fullscreen mode

Label Styles

Our label uses the class of .radio. The base styles we'll include here are the font-size and color. Recall from earlier that the font-size will not yet have an effect on the visual size of the radio input.

.radio {
  font-size: 2.25rem;
  color: var(--color);
}
Enter fullscreen mode Exit fullscreen mode

We're using an abnormally large font-size just to emphasize the visual changes for purposes of the tutorial demo.

Our label is also the layout container for our design, and we're going to set it up to use CSS grid layout to take advantage of grid-gap.

.radio {
  // ...existing styles

  display: grid;
  grid-template-columns: min-content auto;
  grid-gap: 0.5em;
}
Enter fullscreen mode Exit fullscreen mode

Here's our progress as captured in Chrome, with Inspector revealing grid lines:

radio label with grid layout revealed

Custom Radio Button Style

Ok, this is the part you came here for!

To prepare for this, we have wrapped our input in span with the class radio__input. Then, we have also added a span as a sibling of the input with the class radio__control.

Order here matters, as we'll see when we style for :checked and :focus.

Step 1: Hide the Native Radio Input

We need to hide the native radio input, but keep it technically accessible to enable proper keyboard interaction and also to maintain access to the :focus state.

To accomplish this, we'll use opacity to visually hide it, and set its width and height to 0 to reduce its impact on the flow of elements.

.radio__input {

  input {
    opacity: 0;
    width: 0;
    height: 0;
  }

}
Enter fullscreen mode Exit fullscreen mode

You may have seen more verbose solutions in the past, but we'll see why this works when we add the custom-styled control.

Step 2: Custom Unchecked Radio Styles

For our custom radio, we'll attach styles to the span of class radio__control that is the sibling following the input.

We'll define it as block element that is sized using em to keep it relative to the font-size applied to the label. We also use em for the border-width value to maintain the relative appearance. Good ole border-radius: 50% finishes the expected appearance by rendering the element as a circle.

.radio__control {
  display: block;
  width: 1em;
  height: 1em;
  border-radius: 50%;
  border: 0.1em solid currentColor;
}
Enter fullscreen mode Exit fullscreen mode

Here's our progress after hiding the native input and defining these base styles for the custom radio control:

progress of styles for the custom radio control shows the custom control rendering lower than the radio label

Uh - what is happening with that alignment?

Despite defining a width and height of 0, with default behavior of the span it is still being calculated as an element with dimensions.

The quick fix for this is to add display: flex to the .radio__input span that wraps the native input and the custom control:

.radio__input {
  display: flex;
}
Enter fullscreen mode Exit fullscreen mode

Flex honors the 0 dimensions, and the custom control pops up and acts like the only element within .radio__input.

result of adding display: flex to fix the alignment

Step 3: Improve Input vs. Label Alignment

If you've worked with grid or flexbox, your instinct right now might be to apply align-items: center to optically tune the alignment of the input in relation to the label text.

But what if the label is long enough to become broken across multiple lines? In that case, alignment along horizontal center may be undesirable.

Instead, let's make adjustments so the input stays horizontally centered in relation to the first line of the label text.

Our first step is to adjust the line-height on the span of class .radio__label.

.radio__label {
  line-height: 1;
}
Enter fullscreen mode Exit fullscreen mode

Using the value of 1 is admittedly a quick fix here and may not be desirable if your application has multi-line radio labels more often than not.

Depending on font in use, that may not 100% solve the alignment, in which case you may benefit from the following additional adjustment.

On our custom control, we'll use transform to nudge the element up. This is a bit of a magic number, but as a starting point this value is half the size of the applied border.

.radio__control {
  // ...existing styles

  transform: translateY(-0.05em);
}
Enter fullscreen mode Exit fullscreen mode

And with that our alignment is complete and functional for both single-line and multi-line labels:

final alignment of input vs. label text

Step 4: The :checked State

Our use of opacity: 0 has kept the native radio input accessible for keyboard interaction as well as click/tap interaction.

It has also maintained the ability to detect its :checked state with CSS.

Remember how I mentioned order matters? Thanks to our custom control following the native input, we can use the adjacent sibling combination - + - to style our custom control when the native control is :checked πŸ™Œ

Option 1: Creating the circle with radial-gradient

We can add a radial-gradient for a classic filled circle appearance:

.radio__input {
  // ...existing styles

  input {
    // ...existing styles

    &:checked + .radio__control {
      background: radial-gradient(currentcolor 50%, rgba(255, 0, 0, 0) 51%);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

You can adjust the stop point for the gradient to your preference.

Note the use of rgba to define a transparent color instead of the keyword transparent due to an issue with using transparent in gradients for Safari where its interpreted as "transparent black" πŸ‘Ž

Here's a gif of the result:

demo of the custom radio checked state with radial-gradient

Since the radial-gradient is applied as a background, it will not be visible if the form page is printed with default printer settings which remove CSS backgrounds.

Option 2: Creating the circle with :before

The alternate method is to use :before on the custom control to become child element that renders as a circle.

The advantage of this method is that it is also available to animate.

We first need to change the behavior of the .radio__control wrapping span:

.radio__control {
  display:grid;
  place-items: center;
}
Enter fullscreen mode Exit fullscreen mode

This is the quickest way to align the :before to the horizontal and vertical center of custom control.

Then, we create the :before element, including a transition and using transform hide it with scale(0):

input + .radio__control::before {
  content: "";
  width: .5em;
  height: .5em;
  box-shadow: inset .5em .5em currentColor;
  border-radius: 50%;
  transition: 180ms transform ease-in-out;
  transform: scale(0);    
}
Enter fullscreen mode Exit fullscreen mode

Use of box-shadow instead of background-color will enable the state of the radio to be visible when printed (h/t Alvaro Montoro).

Finally, when the input is :checked, we make it visible with scale(1) with a nicely animated result thanks to the transition:

input:checked + .radio__control::before {
  transform: scale(1);
}
Enter fullscreen mode Exit fullscreen mode

And here's a gif of the result using an animated :before element:

demo of the custom radio checked state with :before

Step 5: The :focus State

For the :focus state, we're going to use a double box-shadow in order to leverage currentColor but ensure distinction between the base custom radio button and the :focus style.

Again, we'll use the adjacent sibling combinator:

.radio__input {
  // ...existing styles

  input {
    // ...existing styles

    &:focus + .radio__control {
      box-shadow: 0 0 0 0.05em #fff, 0 0 0.15em 0.1em currentColor;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The order of box-shadow definitions corresponds with their layering, with the first definition being equal to the "top" layer. That means in this rule, we are first creating the appareance of a thin white border, which appears above a feathered out shadow that takes on the value from currentColor.

Here's a gif to demo the :focus appearance:

demo of the custom radio focused state

And with that, the essential styles for a custom radio button are complete! πŸŽ‰

Experimental: Using :focus-within to Style the Label Text

Since the label is not a sibling of the native input, we can't use the :focus state of the input to style it.

An upcoming pseudo selector is :focus-within, and one feature is that it can apply styles to elements that contain an element which has received focus.

The ModernCSS episode on a pure CSS accessible dropdown navigation menu also covered :focus-within.

For now, :focus-within requires a polyfill, so the following styles should be considered an enhancement and not relied on as the only way to provide a visual indication of focus.

The first adjustment we'll make is to add a transition and reduce the opacity of the radio__label:

.radio__label {
  // ...existing styles
  transition: 180ms all ease-in-out;
  opacity: 0.8;
}
Enter fullscreen mode Exit fullscreen mode

Ensure that the reduced opacity still meets appropriate contrast for your color palette.

Then, we'll test for focus by adding a rule for :focus-within on the label (.radio). This means when the native input - which is a child and therefore "within" the label - receives focus, we can style any element within the label while focus is active.

So, we'll slightly bump up the visual size of the label text using scale(), and bring the opacity back up.

.radio {
  // ...existing styles

    &:focus-within {
      .radio__label {
        transform: scale(1.05);
        opacity: 1;
      }
    }
}
Enter fullscreen mode Exit fullscreen mode

Use of scale() prevents the resize from impacting the flow of elements and causing any jittering. The transition makes this nice and smooth, as seen in this gif:

demo of custom radio focus-within state

Demo

Here is the solution altogether, with the first radio demonstrating the :checked state using radial-gradient and the second demonstrating use of :before:

Check out the custom checkbox styling to also learn how to extend styles to the :disabled state, and see how to work with an SVG as a :checked indicator.

Top comments (14)

Collapse
 
alvaromontoro profile image
Alvaro Montoro

Notice that with the default printing settings, backgrounds are removed. If a user prints a page with these radio buttons, they won't be able to know which one was checked. (Inset) Box-shadows are not removed at printing, and they will a similar effect to the radial background.

Collapse
 
5t3ph profile image
Stephanie Eckles

Good tip, it's easy to forget about the scenario of a page being printed!

Collapse
 
5t3ph profile image
Stephanie Eckles

Updated with an alternate option that swaps to use of box-shadow - thanks again!

Collapse
 
mrgrigri profile image
Michael Richins

It would be nice to see the upcoming 'lh' and 'rlh' CSS length values. That way the radio's height and width can be set to 1lh which will align it properly. Look at this blog post regarding the soon-to-be unit.

Collapse
 
5t3ph profile image
Stephanie Eckles

Great thought, I look forward to updating the recommendation in this post when that has better support! You could certainly define it following the em definition to prepare for it being available πŸ‘

Collapse
 
mrgrigri profile image
Michael Richins

Yeah...definitely a future idea.

Collapse
 
paintedsky_ca profile image
PaintedSky

Just to clarify, you're using SCSS not pure CSS here right? I noticed the nested selectors and was kinda confused for a moment there... hehe

Collapse
 
5t3ph profile image
Stephanie Eckles

Ah, you got me on a technicality - that's correct. I need to remember to add a note πŸ˜‰ But it's not using any special features of Sass so it's still true, ha!

Collapse
 
frse97 profile image
Sebastian Fries

Great Tips! I wanted to ask if you have planned to make an article also on the different input types. I'm struggling with input color and such a good explanation would be helpful 😊

Collapse
 
5t3ph profile image
Stephanie Eckles

Thanks! Yes, I will also be covering the other varieties of input and textarea as well as select

Collapse
 
frse97 profile image
Sebastian Fries

Ok awesome :)

Collapse
 
urielbitton profile image
Uriel Bitton

very interesting post thanks for this! :)

Collapse
 
cooljasonmelton profile image
Jason Melton

This rules. Thanks Stephanie

Collapse
 
5t3ph profile image
Stephanie Eckles

πŸ™Œ