macbetter

Buttons and controls

Toggle

A button that stays pressed until you press it again, alone or in a group.

import { Toggle, ToggleIcon } from "macbetter/toggle"
import { BookmarkIcon, StarIcon } from "lucide-react"

export default function Example() {
  return (
    <>
      <Toggle aria-label="Favorite" defaultPressed>
        <ToggleIcon>
          <StarIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle aria-label="Bookmark">
        <ToggleIcon>
          <BookmarkIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle variant="bezeled" defaultPressed>
        Show Invisibles
      </Toggle>
    </>
  )
}

When to use it

Use a toggle for a button whose state is part of what it shows: Bold in a formatting bar, a sidebar button in a toolbar, a favorite star. It changes something right away, like a switch, but looks like a button because it lives among buttons.

  • Keep the label or icon the same in both states. The pressed fill says it's on; screen readers say "pressed".
  • Group toggles that belong together in a ToggleGroup. With multiple, several can be pressed, like Bold and Italic. Without it, one at a time, like text alignment.
  • For a setting in a list or form, use a Switch or Checkbox. For switching views, use a Segmented Control.

Usage

import { Toggle, ToggleGroup, ToggleIcon } from "macbetter/toggle"

Wrap icons in ToggleIcon, which hides them from screen readers. An icon-only toggle needs an aria-label.

Group

A ToggleGroup has one tab stop, and the arrow keys move between its toggles. variant="bezeled" joins them into one raised control, like a "select any" segmented control on macOS.

import { Toggle, ToggleGroup, ToggleIcon } from "macbetter/toggle"
import { BoldIcon, ItalicIcon, StrikethroughIcon, UnderlineIcon } from "lucide-react"

export default function Example() {
  return (
    <ToggleGroup multiple variant="bezeled" aria-label="Text style" defaultValue={["bold"]}>
      <Toggle value="bold" aria-label="Bold">
        <ToggleIcon>
          <BoldIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="italic" aria-label="Italic">
        <ToggleIcon>
          <ItalicIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="underline" aria-label="Underline">
        <ToggleIcon>
          <UnderlineIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="strikethrough" aria-label="Strikethrough">
        <ToggleIcon>
          <StrikethroughIcon />
        </ToggleIcon>
      </Toggle>
    </ToggleGroup>
  )
}

One at a time

Leave out multiple and pressing one toggle releases the others. deselectable={false} keeps one pressed, which suits choices like text alignment that always have a value.

import { Toggle, ToggleGroup, ToggleIcon } from "macbetter/toggle"
import { TextAlignCenterIcon, TextAlignEndIcon, TextAlignJustifyIcon, TextAlignStartIcon } from "lucide-react"

export default function Example() {
  return (
    <ToggleGroup variant="bezeled" aria-label="Alignment" defaultValue={["start"]} deselectable={false}>
      <Toggle value="start" aria-label="Align Left">
        <ToggleIcon>
          <TextAlignStartIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="center" aria-label="Center">
        <ToggleIcon>
          <TextAlignCenterIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="end" aria-label="Align Right">
        <ToggleIcon>
          <TextAlignEndIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="justify" aria-label="Justify">
        <ToggleIcon>
          <TextAlignJustifyIcon />
        </ToggleIcon>
      </Toggle>
    </ToggleGroup>
  )
}

In a toolbar

The default plain variant is a toolbar button: no bezel until it's hovered or pressed. tone="accent" tints the pressed icon with the accent, as Xcode's panel buttons do.

import { Toggle, ToggleGroup, ToggleIcon } from "macbetter/toggle"
import { PanelLeftIcon, PanelRightIcon, SquareTerminalIcon } from "lucide-react"

export default function Example() {
  return (
    <ToggleGroup multiple aria-label="Panels" defaultValue={["navigator", "inspector"]}>
      <Toggle value="navigator" tone="accent" aria-label="Navigator">
        <ToggleIcon>
          <PanelLeftIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="console" tone="accent" aria-label="Console">
        <ToggleIcon>
          <SquareTerminalIcon />
        </ToggleIcon>
      </Toggle>
      <Toggle value="inspector" tone="accent" aria-label="Inspector">
        <ToggleIcon>
          <PanelRightIcon />
        </ToggleIcon>
      </Toggle>
    </ToggleGroup>
  )
}

With a label

Put text after the icon. Bare text is wrapped for you, so the toggle gets the right padding.

import { Toggle, ToggleIcon } from "macbetter/toggle"
import { BellOffIcon, EyeIcon, MapPinIcon } from "lucide-react"

export default function Example() {
  return (
    <>
      <Toggle variant="bezeled" defaultPressed>
        <ToggleIcon>
          <MapPinIcon />
        </ToggleIcon>
        Share Location
      </Toggle>
      <Toggle variant="bezeled">
        <ToggleIcon>
          <BellOffIcon />
        </ToggleIcon>
        Mute
      </Toggle>
      <Toggle variant="bezeled" tone="accent" defaultPressed>
        <ToggleIcon>
          <EyeIcon />
        </ToggleIcon>
        Watching
      </Toggle>
    </>
  )
}

Sizes

size is sm, md or lg. Set it on a group to size every toggle inside.

import { Toggle, ToggleIcon } from "macbetter/toggle"
import { HeartIcon } from "lucide-react"

const sizes = ["sm", "md", "lg"] as const

export default function Example() {
  return (
    <>
      {sizes.map((size) => (
        <Toggle key={size} size={size} variant="bezeled" defaultPressed aria-label={`Love, ${size}`}>
          <ToggleIcon>
            <HeartIcon />
          </ToggleIcon>
        </Toggle>
      ))}
      {sizes.map((size) => (
        <Toggle key={`text-${size}`} size={size} variant="bezeled">
          Repeat
        </Toggle>
      ))}
    </>
  )
}

Controlled

Pass pressed and onPressedChange to keep the state yourself. In a group, use the group's value and onValueChange, which list the pressed toggles' values.

Notifications are on
"use client"

import { Toggle, ToggleIcon } from "macbetter/toggle"
import { BellIcon, BellOffIcon } from "lucide-react"
import { useState } from "react"

export default function Example() {
  const [muted, setMuted] = useState(false)

  return (
    <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
      <Toggle variant="bezeled" aria-label="Mute" pressed={muted} onPressedChange={setMuted}>
        <ToggleIcon>{muted ? <BellOffIcon /> : <BellIcon />}</ToggleIcon>
      </Toggle>
      <span style={{ color: "var(--mb-label-secondary)" }}>
        {muted ? "Notifications are muted" : "Notifications are on"}
      </span>
    </div>
  )
}

Platform behavior

  • macOS. Toolbar-style: a pressed toggle sinks into a gray fill, and its icon turns from secondary to primary label color. Bezeled toggles look like push buttons and darken when on, with an inner shadow. A bezeled group draws hairline dividers between toggles.
  • iPadOS and iOS. Capsules, and a pressed toggle is tinted with the accent: an accent-colored label on a light accent fill, in semibold. A bezeled group becomes a gray capsule with tinted capsules inside. Presses give a small, springy scale.
  • Every platform. Toggles smaller than the touch minimum still answer presses across the full target.

Accessibility

  • Renders a native button with aria-pressed. Enter and Space press it.
  • A group follows the toolbar keyboard pattern: Tab enters on the pressed toggle (or the first), the arrow keys move, Home and End jump to the ends. It has the group role, so name it with aria-label.
  • Inside a group, a disabled toggle stays focusable with aria-disabled, so arrow keys still reach it.
  • The pressed state is a fill, not only a color, and pressed text keeps 4.5:1 contrast on its tint.
  • Bezeled edges are as soft as AppKit's; with Increase Contrast on (prefers-contrast: more) they darken to 3:1.

API

Toggle

PropTypeDefaultDescription
variant"plain" | "bezeled""plain"A toolbar button, or a push-on/push-off button. In a group, the group's.
tone"neutral" | "accent""neutral"accent tints the pressed content on macOS. Touch always tints.
size"sm" | "md" | "lg""md"In a group, the group's.
pressedboolean—Whether it's pressed. Pass it to control the toggle.
defaultPressedbooleanfalseWhether it's pressed at first.
onPressedChange(pressed, details) => void—Runs when a press wants to change it.
valuestring—Identifies the toggle inside a group.
disabledbooleanfalseStops presses.

ToggleGroup

PropTypeDefaultDescription
variant"plain" | "bezeled""plain"Separate toolbar buttons, or one joined control.
size"sm" | "md" | "lg""md"Sizes every toggle inside.
valuestring[]—The pressed toggles' values. Pass it to control the group.
defaultValuestring[]—The toggles pressed at first.
onValueChange(value, details) => void—Runs when a toggle wants to change.
multiplebooleanfalseLets several toggles stay pressed.
deselectablebooleantrueWith false, one toggle stays pressed once any is.
orientation"horizontal" | "vertical""horizontal"Which arrow keys move between toggles.
disabledbooleanfalseDisables every toggle.

ToggleIcon

PropTypeDefaultDescription
mirrorbooleanfalseFlips the icon in right-to-left text, for icons that point a way.

Data attributes

AttributeOnValues
data-variantToggle, ToggleGroupplain, bezeled
data-toneToggleneutral, accent
data-sizeToggle, ToggleGroupsm, md, lg
data-pressedTogglePresent while pressed
data-has-iconTogglePresent when it holds a ToggleIcon
data-orientationToggleGrouphorizontal, vertical
data-disabledBothPresent when disabled

CSS variables

Set these on a toggle or a group.

VariableDefaultWhat it sets
--mb-toggle-height--mb-control-heightHeight, and the width of an icon-only toggle
--mb-toggle-padding--mb-control-paddingInline padding of a labeled toggle
--mb-toggle-radius--mb-control-radiusCorner radius
--mb-toggle-bg-pressedPer platformThe pressed fill
--mb-toggle-fg-pressedPer platformThe pressed label and icon color
--mb-toggle-edgeA soft ink hairlineThe edge of a bezeled toggle or group