Skip to main content

ToggleButton

Overview​

The ToggleButton component renders a button that stays pressed until it's pressed again, and says whether it's on through aria-pressed.

Reach for it when pressing a button turns something on and it stays on: Bold, Italic and Underline in an editor toolbar, Mute in a player, Show archived above a list, Pin on a card. The change happens right away, and a screen reader announces something like "Mute, toggle button, pressed". Pressed, it fills with its color. Not pressed, it's a plain button.

It isn't a form field. A setting that's saved with a form is a Switch or a Checkbox.

Only one can be on?

A row where pressing one option turns the others off (left, center or right; list or grid) looks like a set of toggles, but it behaves like radio buttons. Use SegmentedControl for it, as Picking One of Several shows.


Import​

import { ToggleButton } from '@allxsmith/bestax-bulma';

Usage​

Default ToggleButton​

It holds its own state. Click it, or Tab to it and press Enter or Space.

<ToggleButton>Mute</ToggleButton>

Starting Pressed​

defaultPressed sets where it starts when it holds its own state.

<ToggleButton defaultPressed>Show archived</ToggleButton>

Controlled​

Pass pressed to hold the state yourself. A click calls onPressedChange with the new value and changes nothing until you pass that value back, the way a controlled Collapse works. onPressedChange fires without pressed too, so it's also how you hear about a toggle that holds its own state. Pick pressed or defaultPressed for the button's lifetime.

function example() {
  const [muted, setMuted] = useState(false);

  return (
    <>
      <ToggleButton pressed={muted} onPressedChange={setMuted}>
        Mute
      </ToggleButton>
      <Paragraph mt="3">The player is {muted ? 'muted' : 'playing'}.</Paragraph>
    </>
  );
}

Colors​

color is the fill while it's pressed. It's link by default, the color Bulma fills its own selected tabs and current page with. Not pressed, it's a plain button whatever the color, so the fill is the difference people see. Of the colors on offer, a pale one such as warning changes less than the default.

white, light, dark and black aren't offered. Bulma's color modifier fills the button and drops its border, and in one theme or the other each of those four fills about as light or dark as a plain button already is, so pressed would look like off without its border. black in a dark theme even reads as pushed in rather than on.

<Buttons>
  <ToggleButton defaultPressed>Link</ToggleButton>
  <ToggleButton color="primary" defaultPressed>
    Primary
  </ToggleButton>
  <ToggleButton color="info" defaultPressed>
    Info
  </ToggleButton>
  <ToggleButton color="success" defaultPressed>
    Success
  </ToggleButton>
  <ToggleButton color="danger" defaultPressed>
    Danger
  </ToggleButton>
</Buttons>

There's no light, outlined or inverted pressed look, and no textColor or bgColor: each would color the button in both states or turn "on" into a faint tint. To style your own pressed look, target [aria-pressed='true'] or .is-selected, which are both there while it's pressed. is-selected takes the class prefix a ConfigProvider sets, which a hand-written class can't.

Sizes and Shapes​

size, isRounded and isFullwidth work in both states, the same as on Button, and so do the helper props.

<Buttons>
  <ToggleButton size="small">Small</ToggleButton>
  <ToggleButton defaultPressed>Normal</ToggleButton>
  <ToggleButton size="medium" isRounded>
    Medium
  </ToggleButton>
  <ToggleButton size="large" isRounded defaultPressed>
    Large
  </ToggleButton>
</Buttons>

Icon-Only​

An icon on its own needs an aria-label. Keep it the same in both states, since the screen reader already says whether it's pressed. The icon can change, as the heart fills in here, but the label doesn't.

function example() {
  const [favorite, setFavorite] = useState(false);

  return (
    <ToggleButton
      aria-label="Favorite"
      color="danger"
      pressed={favorite}
      onPressedChange={setFavorite}
    >
      <Icon name="heart" variant={favorite ? 'solid' : 'regular'} />
    </ToggleButton>
  );
}

A Set of Toggles​

Toggles that each turn on and off on their own, like Bold, Italic and Underline, go in Buttons hasAddons. Give the Buttons role="group" and an aria-label, so a screen reader names the set. Each toggle keeps its own state and its own tab stop. Pressed, a toggle takes Bulma's is-selected, which lifts it above its neighbours so their borders don't cut into its fill. ToggleButton is also Buttons.ToggleButton, so the set can come from the one Buttons import.

<Buttons hasAddons role="group" aria-label="Text style">
  <ToggleButton aria-label="Bold" defaultPressed>
    <Icon name="bold" />
  </ToggleButton>
  <ToggleButton aria-label="Italic">
    <Icon name="italic" />
  </ToggleButton>
  <ToggleButton aria-label="Underline">
    <Icon name="underline" />
  </ToggleButton>
</Buttons>

Picking One of Several​

A row where only one option can be on, such as text alignment or a list or grid view, isn't a set of toggles. Pressing one turns the others off, so it behaves like radio buttons, and the APG toolbar example gives its alignment buttons radio semantics for that reason. Use SegmentedControl for it: the same joined row of buttons, with the chosen one filled, that a screen reader hears as a radio group.

<SegmentedControl aria-label="Text alignment" defaultValue="left">
  <SegmentedControl.Segment value="left" aria-label="Align left">
    <Icon name="align-left" />
  </SegmentedControl.Segment>
  <SegmentedControl.Segment value="center" aria-label="Align center">
    <Icon name="align-center" />
  </SegmentedControl.Segment>
  <SegmentedControl.Segment value="right" aria-label="Align right">
    <Icon name="align-right" />
  </SegmentedControl.Segment>
</SegmentedControl>

A SegmentedControl carries no form value, so a choice that's sent with a form is Radios.

Inside a Form​

It's always type="button", so pressing it never submits the form around it, and it carries no value, so the form never sees it. That suits something that changes the page and not the data, like the help below. A setting the form should send is a Checkbox or a Switch.

function example() {
  const [help, setHelp] = useState(false);
  const [posted, setPosted] = useState(false);

  return (
    <form
      onSubmit={event => {
        event.preventDefault();
        setPosted(true);
      }}
    >
      <Field label="Comment">
        <TextArea name="comment" />
      </Field>
      {help && (
        <Paragraph textSize="7" mb="3">
          Wrap a word in asterisks to make it bold.
        </Paragraph>
      )}
      <Buttons>
        <ToggleButton pressed={help} onPressedChange={setHelp}>
          Formatting help
        </ToggleButton>
        <Button type="submit" color="primary">
          Post
        </Button>
      </Buttons>
      <Paragraph>{posted ? 'Posted.' : 'Not posted yet.'}</Paragraph>
    </form>
  );
}

Stopping a Toggle​

Your onClick runs before the toggle, unless the button is loading. Call event.preventDefault() in it and nothing flips, and onPressedChange doesn't fire, which is how you'd ask before turning something on.

<ToggleButton
  onClick={event => {
    const turningOn =
      event.currentTarget.getAttribute('aria-pressed') === 'false';
    if (turningOn && !window.confirm('Anyone with the link will see it.')) {
      event.preventDefault();
    }
  }}
>
  Public
</ToggleButton>

Loading​

isLoading shows Bulma's spinner in place of the label and holds the toggle where it is until you clear it. A click, Enter or Space does nothing meanwhile, and neither onClick nor onPressedChange fires. Set it while a change is saving, so nobody flips it again before the last change has landed. It carries aria-busy and aria-disabled until you clear it, and keeps focus, since it isn't disabled.

function example() {
  const [pinned, setPinned] = useState(false);
  const [saving, setSaving] = useState(false);

  // Stands in for the request that saves the change.
  useEffect(() => {
    if (!saving) return undefined;
    const timer = setTimeout(() => setSaving(false), 1500);
    return () => clearTimeout(timer);
  }, [saving]);

  return (
    <ToggleButton
      pressed={pinned}
      isLoading={saving}
      onPressedChange={next => {
        setPinned(next);
        setSaving(true);
      }}
    >
      Pin
    </ToggleButton>
  );
}

Disabled​

isDisabled (or disabled) makes it a disabled button: clicks and keys don't toggle it, and onPressedChange doesn't fire. A pressed one keeps its fill under Bulma's disabled look.

<Buttons>
  <ToggleButton isDisabled>Show archived</ToggleButton>
  <ToggleButton isDisabled defaultPressed>
    Show archived
  </ToggleButton>
</Buttons>


Accessibility​

ToggleButton follows the toggle button in the APG button pattern:

  • Role and state: it's a native <button> with aria-pressed set to "true" or "false" from the first render, server markup included, so it's announced as a toggle button, pressed or not pressed. It's never "mixed".
  • Keyboard: Enter and Space toggle it, as on any button, and focus stays on it.
  • Loading: while isLoading is set, it carries aria-busy="true" and aria-disabled="true", so a screen reader can tell it won't respond, and keys hold it still the same as the pointer does. It keeps focus, since aria-disabled doesn't take it away the way disabled would.
  • The label stays put: APG says "it is critical the label on a toggle does not change when its state changes." Mute stays "Mute". An icon can change, but the aria-label doesn't. A label that changes, "Play" and then "Pause", belongs on a plain Button with no aria-pressed. Nothing can check this at runtime, so it's yours to keep.
  • Icon-only toggles need an aria-label, as on Button.
  • On is easy to see: the default link fill turns a light button solid blue, and a dark one bright in dark mode, so it's a change in lightness and not only in hue. color leaves out the fills that wouldn't be: white, light, dark and black each match a plain button's lightness in one theme or the other. That's also why the pressed look isn't Button's isActive shade: WCAG's own example of a button marked only by a darker shade of its color "would not pass Use of color".
  • A set has a name: give the Buttons around a set of toggles role="group" and an aria-label.
  • One choice from several is a radio group, not a set of toggles, so it's SegmentedControl, or Radios when the choice is sent with a form.

  • Button: A plain button. Its isActive is a visual state only, with no aria-pressed.
  • Buttons: Groups toggles, and hasAddons joins them into one row.
  • Switch: An on or off setting that's saved with a form.
  • Checkbox: A form value that's checked or not.
  • SegmentedControl: One choice from several, as a row of buttons where only one can be on.
  • Radios: One choice from several, sent with a form.
  • Icon: The icon in an icon-only toggle.
  • Helper Props: Bulma helper props for spacing, display, etc.

Additional Resources​


Props​

PropTypeDefaultDescription
pressedboolean—Whether it's pressed, when you hold the state. Setting it makes the toggle controlled: a click only calls onPressedChange, and nothing changes until you pass the new value. Pick this or defaultPressed for the button's lifetime.
defaultPressedbooleanfalseWhether it starts pressed, when it holds its own state.
onPressedChange(pressed: boolean) => void—Called with the new state each time it's toggled, whether or not pressed is set. An onClick that calls preventDefault() stops the toggle, and this doesn't fire.
color'primary' | 'link' | 'info' | 'success' | 'warning' | 'danger''link'The fill while it's pressed. Not pressed, it's a plain button, so the fill is the difference people see. white, light, dark and black aren't offered, since in one theme or the other each fills about as light or dark as a plain button already is. Of the rest, a pale one such as warning changes less than the default link.
childrenReact.ReactNode—The label, which stays the same in both states: a screen reader announces whether it's pressed, so "Mute" stays "Mute". An icon can swap, but an icon-only toggle needs an aria-label that doesn't. A label that changes ("Play", then "Pause") belongs on a plain Button.
isLoadingbooleanfalseShows Bulma's spinner in place of the label and holds the toggle where it is: a click, Enter or Space does nothing, so neither onClick nor onPressedChange fires, until it's cleared. Set it while a change is saving. The button carries aria-busy and aria-disabled meanwhile, and stays focusable, so focus isn't lost mid-save. Under prefers-reduced-motion: reduce the spinner stops and stays drawn (with bestax's CSS loaded).
size'small' | 'normal' | 'medium' | 'large'—Size of the button.
isRoundedbooleanfalseMakes the button rounded.
isFullwidthbooleanfalseMakes the button full-width.
isFullWidthbooleanfalseDeprecated. Use isFullwidth instead — isFullwidth wins if both are set. Makes the button full-width.
isFocusedbooleanfalseApplies focused styling (visual only).
isHoveredbooleanfalseApplies hovered styling (visual only).
isDisabledbooleanfalseApplies disabled styling.
classNamestring—Custom class name.
refReact.Ref<HTMLButtonElement>—Ref forwarded to the <button>.
...All standard <button> attributes and Bulma helper props—See Helper Props

CSS & Sass Variables​

ToggleButton has no variables of its own. It's a Button underneath, so Button's variables style it, set the same way: on the button itself or through className. The --bulma-button-text-*, --bulma-button-ghost-* and --bulma-button-static-* rows there don't apply, since they style is-text, is-ghost and is-static, and ToggleButton offers none of those.