Skip to content

useIconState

Keep a toggle button’s state, icon and accessible labels in sync.

Use it for any button whose icon changes with its state: save, like, mute, play and pause, show and hide. It keeps the state, the icon and the button's ARIA in step, so the icon never says one thing while assistive technology hears another. For a menu or a panel that opens, use aria-expanded on the trigger instead, with the icon's active bound to the open state.

Installation

npm i @youj/icons

import { useIconState } from '@youj/icons'

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Usage

Spread buttonProps on a native <button> and iconProps on the icon.

import { BookmarkIcon, useIconState } from '@youj/icons'

export function SaveButton() {
  const save = useIconState({ label: 'Save episode' })
  return (
    <button {...save.buttonProps}>
      <BookmarkIcon {...save.iconProps} />
    </button>
  )
}

buttonProps sets type="button", the click handler and the ARIA. iconProps is { active }, which a state icon or an IconSwap takes as it is.

Examples

Two kinds of toggle

The accessible pattern depends on what the button does, and the options decide it.

  • A pressed toggle (label): save, like, mute, bold. The button keeps one name, and aria-pressed reports whether it's on, so a screen reader announces "Save episode" and whether it's pressed.
  • An action that flips (labels): play and pause, show and hide password. The name says what the button will do next, so it changes with the state, and there's no aria-pressed. While a track plays, the button is announced as "Pause".
const like = useIconState({ label: 'Like' })
const play = useIconState({ labels: ['Play', 'Pause'] })

Never combine the two: a pressed button whose name also changes reads as "Pause, pressed", which means the opposite of what it does. The types allow label or labels, not both.

Reference

APIPass exactly one of label and labels

API

Options

OptionTypeDescription
labelstringA pressed toggle's one name. It gets aria-pressed.
labelsreadonly [string, string]An action's names for [inactive, active]. No aria-pressed.
activebooleanControlled state.
defaultActivebooleanStarting state when uncontrolled. Default false.
onActiveChange(active: boolean) => voidCalled with the next state on every press, and on setActive.

Pass exactly one of label and labels.

Returns

ValueTypeDescription
activebooleanThe current state.
toggle() => voidFlips the state.
setActive(active: boolean) => voidSets the state.
buttonPropsIconStateButtonPropstype, onClick, aria-label, and aria-pressed for a pressed toggle. Spread onto a native <button>.
iconProps{ active: boolean }Spread onto a state icon or an IconSwap.
Reduced motionThe hook has no motion of its own

Reduced motion

The hook has no motion of its own. The icon it drives switches state instantly under reduced motion, and the ARIA is the same either way.

AccessibilityKeyboard, ARIA

Accessibility

  • buttonProps gives the button its name through aria-label, so an icon-only button is named. If the button also shows text, make the text match the name.
  • Space and Enter toggle it, as with any native button.
  • The icon stays decorative. The button carries the state, so there's one source of truth for assistive technology.