Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

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.

Switches
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>

Usage

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 a span label part, and labelElementType: 'span' in React and Vue) makes the whole row toggle. It is the root of a plain switch, or the wrapper part inside the root when the switch has a description or an error, which stay outside the label, as in the demo. A div root with a label label part toggles from the input and the label only. See Checkbox.
  • Hiding the input. Lay it over the label with opacity: 0, or clip it. Never display: none, which takes it out of the tab order and the accessibility tree.
  • Controlled. Pass isSelected (the isSelected property on the element). A toggle you don’t accept by the end of its event goes back. See State.

In a form

  • Submission. While on, the form submits the input’s name with its value (on when 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 a selectedchange asking for defaultSelected, 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 name and checks it on each change: isRequired fails while it is off. isRequired writes aria-required, not the native required, so the browser shows no message of its own. Without a form, you decide when it is invalid and set isInvalid.

See Forms.

Anatomy

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.

Props

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.

Events

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.

State

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.

Styling

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.

Default 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.

Keyboard

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.

Accessibility

  • 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 fieldset with a legend, or a role="group" named by its heading. There is no switch group.
  • Indeterminate. Don’t set the input’s indeterminate property: 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 isFocusableWhenDisabled and link the tooltip through the tooltip prop (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.

Compared with other libraries

  • A native checkbox with role="switch", as in React Aria and the APG’s checkbox-based example. UniAria never writes aria-checked.
  • Enter doesn’t toggle, as in React Aria and Spectrum Web Components.
  • One indicator part. Zag has control and thumb parts; here the thumb is your markup inside the track.
  • Description, error, required and invalid stay. React Aria Components’ Switch leaves them out.
  • selectedchange is React Aria’s onChange, with the same isSelected and defaultSelected.