---
language: "en"
title: "ToggleButton"
canonical: "https://uniaria.tenphi.me/components/toggle-button/"
source: "docs/components/toggle-button.mdx"
index: true
---

# 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](/components/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](https://www.w3.org/WAI/ARIA/apg/patterns/button/).

## Usage

```tsx
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](/guide/state/).

```tsx
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>
```

```js
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](/components/button/#label-description-and-extras) for how they behave.

```tsx
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](/components/button/#anatomy):
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](/components/checkbox/) or a [Switch](/components/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](/components/button/), 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):

```ts
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](/guide/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`](/guide/default-styles/) package styles the
root, as in the demo above, whose CSS tab holds only the page's layout. It looks
like a [Button](/components/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](/components/button/#default-styles): 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](/components/tooltip/#overflow-tooltips), 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](/components/button/#keyboard).

## 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](/components/button/#accessibility).
* **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](/components/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](/components/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](/components/radio-group/) styled as buttons covers a single choice.
