Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

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.

Toggle buttons
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>

Usage

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.

Controlled and uncontrolled

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.

Label, description and extras

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.

Anatomy

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.

Props

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.

Events

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.

State

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.

Styling

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.

Default styles

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.

Keyboard

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.

Accessibility

  • 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-pressed is 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 isFocusableWhenDisabled so keyboard users can reach it.

Compared with other libraries

  • Toggles on keyup, as Button activates. The APG’s toggle example sets aria-pressed on keydown.
  • selectedchange is React Aria’s onChange: same value, same order (toggle first, then press). data-selected matches 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.