A foundational overview of how to build a responsive and accessible switch component.
In this post I want to share thinking on a way to build switch components. Try the demo.
If you prefer video, here's a YouTube version of this post:
Overview
A switch functions similar to a checkbox but explicitly represents boolean on and off states.
This demo uses <input type="checkbox" role="switch">
for the majority of its
functionality, which has the advantage of not needing CSS or JavaScript to be
fully functional and accessible. Loading CSS brings support for right-to-left
languages, verticality, animation and more. Loading JavaScript makes the switch
draggable and tangible.
Custom properties
The following variables represent the various parts of the switch and their
options. As the top-level class, .gui-switch
contains custom properties used
throughout the component children, and entry points for centralized
customization.
Track
The length (--track-size
), padding, and two colors:
.gui-switch {
--track-size: calc(var(--thumb-size) * 2);
--track-padding: 2px;
--track-inactive: hsl(80 0% 80%);
--track-active: hsl(80 60% 45%);
--track-color-inactive: var(--track-inactive);
--track-color-active: var(--track-active);
@media (prefers-color-scheme: dark) {
--track-inactive: hsl(80 0% 35%);
--track-active: hsl(80 60% 60%);
}
}
Thumb
The size, background color, and interaction highlight colors:
.gui-switch {
--thumb-size: 2rem;
--thumb: hsl(0 0% 100%);
--thumb-highlight: hsl(0 0% 0% / 25%);
--thumb-color: var(--thumb);
--thumb-color-highlight: var(--thumb-highlight);
@media (prefers-color-scheme: dark) {
--thumb: hsl(0 0% 5%);
--thumb-highlight: hsl(0 0% 100% / 25%);
}
}
Reduced motion
To add a clear alias and reduce repetition, a reduced motion preference user media query can be put into a custom property with the PostCSS plugin based on this draft spec in Media Queries 5:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
Markup
I chose to wrap my <input type="checkbox" role="switch">
element with a
<label>
, bundling their relationship to avoid checkbox and label association
ambiguity, while giving the user the ability to interact with the label to
toggle the input.
<label for="switch" class="gui-switch">
Label text
<input type="checkbox" role="switch" id="switch">
</label>
<input type="checkbox">
comes prebuilt with an
API
and state. The
browser manages the
checked
property and input
events
such as oninput
and onchanged
.
Layouts
Flexbox, grid, and custom properties are critical in maintaining the styles of this component. They centralize values, give names to otherwise ambiguous calculations or areas, and enable a small custom property API for easy component customizations.
.gui-switch
The top-level layout for the switch is flexbox. The class .gui-switch
contains
the private and public custom properties the children use to compute their
layouts.
.gui-switch {
display: flex;
align-items: center;
gap: 2ch;
justify-content: space-between;
}
Extending and modifying the flexbox layout is like changing any flexbox layout.
For example, to put labels above or below a switch, or to change the
flex-direction
:
<label for="light-switch" class="gui-switch" style="flex-direction: column">
Default
<input type="checkbox" role="switch" id="light-switch">
</label>
Track
The checkbox input is styled as a switch track by removing its normal
appearance: checkbox
and supplying its own size instead:
.gui-switch > input {
appearance: none;
inline-size: var(--track-size);
block-size: var(--thumb-size);
padding: var(--track-padding);
flex-shrink: 0;
display: grid;
align-items: center;
grid: [track] 1fr / [track] 1fr;
}
The track also creates a one by one single cell grid track area for a thumb to claim.
Thumb
The style appearance: none
also removes the visual checkmark supplied by the
browser. This component uses a
pseudo-element and the :checked
pseudo-class on the input to
replace this visual indicator.
The thumb is a pseudo-element child attached to the input[type="checkbox"]
and
stacks on top of the track instead of below it by claiming the grid area
track
:
.gui-switch > input::before {
content: "";
grid-area: track;
inline-size: var(--thumb-size);
block-size: var(--thumb-size);
}
Styles
Custom properties enable a versatile switch component that adapts to color schemes, right-to-left languages and motion preferences.
Touch interaction styles
On mobile, browsers add tap highlights and text selection features to labels and
inputs. These negatively affected the style and visual interaction feedback that
this switch needed. With a few lines of CSS I can remove those effects and add
my own cursor: pointer
style:
.gui-switch {
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
It's not always advisable to remove those styles, as they can be valuable visual interaction feedback. Be sure to provide custom alternatives if you remove them.
Track
This element's styles are mostly about its shape and color, which it accesses
from the parent .gui-switch
via the
cascade.
.gui-switch > input {
appearance: none;
border: none;
outline-offset: 5px;
box-sizing: content-box;
padding: var(--track-padding);
background: var(--track-color-inactive);
inline-size: var(--track-size);
block-size: var(--thumb-size);
border-radius: var(--track-size);
}
A wide variety of customization options for the switch track come from four
custom properties. border: none
is added since appearance: none
doesn't
remove the borders from the checkbox on all browsers.
Thumb
The thumb element is already on the right track
but needs circle styles:
.gui-switch > input::before {
background: var(--thumb-color);
border-radius: 50%;
}
Interaction
Use custom properties to prepare for interactions which will show hover highlights and thumb position changes. The user's preference is also checked before transitioning the motion or hover highlight styles.
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
Thumb position
Custom properties provide a single source mechanism for positioning the thumb in
the track. At our disposal are the track and thumb sizes which we'll use in
calculations to keep the thumb properly offset and between within the track:
0%
and 100%
.
The input
element owns the position variable --thumb-position
, and the thumb
pseudo element uses it as a translateX
position:
.gui-switch > input {
--thumb-position: 0%;
}
.gui-switch > input::before {
transform: translateX(var(--thumb-position));
}
We're now free to change --thumb-position
from CSS and the pseudo-classes
provided on checkbox elements. Since we conditionally set transition: transform
var(--thumb-transition-duration) ease
earlier on this element, these changes
may animate when changed:
/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
}
/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
}
I thought this decoupled orchestration worked out well. The thumb element is
only concerned with one style, a translateX
position. The input can manage all
the complexity and calculations.
Vertical
Supporting was done with a modifier class -vertical
which adds a rotation with
CSS transforms to the input
element.
A 3D rotated element does not change the overall height of the component though,
which can throw off block layout. Account for this using the --track-size
and
--track-padding
variables. Calculate the minimum amount of space required for
a vertical button to flow in layout as expected:
.gui-switch.-vertical {
min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));
& > input {
transform: rotate(-90deg);
}
}
(RTL) right-to-left
A CSS friend, Elad Schecter, and I prototyped together a slide out side menu using CSS transforms that handled right-to-left languages by flipping a single variable. We did this because there are no logical property transforms in CSS, and there may never be. Elad had the great idea of using a custom property value to invert percentages, to allow single location management of our own custom logic for logical transforms. I used this same technique in this switch and I think it worked out great:
.gui-switch {
--isLTR: 1;
&:dir(rtl) {
--isLTR: -1;
}
}
A custom property called --isLTR
initially holds a value of 1
, meaning it's
true
since our layout is left-to-right by default. Then, using the CSS
pseudo class :dir()
,
the value is set to -1
when the component is within a right-to-left layout.
Put --isLTR
into action by using it within a calc()
inside of a transform:
.gui-switch.-vertical > input {
transform: rotate(-90deg);
transform: rotate(calc(90deg * var(--isLTR) * -1));
}
Now the rotation of the vertical switch accounts for the opposite side position required by the right-to-left layout.
The translateX
transforms on the thumb pseudo-element also need updated to
account for the opposite side requirement:
.gui-switch > input:checked {
--thumb-position: calc(var(--track-size) - 100%);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
.gui-switch > input:indeterminate {
--thumb-position: calc(
(var(--track-size) / 2) - (var(--thumb-size) / 2)
);
--thumb-position: calc(
((var(--track-size) / 2) - (var(--thumb-size) / 2))
* var(--isLTR)
);
}
While this approach won't work to solve all needs regarding a concept like logical CSS transforms, it does offer some DRY principles for many use cases.
States
Using the built in input[type="checkbox"]
wouldn't be complete without
handling the various states it can be in: :checked
, :disabled
,
:indeterminate
and :hover
. :focus
was intentionally left alone, with an
adjustment only made to its offset; the focus ring looked great on Firefox and
Safari:
Checked
<label for="switch-checked" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>
This state represents the on
state. In this state, the input "track"
background is set to the active color and the thumb position is set to "the
end".
.gui-switch > input:checked {
background: var(--track-color-active);
--thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}
Disabled
<label for="switch-disabled" class="gui-switch">
Default
<input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>
A :disabled
button not only visually looks different, but also should make the
element immutable.Interaction immutability is free from the browser, but the
visual states need styles due to the use of appearance: none
.
.gui-switch > input:disabled {
cursor: not-allowed;
--thumb-color: transparent;
&::before {
cursor: not-allowed;
box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);
@media (prefers-color-scheme: dark) { & {
box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
}}
}
}
This state is tricky since it needs dark and light themes with both disabled and checked states. I stylistically chose minimal styles for these states to ease the maintenance burden of the combinations of styles.
Indeterminate
An often forgotten state is :indeterminate
, where a checkbox is neither
checked or unchecked. This is a fun state, it's inviting and unassuming. A good
reminder that boolean states can have sneaky in between states.
It is tricky to set a checkbox to indeterminate, only JavaScript can set it:
<label for="switch-indeterminate" class="gui-switch">
Indeterminate
<input type="checkbox" role="switch" id="switch-indeterminate">
<script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>
Since the state, to me, is unassuming and inviting, it felt appropriate to put the switch thumb position in the middle:
.gui-switch > input:indeterminate {
--thumb-position: calc(
calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
* var(--isLTR)
);
}
Hover
Hover interactions should provide visual support for connected UI and also provide direction towards interactive UI. This switch highlights the thumb with a semi-transparent ring when the label or the input are hovered. This hover animation then provides direction towards the interactive thumb element.
The "highlight" effect is done with box-shadow
. On hover, of an non-disabled input, increase the size of --highlight-size
. If the user is OK with motion, we transition the box-shadow
and see it grow, if they're not ok with motion, the highlight appears instantly:
.gui-switch > input::before {
box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);
@media (--motionOK) { & {
transition:
transform var(--thumb-transition-duration) ease,
box-shadow .25s ease;
}}
}
.gui-switch > input:not(:disabled):hover::before {
--highlight-size: .5rem;
}
JavaScript
To me, a switch interface can feel uncanny in its attempt to emulate a physical interface, especially this kind with a circle inside a track. iOS got this right with their switch, you can drag them side to side, and it's very satisfying to have the option. Conversely, a UI element can feel inactive if a drag gesture is attempted and nothing happens.
Draggable thumbs
The thumb pseudo-element receives its position from the .gui-switch > input
scoped var(--thumb-position)
, JavaScript can supply an inline style value on
the input to dynamically update the thumb position making it appear to follow
the pointer gesture. When the pointer is released, remove the inline styles and
determine if the drag was closer to off or on by using the custom property
--thumb-position
. This is the backbone of the solution; pointer events
conditionally tracking pointer positions to modify CSS custom properties.
Since the component was already 100% functional before this script is showing up, it does take quite a bit of work to maintain the existing behavior, like clicking a label to toggle the input. Our JavaScript shouldn't add features at the expense of existing features.
touch-action
Dragging is a gesture, a custom one, which makes it a great candidate for
touch-action
benefits. In the case of this switch, a horizontal gesture should
be handled by our script, or a vertical gesture captured for the vertical switch
variant. With touch-action
we can tell the browser what gestures to handle on
this element, so a script can handle a gesture without competition.
The following CSS instructs the browser that when a pointer gesture starts from within this switch track, handle vertical gestures, do nothing with horizontal ones:
.gui-switch > input {
touch-action: pan-y;
}
The desired result is a horizontal gesture that doesn't also pan or scroll the page. A pointer can vertically scroll start from within the input and scroll the page, but horizontal ones are custom handled.
Pixel value style utilities
On setup and during drag, various computed number values will need to be grabbed
from elements. The following JavaScript functions return computed pixel values
given a CSS property. It's used in the setup script like this
getStyle(checkbox, 'padding-left')
.
const getStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}
const getPseudoStyle = (element, prop) => {
return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}
export {
getStyle,
getPseudoStyle,
}
Notice how window.getComputedStyle()
accepts a second argument, a target pseudo element. Pretty neat that JavaScript can read so many values from elements, even from pseudo elements.
dragging
This is a core moment for the drag logic and there are a few things with noting from the function event handler:
const dragging = event => {
if (!state.activethumb) return
let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
let directionality = getStyle(state.activethumb, '--isLTR')
let track = (directionality === -1)
? (state.activethumb.clientWidth * -1) + thumbsize + padding
: 0
let pos = Math.round(event.offsetX - thumbsize / 2)
if (pos < bounds.lower) pos = 0
if (pos > bounds.upper) pos = bounds.upper
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}
The script hero is state.activethumb
, the little circle this script is
positioning along with a pointer. The switches
object is a Map()
where the
keys are .gui-switch
's and the values are cached bounds and sizes that keep
the script efficient. Right-to-left is handled using the same custom property
that CSS is --isLTR
, and is able to use it to invert logic and continue
supporting RTL. The event.offsetX
is valuable as well, as it contains a delta
value useful for positioning the thumb.
state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
This final line of CSS sets the custom property used by the thumb element. This
value assignment would otherwise transition over time, but a previous pointer
event has temporarily set --thumb-transition-duration
to 0s
, removing what
would have been a sluggish interaction.
dragEnd
In order for the user to be allowed to drag far outside the switch and let go, a global window event needed registered:
window.addEventListener('pointerup', event => {
if (!state.activethumb) return
dragEnd(event)
})
I think it's very important that a user has freedom to drag loosely and have the interface be smart enough to account for it. It didn't take much to handle it with this switch, but it did need careful consideration during the development process.
const dragEnd = event => {
if (!state.activethumb) return
state.activethumb.checked = determineChecked()
if (state.activethumb.indeterminate)
state.activethumb.indeterminate = false
state.activethumb.style.removeProperty('--thumb-transition-duration')
state.activethumb.style.removeProperty('--thumb-position')
state.activethumb.removeEventListener('pointermove', dragging)
state.activethumb = null
padRelease()
}
Interaction with the element has completed, time to set the input checked
property and remove all the gesture events. The checkbox is changed with
state.activethumb.checked = determineChecked()
.
determineChecked()
This function, called by dragEnd
, determines where the thumb current lies
within the bounds of its track and returns true if it is equal to or over
halfway along the track:
const determineChecked = () => {
let {bounds} = switches.get(state.activethumb.parentElement)
let curpos =
Math.abs(
parseInt(
state.activethumb.style.getPropertyValue('--thumb-position')))
if (!curpos) {
curpos = state.activethumb.checked
? bounds.lower
: bounds.upper
}
return curpos >= bounds.middle
}
Extra thoughts
The drag gesture incurred a bit of code debt due to the initial HTML structure
chosen, mostly notably wrapping the input in a label. The label, being a parent
element, would receive click interactions after the input. At the end of the
dragEnd
event, you may have noticed padRelease()
as an odd sounding
function.
const padRelease = () => {
state.recentlyDragged = true
setTimeout(_ => {
state.recentlyDragged = false
}, 300)
}
This is to account for the label getting this later click, as it would uncheck, or check, the interaction a user performed.
If I was to do this again, I might consider adjusting DOM with JavaScript during the UX upgrade, as to create an element that handles label clicks itself and doesn't fight with built-in behavior.
This kind of JavaScript is my least favorite to write, I don't want to manage conditional event bubbling:
const preventBubbles = event => {
if (state.recentlyDragged)
event.preventDefault() && event.stopPropagation()
}
Conclusion
This teeny switch component ended up being the most work of all GUI Challenges so far! Now that you know how I did it, how would you‽ 🙂
Let's diversify our approaches and learn all the ways to build on the web. Create a demo, tweet me links, and I'll add it to the community remixes section below!
Community remixes
- @KonstantinRouda with a custom element: demo and code.
- @jhvanderschee with a button: Codepen.
Resources
Find the .gui-switch
source code on
GitHub.