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. Withmultiple, 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.
"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
grouprole, so name it witharia-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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
pressed | boolean | — | Whether it's pressed. Pass it to control the toggle. |
defaultPressed | boolean | false | Whether it's pressed at first. |
onPressedChange | (pressed, details) => void | — | Runs when a press wants to change it. |
value | string | — | Identifies the toggle inside a group. |
disabled | boolean | false | Stops presses. |
ToggleGroup
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "plain" | "bezeled" | "plain" | Separate toolbar buttons, or one joined control. |
size | "sm" | "md" | "lg" | "md" | Sizes every toggle inside. |
value | string[] | — | The pressed toggles' values. Pass it to control the group. |
defaultValue | string[] | — | The toggles pressed at first. |
onValueChange | (value, details) => void | — | Runs when a toggle wants to change. |
multiple | boolean | false | Lets several toggles stay pressed. |
deselectable | boolean | true | With false, one toggle stays pressed once any is. |
orientation | "horizontal" | "vertical" | "horizontal" | Which arrow keys move between toggles. |
disabled | boolean | false | Disables every toggle. |
ToggleIcon
| Prop | Type | Default | Description |
|---|---|---|---|
mirror | boolean | false | Flips the icon in right-to-left text, for icons that point a way. |
Data attributes
| Attribute | On | Values |
|---|---|---|
data-variant | Toggle, ToggleGroup | plain, bezeled |
data-tone | Toggle | neutral, accent |
data-size | Toggle, ToggleGroup | sm, md, lg |
data-pressed | Toggle | Present while pressed |
data-has-icon | Toggle | Present when it holds a ToggleIcon |
data-orientation | ToggleGroup | horizontal, vertical |
data-disabled | Both | Present when disabled |
CSS variables
Set these on a toggle or a group.
| Variable | Default | What it sets |
|---|---|---|
--mb-toggle-height | --mb-control-height | Height, and the width of an icon-only toggle |
--mb-toggle-padding | --mb-control-padding | Inline padding of a labeled toggle |
--mb-toggle-radius | --mb-control-radius | Corner radius |
--mb-toggle-bg-pressed | Per platform | The pressed fill |
--mb-toggle-fg-pressed | Per platform | The pressed label and icon color |
--mb-toggle-edge | A soft ink hairline | The edge of a bezeled toggle or group |