macbetter

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>
    </>
  )
}

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 sm buttons 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 with aria-hidden="true".
  • While pending, the button sets aria-busy and aria-disabled, and after a second announces pendingAnnouncement ("Loading" by default) in a polite live region.
  • disabled removes the button from the tab order. Use focusableWhenDisabled when 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

PropTypeDefaultDescription
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.
blockbooleanfalseStretches 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.
pendingAnnouncementstring"Loading"What screen readers hear once the button has been pending for a second.
disabledbooleanfalseStops presses and leaves the tab order.
focusableWhenDisabledbooleanfalseKeeps a disabled button focusable with aria-disabled.
renderReactElement | (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

AttributeOnValues
data-variantButtonbezeled, filled, tinted, plain, glass
data-toneButtonaccent, neutral, destructive
data-sizeButtonsm, md, lg
data-shapeButtonauto, capsule, circle
data-blockButtonPresent when block is set
data-pendingButtonPresent while the action runs
data-disabledButtonPresent when disabled
data-showContentidle or pending: shows the element only in that state

CSS variables

VariableDefaultWhat it sets
--mb-button-height--mb-control-heightHeight, and the width of a circle
--mb-button-padding--mb-control-paddingInline padding
--mb-button-radius--mb-control-radiusCorner radius
--mb-button-bgPer variantBackground
--mb-button-fgPer variantText and icon color
--mb-button-edgeA soft ink hairlineThe bezeled button's edge