Switch
A switch turns a setting on or off, such as Wi-Fi. A native
<input type="checkbox"> with role="switch" sits underneath, and the browser
keeps doing the toggling. Switch is Checkbox without the
indeterminate state or a group: the same labelling, description and error
message (as Field does), read-only mode, controlled use, form
reset and state attributes. Only the role and the drawing differ.
It follows the WAI-ARIA Switch pattern, in its checkbox-based form.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The switches' look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
.list {
display: grid;
gap: 0.5rem;
justify-items: start;
}
</style><div class="list">
<uni-switch>
<label data-uni="switch">
<span data-element="Label">Wi-Fi</span>
<input data-element="Input" type="checkbox" name="wifi" checked />
<span data-element="Indicator"><span data-element="Thumb"></span></span>
</label>
</uni-switch>
<uni-switch>
<label data-uni="switch">
<span data-element="Label">Bluetooth</span>
<input data-element="Input" type="checkbox" name="bluetooth" />
<span data-element="Indicator"><span data-element="Thumb"></span></span>
</label>
</uni-switch>
<uni-switch>
<div data-uni="switch">
<label data-element="Wrapper">
<span data-element="Label">Low power mode</span>
<input data-element="Input" type="checkbox" name="low-power" />
<span data-element="Indicator"><span data-element="Thumb"></span></span>
</label>
<p data-element="Description">Fetches mail less often.</p>
</div>
</uni-switch>
<uni-switch disabled>
<label data-uni="switch">
<span data-element="Label">Airplane mode</span>
<input data-element="Input" type="checkbox" name="airplane" />
<span data-element="Indicator"><span data-element="Thumb"></span></span>
</label>
</uni-switch>
</div>
<output id="log">Flip a switch with the mouse, Space or a tap.</output>
<script src="/demo/switch.js"></script><script>const log = document.getElementById('log');
document.addEventListener('selectedchange', (event) => {
const label = event.target.querySelector('[data-element="Label"]');
log.textContent = `${label.textContent}: selectedchange, ${event.detail}`;
});
</script>The definition is exported as toggleSwitch, since switch is a reserved
word in JavaScript. Its element and markers still say switch:
<uni-switch> and data-uni="switch".
React
import { toggleSwitch } from '@uniaria/core/switch';
import { useBehavior } from '@uniaria/react';
export function WifiSwitch(props: {
isOn: boolean;
onChange(isOn: boolean): void;
}) {
const wifi = useBehavior(toggleSwitch, {
labelElementType: 'span',
isSelected: props.isOn,
onSelectedChange: props.onChange,
});
return (
<label {...wifi.parts.root}>
<input {...wifi.parts.input} name="wifi" />
<span {...wifi.parts.indicator}>
<span data-element="Thumb" />
</span>
<span {...wifi.parts.label}>Wi-Fi</span>
</label>
);
}Vue
<script setup lang="ts">
import { toggleSwitch } from '@uniaria/core/switch';
import { useBehavior } from '@uniaria/vue';
const isOn = defineModel<boolean>({ required: true });
const wifi = useBehavior(toggleSwitch, () => ({
labelElementType: 'span',
isSelected: isOn.value,
onSelectedChange: (isSelected) => (isOn.value = isSelected),
}));
</script>
<template>
<label v-bind="wifi.parts.root">
<input v-bind="wifi.parts.input" name="wifi" />
<span v-bind="wifi.parts.indicator">
<span data-element="Thumb" />
</span>
<span v-bind="wifi.parts.label">Wi-Fi</span>
</label>
</template>HTML
<uni-switch id="wifi">
<label data-uni="switch">
<input data-element="Input" type="checkbox" name="wifi" checked />
<span data-element="Indicator"><span data-element="Thumb"></span></span>
<span data-element="Label">Wi-Fi</span>
</label>
</uni-switch>
<script type="module">
import '@uniaria/elements/switch';
document
.querySelector('#wifi')
.addEventListener('selectedchange', (event) => {
console.log('Wi-Fi', event.detail ? 'on' : 'off');
});
</script>The input’s checked in the markup, or a click before the script loaded,
becomes the element’s starting state.
The indicator part is the track. The thumb is your own markup inside it, named
with data-element="Thumb" like a part, and styled from the track’s state
attributes, such as [data-selected] > [data-element='Thumb']. Thumb isn’t
one of Switch’s parts, so UniAria leaves it alone.
Everything else works as in Checkbox:
- Layouts. A
label(with aspanlabel part, andlabelElementType: 'span'in React and Vue) makes the whole row toggle. It is the root of a plain switch, or thewrapperpart inside the root when the switch has a description or an error, which stay outside thelabel, as in the demo. Adivroot with alabellabel part toggles from the input and the label only. See Checkbox. - Hiding the input. Lay it over the
labelwithopacity: 0, or clip it. Neverdisplay: none, which takes it out of the tab order and the accessibility tree. - Controlled. Pass
isSelected(theisSelectedproperty on the element). A toggle you don’t accept by the end of its event goes back. See State.
- Submission. While on, the form submits the input’s
namewith itsvalue(onwhen it has none). You write both on the input. - Reset. A form reset returns an uncontrolled switch to
defaultSelected, even one set after it rendered; a controlled one gets aselectedchangeasking fordefaultSelected, or without one the state it mounted with. - Disabled. A disabled switch is never submitted, and neither is one
disabled with
isFocusableWhenDisabled. A read-only one is submitted. - Validation. In a Form, the form holds the selected
state under its
nameand checks it on each change:isRequiredfails while it is off.isRequiredwritesaria-required, not the nativerequired, so the browser shows no message of its own. Without a form, you decide when it is invalid and setisInvalid.
See Forms.
| Part | Element | Notes |
|---|---|---|
root |
Any; usually div |
Optional. The outermost element, around the wrapper, the description and the error. No role. Without a wrapper, a label root makes its whole area toggle the switch. |
wrapper |
Any; a label makes its whole area toggle the switch |
Optional. The area around the input, the indicator and the label. No role. |
input |
input |
Required. UniAria writes type="checkbox", role="switch", its id (unless it has one) and ARIA attributes, disabled, and the checked property. You own name, value, form, autofocus, tabindex and the checked attribute. |
indicator |
Any; usually span |
Optional. The track you draw in place of the input, with your thumb inside. Gets aria-hidden="true". |
label |
label by default, any element through labelElementType |
Names the switch. A span inside a label wrapper or root. |
description |
Any | Optional. Outside a label wrapper or root. |
error |
Any | Optional. Outside a label wrapper or root. Hidden unless invalid. |
errorText |
any, inside error |
Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself. |
A part keeps an id you give it, and everything that refers to the part
follows it; UniAria writes an id only on a part that has none (see
Ids).
You own every part’s content. UniAria fills none of them.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
isSelected |
— (property) | boolean |
— | On or off, controlled. Pair it with onSelectedChange. |
defaultSelected |
default-selected |
boolean |
false |
The starting state, uncontrolled. The element adopts the input’s checked. |
isDisabled |
disabled |
boolean |
false |
Native disabled: not focusable, never toggles, not submitted. Hides the error. |
isFocusableWhenDisabled |
focusable-when-disabled |
boolean |
false |
While disabled, aria-disabled instead of disabled: focusable, so a tooltip can explain it, but never toggles or submits. |
isReadOnly |
readonly |
boolean |
false |
aria-readonly="true", and every toggle is cancelled. Focusable and submitted. |
isRequired |
required |
boolean |
false |
aria-required="true", not the native required. |
isInvalid |
invalid |
boolean |
— | aria-invalid="true", and shows and announces the error. Unset in a Form, the form’s errors decide it. |
inputId |
— | string |
generated | The input’s id while the input has none of its own. |
labelElementType |
— (read from the tag) | string |
'label' |
The label part’s tag. 'span' inside a label wrapper or root; only a label gets for. |
labelledBy |
labelled-by |
string |
— | More ids for aria-labelledby, after the label part’s. |
describedBy |
described-by |
string |
— | More ids for aria-describedby, after the description’s and the error’s. |
In a Form, Switch also takes name, form, validate,
validationTrigger, validationDelay, validationMessages, dependsOn,
isEqual and shouldPreserve: see Field props for the
form.
Switch has no value prop and no isIndeterminate: the input’s value is
yours, and a switch is either on or off.
| Event | Callback prop | Detail | When |
|---|---|---|---|
selectedchange |
onSelectedChange |
boolean |
The user toggled the switch (right before the native change), or a form reset asks for its default. |
focuschange |
onFocusChange |
boolean |
The input gained or lost focus. |
selectedchange never fires for a value you set. The custom element
dispatches each event on itself, bubbling, with the value in detail. In
React, UniAria’s part props include the input’s onInput, onClick,
onFocus and onBlur: use the events above rather than replacing them. In
Vue, listeners you add next to v-bind run as well as UniAria’s.
| Field | Type | Meaning |
|---|---|---|
isSelected |
boolean |
On: the controlled value, or the uncontrolled one. |
isFocused |
boolean |
The input has focus. |
isFocusVisible |
boolean |
The input has focus from a keyboard or assistive technology. |
isHovered |
boolean |
A mouse or pen is over the wrapper (without one, the root) or the input. Never while disabled. |
isDisabled |
boolean |
The isDisabled prop, or the form’s. |
isReadOnly |
boolean |
The isReadOnly prop, or the form’s. |
isRequired |
boolean |
The isRequired prop. |
isInvalid |
boolean |
The isInvalid prop, else in a form whether it holds errors. |
isErrorShown |
boolean |
The error part is shown: invalid and not disabled. |
To render something from state in React or Vue, select it, and the component renders again only when that value changes (in Vue, it is a ref):
const isOn = useBehaviorState(wifi, (state) => state.isSelected);On the custom element, read element.instance.getState(). The isSelected
property holds only the value you set, not the current state. See
State.
Every part carries the state attributes, so the track, the label and the messages can be styled without reading state.
| Attribute | When |
|---|---|
data-uni="switch" |
On the root, always. |
data-element="<Part>" |
On every other part, always. |
data-selected |
The switch is on. |
data-hovered |
A mouse or pen is over the wrapper (without one, the root) or the input. |
data-focused |
The input has focus. |
data-focus-visible |
The input has focus from a keyboard or a screen reader. |
data-disabled |
The switch is disabled. |
data-readonly |
The switch is read-only. |
data-invalid |
The switch is invalid. |
data-required |
The switch is required. |
State attributes are present or absent, never "false". There is no pressed
state; use :active. UniAria writes no styles.
The optional @uniaria/styles package styles
every part, as in the demo above, whose CSS tab holds only the page’s layout. It
lays the input over the wrapper, or over a label root, invisible and
clickable, and draws the indicator as a 32 by 20 pixel track. Put your own
<span data-element="Thumb"> in the indicator: the styles slide it to the end
while the switch is on, mirrored in right-to-left text.
| Key | Behavior |
|---|---|
| Space | Toggles, natively. Nothing when read-only or focusable while disabled. |
| Enter | Nothing on the switch: it submits the form, natively. The APG makes Enter optional. |
| Tab | Moves focus, natively. A disabled switch is skipped, unless isFocusableWhenDisabled is set. |
Switch handles no keys itself.
- Spoken output. “switch, Wi-Fi”, then the state: VoiceOver, JAWS and current NVDA say “on” and “off”, other readers “checked” and “not checked”. The description follows the name; “read only”, “required”, “invalid” and “disabled” follow the state. Screen readers announce a toggle themselves, so UniAria announces nothing.
- Keep the label fixed. The state is announced, so a label that changes (“Wi-Fi on”) reads the state twice. Put “On” and “Off” text, if you show it, inside the indicator, which is hidden from screen readers.
- A set of switches is your markup: a
fieldsetwith alegend, or arole="group"named by its heading. There is no switch group. - Indeterminate. Don’t set the input’s
indeterminateproperty: Chromium exposes an indeterminate switch as “mixed”, which ARIA treats as off. - Disabled with a reason. To explain a disabled switch with a
Tooltip, set
isFocusableWhenDisabledand link the tooltip through thetooltipprop (React and Vue) or attribute (an id). - Focus. A press on the input or its label focuses the input in every
browser, Safari included, and that focus isn’t visible: draw the ring on
data-focus-visible.
- A native checkbox with
role="switch", as in React Aria and the APG’s checkbox-based example. UniAria never writesaria-checked. - Enter doesn’t toggle, as in React Aria and Spectrum Web Components.
- One
indicatorpart. Zag hascontrolandthumbparts; here the thumb is your markup inside the track. - Description, error, required and invalid stay. React Aria
Components’
Switchleaves them out. selectedchangeis React Aria’sonChange, with the sameisSelectedanddefaultSelected.