It’s entirely possible to build custom checkboxes, radio buttons, and toggle switches these days, while staying semantic and accessible. We don’t even need a single line of JavaScript or extra HTML elements! It’s actually gotten easier lately than it has been in the past. Let’s take a look.
Here’s where we’ll end up:
Things sure have gotten easier than they were!
The reason is that we can finally style the ::before
and ::after
pseudo-elements on the <input>
tag itself. This means we can keep and style an <input>
and won’t need any extra elements. Before, we had to rely on the likes of an extra <div>
or <span>
, to pull off a custom design.
Let’s look at the HTML
Nothing special here. We can style our inputs with just this HTML:
<!-- Checkbox -->
<input type="checkbox">
<!-- Radio -->
<input type="radio">
<!-- Switch -->
<input type="checkbox" class="switch">
That’s it for the HTML part, but of course it’s recommended to have name and id attributes, plus a matching <label>
element:
<!-- Checkbox -->
<input type="checkbox" name="c1" id="c1">
<label for="c1">Checkbox</label>
<!-- Radio -->
<input type="radio" name="r1" id="r1">
<label for="r1">Radio</label>
<!-- Switch -->
<input type="checkbox" class="switch" name="s1" id="s1">
<label for="s1">Switch</label>
Getting into the styling
First of all, we check for the support of appearance: none;
, including it’s prefixed companions. The appearance
property is key because it is designed to remove a browser’s default styling from an element. If the property isn’t supported, the styles won’t apply and default input styles will be shown. That’s perfectly fine and a good example of progressive enhancement at play.
@supports(-webkit-appearance: none) or (-moz-appearance: none) {
input[type='checkbox'],
input[type='radio'] {
-webkit-appearance: none;
-moz-appearance: none;
}
}
As it stands today, appearance is a working draft, but here’s what support looks like:
This browser support data is from Caniuse, which has more detail. A number indicates that browser supports the feature at that version and up.
Desktop
Chrome | Firefox | IE | Edge | Safari |
---|---|---|---|---|
83* | 80 | No | 83* | 15.4 |
Mobile / Tablet
Android Chrome | Android Firefox | Android | iOS Safari |
---|---|---|---|
113 | 113 | 113 | 15.4 |
Like links, we’ve gotta consider different interactive states with form elements. We’ll consider these when styling our elements:
:checked
:hover
:focus
:disabled
For example, here’s how we can style our toggle input, create the knob, and account for the :checked
state:
/* The toggle container */
.switch {
width: 38px;
border-radius: 11px;
}
/* The toggle knob */
.switch::after {
left: 2px;
top: 2px;
border-radius: 50%;
width: 15px;
height: 15px;
background: var(--ab, var(--border));
transform: translateX(var(--x, 0));
}
/* Change color and position when checked */
.switch:checked {
--ab: var(--active-inner);
--x: 17px;
}
/* Drop the opacity of the toggle knob when the input is disabled */
.switch:disabled:not(:checked)::after {
opacity: .6;
}
We are using the <input>
element like a container. The knob inside of the input is created with the ::after
pseudo-element. Again, no more need for extra markup!
If you crack open the styles in the demo, you’ll see that we’re defining some CSS custom properties because that’s become such a nice way to manage reusable values in a stylesheet:
@supports(-webkit-appearance: none) or (-moz-appearance: none) {
input[type='checkbox'],
input[type='radio'] {
--active: #275EFE;
--active-inner: #fff;
--focus: 2px rgba(39, 94, 254, .25);
--border: #BBC1E1;
--border-hover: #275EFE;
--background: #fff;
--disabled: #F6F8FF;
--disabled-inner: #E1E6F9;
}
}
But there’s another reason we’re using custom properties — they work well for updating values based on the state of the element! We won’t go into full detail here, but here’s an example how we can use custom properties for different states.
/* Default */
input[type='checkbox'],
input[type='radio'] {
--active: #275EFE;
--border: #BBC1E1;
border: 1px solid var(--bc, var(--border));
}
/* Override defaults */
input[type='checkbox']:checked,
input[type='radio']:checked {
--b: var(--active);
--bc: var(--active);
}
/* Apply another border color on hover if not checked & not disabled */
input[type='checkbox']:not(:checked):not(:disabled):hover,
input[type='radio']:not(:checked):not(:disabled):hover {
--bc: var(--border-hover);
}
For accessibility, we ought to add a custom focus style. We are removing the default outline because it can’t be rounded like the rest of the things we’re styling. But a border-radius along with a box-shadow can make for a rounded style that works just like an outline.
input[type='checkbox'],
input[type='radio'] {
--focus: 2px rgba(39, 94, 254, .25);
outline: none;
transition: box-shadow .2s;
}
input[type='checkbox']:focus,
input[type='radio']:focus {
box-shadow: 0 0 0 var(--focus);
}
It’s also possible to align and style the <label>
element which directly follows the <input>
element in the HTML:
<input type="checkbox" name="c1" id="c1">
<label for="c1">Checkbox</label>
input[type='checkbox'] + label,
input[type='radio'] + label {
display: inline-block;
vertical-align: top;
/* Additional styling */
}
input[type='checkbox']:disabled + label,
input[type='radio']:disabled + label {
cursor: not-allowed;
}
Here’s that demo again:
Hopefully, you’re seeing how nice it is to create custom form styles these days. It requires less markup, thanks to pseudo-elements that are directly on form inputs. It requires less fancy style switching, thanks to custom properties. And it has pretty darn good browser support, thanks to @supports
.
All in all, this is a much more pleasant developer experience than we’ve had to deal with in the past!
I note that you stayed away from the :-)
That’s still not easy (at least not in a nice way).
Hi, could you maybe provide a reference to your statement:
„ The reason is that we can finally style the ::before and ::after pseudo-elements on the tag itself.“
Haven’t found anything but a Stack Overflow feed on this…
Yes, I was wondering that as well. When did browsers start supporting this, and how can we check for support for pseudo elements on inputs?
Browsers support this from more than 2 years. This is how I built Native Elements
https://native-elements.stackblitz.io
very little information on that topic out there – sadly …
it should be supported if
appearance: none;
is also supported, so we got a box modelgreat that we can check for support with
@supports
Yes, more information on that would be really nice!
It seems to only work on certain inputs like radio and checkbox, but does not work on others like text and button.
Nice demo Aaron. However, for toggle switches, I would personally still approach them as described in inclusive components or Mozilla docs.
I’m afraid I can’t pass your toggle switch since it’s not draggable. Love how simple this has gotten though, no extra markup, no weird style hacks.
Turning an input into a container element by adding an end-tag () is not valid HTML. The browser may accept it for some reason, but your document will not validate.
I have been longing for the day when I could insert pseudo’s into non-container elements like image or input, but it seems we will have to wait a little longer, unless we are prepared to write non-valid markup. I, for one, am not…
OK, but I don’t see that happening in this article do you?
There are unclosed inputs (totally valid), like:
But not “container” inputs, which I agree, are not valid, which would be like…
My apologies! I looked at the generated code in the inspector, and there the input had an end-tag. Apparently the browser does that when someone inserts something into a non-container element.
Lesson learned: never trust the code inspector too much…
Hey! I forked your code to React components. Take a look!
https://codesandbox.io/s/custom-css-only-form-inputs-react-hofx5
Doesn’t work in Microsoft Edge 44 – I don’t know why. Bootstrap’s custom checkboxes and radios work in Edge 44: https://getbootstrap.com/docs/4.4/components/forms/#checkboxes-and-radios-1
Others also have this problem in Edge: https://stackoverflow.com/questions/53657488/how-do-i-make-this-checkbox-styling-work-in-edge
There is a problem if one wants to have smaller controls like in Bootstrap. If I decrease the height of controls from 21px to 16px, then radio button artifacts will start to be visible on displays with Windows scaling turned on. I described the issue here: https://stackoverflow.com/questions/61760484/unable-to-center-an-element-when-windows-scaling-is-enabled-125
You can also observe the issue here: https://codepen.io/iwis/pen/eYpjeYr – it is the original code with an added button. However, here it is not a problem, because the control is bigger (21px), thus the radio button isn’t so much distorted.
What can be done with this problem in smaller controls?
Hi Aaron,
Thank you so much for sharing this helpful post. I forked the CodePen to teach myself how it works. Along the way I found it helped to make some changes, and I thought I’d share them. I welcome feedback. I also have a question.
The content for both checkbox and radio is an empty string. Is the checkmark default content for :after on an input of type=”checkbox”? Likewise, is a box default for a radio input? If so, can you please point me to the reference where this is documented so I can read up on how this works?
Here’s my CodePen in case it helps anyone: https://codepen.io/greatgraphicdesign/pen/MWaLrWp
The changes I made…
– Removed custom properties because they made it harder for me to follow this.
– Pulled the CSS reset code out in front of the @support since that’s where I would use it.
– Added .focus and tabIndex for accessibility. (Outlines only appear on focus, AFAIK.)
– Removed ul/li tags for clarity. I discovered the need for vertical-align: middle this way.
– Wrapped labels around items. This is just my preference for these types of inputs. The way you have it makes it possible to style the cursor, which is cool. You’ll still need a script to add/remove the disabled attribute. I’m using a .point class on the enabled item labels which would have to be added/removed with a script as well.
Thanks again for this post!