Buttons and controls
Button
Run an action with a click, a tap or the keyboard.
import { Button } from "macbetter/button"
export default function Example() {
return (
<>
<Button variant="filled">Save</Button>
<Button variant="tinted">Share</Button>
<Button variant="bezeled">Cancel</Button>
<Button variant="plain">Learn More</Button>
</>
)
}When to use it
Use a button for an action that happens now: save, share, delete, send. Label it with a verb, in title case on macOS and iOS ("Save Draft", not "Draft saving").
- Filled marks the one default action in a view, the button Return presses in a macOS dialog or the main action at the bottom of an iOS sheet. Use one per view.
- Bezeled is the standard push button. It's the default, and most buttons should be this one.
- Tinted is a quieter alternative to filled that still carries the accent, for a secondary action that should stand out from the plain ones.
- Plain is text alone, for toolbars, navigation bars and actions inside content.
- Glass floats over photos, maps and video. Use it only over content, never on another material.
To go somewhere rather than do something, render the button as a link. To turn something on and off, use a Toggle or a Switch instead.
Usage
import { Button, ButtonSpinner } from "macbetter/button"Tones
tone sets the color a variant uses: accent (the default), neutral for actions that shouldn't draw the eye, and destructive for actions that delete or can't be undone. Destructive is always a choice you make for the person, not decoration.
import { Button } from "macbetter/button"
export default function Example() {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, auto)", gap: 12, alignItems: "center" }}>
<Button variant="filled">Continue</Button>
<Button variant="filled" tone="neutral">
Continue
</Button>
<Button variant="filled" tone="destructive">
Delete
</Button>
<Button variant="tinted">Continue</Button>
<Button variant="tinted" tone="neutral">
Continue
</Button>
<Button variant="tinted" tone="destructive">
Delete
</Button>
<Button variant="bezeled">Continue</Button>
<Button variant="bezeled" tone="neutral">
Continue
</Button>
<Button variant="bezeled" tone="destructive">
Delete
</Button>
</div>
)
}Sizes
size is sm, md or lg. The heights come from the platform: 22, 28 and 36 points on macOS, 34, 44 and 52 on iOS. Use sm in inspectors and dense toolbars, and lg for the main action of a sheet or an onboarding screen.
import { Button } from "macbetter/button"
export default function Example() {
return (
<>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button variant="filled" size="sm">
Small
</Button>
<Button variant="filled" size="md">
Medium
</Button>
<Button variant="filled" size="lg">
Large
</Button>
</>
)
}Shapes
shape="auto" follows the platform: a rounded rectangle on macOS and a capsule on touch. capsule forces the capsule everywhere, and circle makes a round button for a single icon.
import { Button } from "macbetter/button"
import { PlusIcon } from "lucide-react"
export default function Example() {
return (
<>
<Button variant="filled">Follows the platform</Button>
<Button variant="filled" shape="capsule">
Capsule
</Button>
<Button variant="filled" shape="circle" aria-label="New Note">
<PlusIcon aria-hidden="true" />
</Button>
<Button variant="tinted" shape="circle" aria-label="New Note">
<PlusIcon aria-hidden="true" />
</Button>
</>
)
}Icons
Put an icon before the label, hidden from screen readers with aria-hidden. A button with only an icon needs an aria-label that says what it does.
import { Button } from "macbetter/button"
import { DownloadIcon, ShareIcon, TrashIcon } from "lucide-react"
export default function Example() {
return (
<>
<Button variant="filled">
<DownloadIcon aria-hidden="true" />
Download
</Button>
<Button>
<ShareIcon aria-hidden="true" />
Share
</Button>
<Button variant="plain" tone="destructive">
<TrashIcon aria-hidden="true" />
Delete
</Button>
<Button variant="plain" tone="neutral" aria-label="Share">
<ShareIcon aria-hidden="true" />
</Button>
</>
)
}Pending
Return a Promise from onClick and the button stays pending until it settles: it ignores further presses, keeps focus and tells assistive technology it's busy. Put a ButtonSpinner inside to show it, and mark an icon data-show="idle" to hide it while the spinner turns. Keep the label the same so the button's name doesn't change while it's focused.
"use client"
import { Button, ButtonSpinner } from "macbetter/button"
import { CloudUploadIcon } from "lucide-react"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
export default function Example() {
return (
<>
<Button variant="filled" onClick={() => wait(2000)} pendingAnnouncement="Publishing">
<CloudUploadIcon data-show="idle" aria-hidden="true" />
<ButtonSpinner />
Publish
</Button>
<Button onClick={() => wait(2000)} pendingAnnouncement="Syncing">
<ButtonSpinner />
Sync Now
</Button>
</>
)
}Block
block stretches the button to its container, like the stacked actions at the bottom of an iOS sheet or onboarding screen.
import { Button } from "macbetter/button"
export default function Example() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12, width: "100%", maxWidth: 340 }}>
<Button variant="filled" size="lg" block>
Continue
</Button>
<Button variant="plain" size="lg" block>
Not Now
</Button>
</div>
)
}Glass
variant="glass" blurs and brightens what's behind it, with a bright rim. It's for controls over media, like playback buttons over album art.
import { Button } from "macbetter/button"
import { PauseIcon, SkipBackIcon, SkipForwardIcon } from "lucide-react"
export default function Example() {
return (
<>
<Button variant="glass" shape="circle" size="lg" aria-label="Previous Track">
<SkipBackIcon aria-hidden="true" />
</Button>
<Button variant="glass" shape="circle" size="lg" aria-label="Pause">
<PauseIcon aria-hidden="true" />
</Button>
<Button variant="glass" shape="circle" size="lg" aria-label="Next Track">
<SkipForwardIcon aria-hidden="true" />
</Button>
<Button variant="glass" shape="capsule">
Open in Music
</Button>
</>
)
}As a link
Pass a link as render and the button becomes that link, keeping its look and its focus behavior. Use it when the action is navigation.
"use client"
import { Button } from "macbetter/button"
import { ArrowUpRightIcon } from "lucide-react"
export default function Example() {
return (
<>
<Button variant="filled" render={<a href="/docs/installation" />}>
Get Started
</Button>
<Button variant="plain" render={<a href="https://ui-sdk.dev" target="_blank" rel="noreferrer" />}>
ui-sdk docs
<ArrowUpRightIcon aria-hidden="true" />
</Button>
</>
)
}Platform behavior
- macOS. Bezeled buttons are raised, with a hairline edge and a soft shadow in light and a lit top edge in dark; filled buttons use the accent. Corners are rounded rectangles that scale with the size. Hover brightens a button slightly, and press darkens it at once.
- iPadOS and iOS. Buttons are capsules. A bezeled button becomes a gray fill with accent-colored text, as in UIKit's gray configuration. There's no hover; a press dims plain buttons and gives every button a small, springy scale.
- Every platform. A button smaller than the platform's minimum touch target (24 points on macOS, 44 on touch) still answers presses across the full target, so
smbuttons stay easy to hit.
Accessibility
- Renders a native
button, so Enter and Space press it and it joins the tab order. As a link, Enter follows it. - Name icon-only buttons with
aria-label. Hide decorative icons witharia-hidden="true". - While pending, the button sets
aria-busyandaria-disabled, and after a second announcespendingAnnouncement("Loading" by default) in a polite live region. disabledremoves the button from the tab order. UsefocusableWhenDisabledwhen it should keep focus, for example to show a tooltip that explains why.- Focus shows the accent halo. Text keeps 4.5:1 contrast in every variant, light and dark: the destructive fill is deepened and tinted text is adjusted where the stock system red or accent would fall short.
- The label identifies a button, so a bezeled button's edge stays as soft as AppKit's. With Increase Contrast on (
prefers-contrast: more) the edge darkens to 3:1. - With Reduce Motion on, the pending spinner keeps turning, more slowly, so it still says something is happening.
API
Button
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "bezeled" | "filled" | "tinted" | "plain" | "glass" | "bezeled" | How prominent the button is. |
tone | "accent" | "neutral" | "destructive" | "accent" | The color the variant uses. |
size | "sm" | "md" | "lg" | "md" | Height, padding and type size, from the platform's metrics. |
shape | "auto" | "capsule" | "circle" | "auto" | Rounded rectangle on macOS and capsule on touch, or a fixed shape. |
block | boolean | false | Stretches the button to the container's width. |
onClick | (event, details) => unknown | — | Runs the action. Return a Promise to keep the button pending until it settles. |
pendingAnnouncement | string | "Loading" | What screen readers hear once the button has been pending for a second. |
disabled | boolean | false | Stops presses and leaves the tab order. |
focusableWhenDisabled | boolean | false | Keeps a disabled button focusable with aria-disabled. |
render | ReactElement | (props, state) => ReactElement | — | Renders another element, like a link, in the button's place. |
ButtonSpinner
A spinning ring that shows only while the button is pending. It takes the props of a span.
Data attributes
| Attribute | On | Values |
|---|---|---|
data-variant | Button | bezeled, filled, tinted, plain, glass |
data-tone | Button | accent, neutral, destructive |
data-size | Button | sm, md, lg |
data-shape | Button | auto, capsule, circle |
data-block | Button | Present when block is set |
data-pending | Button | Present while the action runs |
data-disabled | Button | Present when disabled |
data-show | Content | idle or pending: shows the element only in that state |
CSS variables
| Variable | Default | What it sets |
|---|---|---|
--mb-button-height | --mb-control-height | Height, and the width of a circle |
--mb-button-padding | --mb-control-padding | Inline padding |
--mb-button-radius | --mb-control-radius | Corner radius |
--mb-button-bg | Per variant | Background |
--mb-button-fg | Per variant | Text and icon color |
--mb-button-edge | A soft ink hairline | The bezeled button's edge |