ToggleButton
A toggle button switches something on or off, such as Bold in an editor
toolbar or Mute in a player. It is a Button with one more piece
of state, isSelected, which UniAria writes to aria-pressed. A native
<button> sits underneath. Every press toggles the state and reports
selectedchange; the label stays the same in both states.
It follows the toggle-button variant of the WAI-ARIA Button pattern.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The toggle buttons' 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);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
#sample {
margin: 0;
font-size: 1.25rem;
}
.bold {
font-weight: 700;
}
.italic {
font-style: italic;
}
.underline {
text-decoration: underline;
}
</style><div class="row">
<uni-toggle-button>
<button data-uni="toggle-button">Bold</button>
</uni-toggle-button>
<uni-toggle-button default-selected>
<button data-uni="toggle-button" aria-pressed="true">Italic</button>
</uni-toggle-button>
<uni-toggle-button>
<button data-uni="toggle-button">Underline</button>
</uni-toggle-button>
<uni-toggle-button disabled>
<button data-uni="toggle-button">Strikethrough</button>
</uni-toggle-button>
</div>
<p id="sample" class="italic">The quick brown fox jumps over the lazy dog.</p>
<output id="log">Toggle a style with the mouse, a key or a tap.</output>
<script src="/demo/toggle-button.js"></script><script>const sample = document.getElementById('sample');
const log = document.getElementById('log');
document.addEventListener('selectedchange', (event) => {
const name = event.target.textContent.trim();
sample.classList.toggle(name.toLowerCase(), event.detail);
log.textContent = `${name}: selectedchange, ${event.detail}`;
});
</script>React
import { toggleButton } from '@uniaria/core/toggle-button';
import { useBehavior } from '@uniaria/react';
export function BoldButton(props: {
isBold: boolean;
onBoldChange(isBold: boolean): void;
}) {
const bold = useBehavior(toggleButton, {
isSelected: props.isBold,
onSelectedChange: props.onBoldChange,
shouldPreventFocusOnPress: true,
});
return <button {...bold.parts.root}>Bold</button>;
}Vue
<script setup lang="ts">
import { toggleButton } from '@uniaria/core/toggle-button';
import { useBehavior } from '@uniaria/vue';
const isBold = defineModel<boolean>({ required: true });
const bold = useBehavior(toggleButton, () => ({
isSelected: isBold.value,
onSelectedChange: (isSelected) => (isBold.value = isSelected),
shouldPreventFocusOnPress: true,
}));
</script>
<template>
<button v-bind="bold.parts.root">Bold</button>
</template>HTML
<uni-toggle-button id="mute" default-selected>
<button data-uni="toggle-button" aria-pressed="true">Mute</button>
</uni-toggle-button>
<script type="module">
import '@uniaria/elements/toggle-button';
document
.querySelector('#mute')
.addEventListener('selectedchange', (event) => {
document.querySelector('video').muted = event.detail;
});
</script>The aria-pressed in the markup only matters before the script runs, so the
button is announced correctly from the start. UniAria owns the attribute once
the element upgrades.
Uncontrolled, the toggle button keeps its own state: seed it with
defaultSelected (the default-selected attribute) and listen to
selectedchange. Controlled, you pass isSelected and selectedchange only
reports the request: the state changes when you pass the new value back. A
value you set yourself never fires selectedchange. See
State.
React
const [isMuted, setMuted] = useState(false);
const mute = useBehavior(toggleButton, {
isSelected: isMuted,
onSelectedChange: setMuted,
});Vue
<script setup lang="ts">
const isMuted = ref(false);
const mute = useBehavior(toggleButton, () => ({
isSelected: isMuted.value,
onSelectedChange: (isSelected) => (isMuted.value = isSelected),
}));
</script>
<template>
<button v-bind="mute.parts.root">Mute</button>
</template>HTML
const mute = document.querySelector('#mute');
mute.isSelected = false; // controlled from now on
mute.addEventListener('selectedchange', (event) => {
if (canMute()) mute.isSelected = event.detail;
});Set the isSelected property to control the element. Setting it to
undefined makes it uncontrolled again. Reading it gives the current state
either way.
A toggle button takes Button’s label and description parts: the label
alone names it and the description describes it, while aria-pressed still
gives its state. A frame around it holds its extras, the controls beside
it that can’t be inside it, such as a button that opens its settings. See
Button for how they behave.
React
const subtitles = useBehavior(toggleButton, { defaultSelected: true });
const language = useBehavior(button, { onPress: openLanguages });
return (
<div {...subtitles.parts.frame}>
<button {...subtitles.parts.root}>
<span {...subtitles.parts.label}>Subtitles</span>
<span {...subtitles.parts.description}>English</span>
</button>
<span {...subtitles.parts.extras}>
<button
{...language.parts.root}
data-variant="quiet"
aria-label="Subtitle language"
>
▾
</button>
</span>
</div>
);Vue
<script setup lang="ts">
const subtitles = useBehavior(toggleButton, { defaultSelected: true });
const language = useBehavior(button, { onPress: openLanguages });
</script>
<template>
<div v-bind="subtitles.parts.frame">
<button v-bind="subtitles.parts.root">
<span v-bind="subtitles.parts.label">Subtitles</span>
<span v-bind="subtitles.parts.description">English</span>
</button>
<span v-bind="subtitles.parts.extras">
<button
v-bind="language.parts.root"
data-variant="quiet"
aria-label="Subtitle language"
>
▾
</button>
</span>
</div>
</template>HTML
<uni-toggle-button default-selected>
<div data-uni-part="toggle-button-frame">
<button data-uni="toggle-button" aria-pressed="true">
<span data-element="Label">Subtitles</span>
<span data-element="Description">English</span>
</button>
<span data-uni-part="toggle-button-extras">
<uni-button>
<button
data-uni="button"
data-variant="quiet"
aria-label="Subtitle language"
>
▾
</button>
</uni-button>
</span>
</div>
</uni-toggle-button>
<script type="module">
import '@uniaria/elements/button';
import '@uniaria/elements/toggle-button';
</script>The extra’s press events bubble through the <uni-toggle-button>, so a
press listener there checks event.target. selectedchange comes from the
toggle button alone.
Hover over the frame is the toggle button’s hover, and a press on an extra never toggles it.
| Part | Element | Notes |
|---|---|---|
root |
button by default, any element through elementType |
You own the content, id, your classes and labelling (aria-label, aria-labelledby, aria-describedby) unless a label or description part takes it over. The root is always type="button". |
label |
any, inside the root; usually span |
Optional. While rendered, it alone names the toggle button: the root’s aria-labelledby is its id. |
description |
any, inside the root; usually span |
Optional. While rendered, it describes the toggle button: the root’s aria-describedby starts with its id, before a tooltip’s. |
frame |
any, outside the root; usually div |
Optional. Around the root and the extras. It carries the root’s state attributes, data-selected included, and hover over it is the toggle button’s. No role. |
extras |
any, inside the frame; usually span |
Optional. Beside the root, never inside it: your own Buttons, badges and counts, in reading order. It carries the root’s state attributes. No role. |
A root that isn’t a button or input gets role="button", a tabindex
and keyboard handling, as in Button. The custom element has no role of its
own and never takes focus.
The label and the description work as in Button:
UniAria writes their id unless they have one, and the root’s references
only while they are rendered. The frame and the extras sit outside the root,
so they are marked with data-uni-part instead of data-element.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
isSelected |
— (property) | boolean |
— | The state, controlled. Pair it with onSelectedChange. |
defaultSelected |
default-selected |
boolean |
false |
The starting state, uncontrolled. |
isDisabled |
disabled |
boolean |
false |
Native disabled on a button; aria-disabled="true" and no tab stop elsewhere. Keeps its state and never toggles. |
isFocusableWhenDisabled |
focusable-when-disabled |
boolean |
false |
While disabled, keeps the root focusable with aria-disabled instead of disabled. It still never toggles. |
elementType |
— (read from the tag) | string |
'button' |
The tag you render as the root. React and Vue warn in development when it doesn’t match. |
shouldExcludeFromTabOrder |
exclude-from-tab-order |
boolean |
false |
tabindex="-1": focusable by pointer and script, skipped by Tab. |
shouldPreventFocusOnPress |
prevent-focus-on-press |
boolean |
false |
A pointer press toggles and leaves focus where it was, such as in the editor a formatting toggle acts on. |
There is no href and no type: a link can’t be pressed, and the root never
submits a form. ToggleButton has no name or value either; use a
Checkbox or a Switch for a value a form
submits.
| Event | Callback prop | Detail | When |
|---|---|---|---|
pressstart |
onPressStart |
PressEvent |
A press begins, as in Button. |
pressend |
onPressEnd |
PressEvent |
A press ends, whether or not it activates. |
selectedchange |
onSelectedChange |
boolean |
A press asks to toggle. The detail is the requested value, the opposite of the current one. |
press |
onPress |
PressEvent |
The button activates, after selectedchange. |
focuschange |
onFocusChange |
boolean |
The root gained or lost focus. |
On activation the order is pressstart, pressend, selectedchange,
press, so a press handler that reads the state sees the value just
toggled. A cancelled press (released outside the root, or a scroll took over
the touch) fires pressstart and pressend only and leaves the state alone.
PressEvent is Button’s, exported as a type from
@uniaria/core/toggle-button. The custom element dispatches each event on
itself, bubbling, with the detail in detail.
| Field | Type | Meaning |
|---|---|---|
isSelected |
boolean |
The toggle is on: the controlled value, or the uncontrolled one. |
isPressed |
boolean |
A pointer or key is down on the root. Unrelated to selection. |
isHovered |
boolean |
A mouse or pen is over the root, or anywhere over the frame while one is rendered. Never from touch, never while disabled. |
isFocused |
boolean |
The root has focus. |
isFocusVisible |
boolean |
The root has focus and the last interaction was a keyboard or screen reader. |
isDisabled |
boolean |
The isDisabled prop. |
To render something from state in React or Vue, select it, and the component renders again only when that value changes (Vue gives a ref):
const isBold = useBehaviorState(bold, (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.
| Attribute | On | When |
|---|---|---|
data-uni="toggle-button" |
root | Always. |
data-element="Label", data-element="Description" |
label, description | Always. |
data-uni-part="toggle-button-frame", data-uni-part="toggle-button-extras" |
frame, extras | Always. |
data-selected |
root, frame, extras | The toggle is on. |
data-hovered |
root, frame, extras | A mouse or pen is over the root, or anywhere over the frame while one is rendered. |
data-pressed |
root, frame, extras | A pointer or key is down on the root. |
data-focused |
root, frame, extras | The root has focus. |
data-focus-visible |
root, frame, extras | The root has focus from a keyboard or a screen reader. |
data-disabled |
root, frame, extras | The toggle button is disabled. |
Style the on state with data-selected, not data-pressed, which only means
a press is in progress. State attributes are present or absent, never
"false". The frame and the extras carry the root’s, so your CSS can paint
the extras for any state of their toggle button, a selected one included. As
in Button, UniAria sets touch-action: pan-x pan-y pinch-zoom on the root,
and user-select: none while a touch press lasts.
The optional @uniaria/styles package styles the
root, as in the demo above, whose CSS tab holds only the page’s layout. It looks
like a Button and takes data-variant the same way. Selected,
it takes the accent fill whatever the variant, so the on state shows by more
than a change of hue.
A label part, a frame and extras get Button’s layout: an icon before the label and a hint after it, one line each for the label and the description, and the extras over the end of a framed toggle button, which needs a label part. The frame takes the toggle button’s text color, the selected one included, and a quiet Button in the extras mixes its fills from it, so it reads on the accent fill. Cut-off text can get an overflow tooltip, as on a Button.
| Key | Behavior |
|---|---|
| Space | Presses on keydown and toggles on keyup. The page doesn’t scroll. |
| Enter | Presses on keydown and toggles on keyup. |
| Tab | Moves focus normally. The root is a tab stop unless disabled (without isFocusableWhenDisabled) or excluded. |
Holding a key down never toggles again.
In a frame, Tab moves from the toggle button to each control in its extras, in DOM order, as in Button.
- Spoken output. “button, Bold, not pressed”, then “button, Bold, pressed” once on; “button, Bold, disabled, pressed” when disabled. Screen readers announce the new state when it changes.
aria-pressedis always present, as"true"or"false": without it the root would be read as a plain button.- Label, description and extras. The label part alone names the toggle button, so an icon or a hint inside stays out of its name, and the description part describes it. Each extra is read on its own after it, named in its context: “Subtitle language”, not a bare “Settings”. See Button.
- Keep the label fixed. The state is announced, so a label that changes with it (“Mute” and “Unmute”) would be read twice and confuse. If the label must change, use a plain Button instead.
- Focus. Toggling never moves focus. With
shouldPreventFocusOnPress, a pointer press leaves focus in the editor the toggle formats. - Tooltips. Give the toggle button a Tooltip through its
link, as a Button. To explain a disabled toggle, add
isFocusableWhenDisabledso keyboard users can reach it.
- Toggles on keyup, as Button activates. The APG’s toggle example sets
aria-pressedon keydown. selectedchangeis React Aria’sonChange: same value, same order (toggle first, thenpress).data-selectedmatches React Aria Components.- No mixed state, link mode or form value. React Aria has none of them either.
- No group. A set of exclusive toggles (React Aria’s
ToggleButtonGroup) is not in UniAria yet; a RadioGroup styled as buttons covers a single choice.