macbetter

Get started

Platforms

One attribute, data-platform, decides whether a page looks and feels like macOS, iPadOS or iOS. Components read their sizes from tokens that change with it.

Network
import { Button } from "macbetter/button"
import { SegmentedControl, SegmentedControlItem } from "macbetter/segmented-control"
import { Switch } from "macbetter/switch"

const group: React.CSSProperties = {
  borderRadius: "var(--mb-group-radius)",
  background: "var(--mb-bg-grouped-secondary)",
  boxShadow: "0 0 0 var(--mb-hairline) var(--mb-separator)",
  overflow: "hidden",
}

const row: React.CSSProperties = {
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  gap: "var(--mb-space-5)",
  minHeight: "var(--mb-row-height)",
  paddingInline: "var(--mb-row-padding)",
  paddingBlock: "var(--mb-space-3)",
  font: "var(--mb-text-body)",
}

const divider: React.CSSProperties = {
  height: "var(--mb-hairline)",
  marginInlineStart: "var(--mb-row-padding)",
  background: "var(--mb-separator)",
}

export default function Example() {
  return (
    <div style={{ display: "grid", gap: "var(--mb-space-6)", width: "100%", maxWidth: 380 }}>
      <div className="mb-text-headline">Network</div>
      <div style={group}>
        <label style={row}>
          Wi-Fi
          <Switch defaultChecked />
        </label>
        <div style={divider} />
        <label style={row}>
          Ask to join networks
          <Switch />
        </label>
        <div style={divider} />
        <div style={row}>
          <span aria-hidden="true">Appearance</span>
          <SegmentedControl aria-label="Appearance" defaultValue="auto" size="sm">
            <SegmentedControlItem value="light">Light</SegmentedControlItem>
            <SegmentedControlItem value="dark">Dark</SegmentedControlItem>
            <SegmentedControlItem value="auto">Auto</SegmentedControlItem>
          </SegmentedControl>
        </div>
      </div>
      <div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--mb-space-4)" }}>
        <Button>Cancel</Button>
        <Button variant="filled">Apply</Button>
      </div>
    </div>
  )
}

Pick a platform in the preview's toolbar. The same markup gets taller rows, larger text and capsule controls on iPadOS and iOS.

How the platform is chosen

<MacbetterScript /> runs before first paint and sets data-platform on <html>. With the default platform="auto" it checks two media queries:

  • A fine pointer, such as a mouse or trackpad, gives macos.
  • A coarse pointer gives ipados when the viewport is at least 744px wide, and ios when it's narrower.

Without the attribute at all, macbetter falls back to macOS metrics.

What changes

Each platform sets the same tokens to different values. These are the values in platforms.css:

TokenmacOSiPadOSiOS
--mb-text-body13/16 regular17/22 regular17/22 regular
--mb-text-headline13/16 semibold17/22 semibold17/22 semibold
--mb-text-large-title26/32 semibold34/41 bold34/41 bold
--mb-control-height-sm22px30px34px
--mb-control-height28px38px44px
--mb-control-height-lg36px50px52px
--mb-control-radius8pxcapsulecapsule
--mb-control-font13px medium17px medium17px medium
--mb-row-height28px44px48px
--mb-menu-item-height24px44px44px
--mb-hit-target24px44px44px
--mb-icon-size16px20px22px
--mb-bar-height52px50px44px
--mb-content-padding20px20px16px

Typography has the full type ramp and Layout and shape the rest of the metrics.

The idioms differ as well as the numbers. On macOS, controls show hover states and bezeled buttons look raised. On iOS and iPadOS there's no hover, bezeled buttons become gray fills, and buttons shrink slightly while pressed. Hover styles only apply under @media (hover: hover) and (pointer: fine), so a touch screen never shows a stuck hover.

How components adapt

Components use two tools, and both are safe to nest.

Different numbers come from inherited custom properties. A component sets its own metrics per platform, and the nearest data-platform wins:

:where(:root, [data-platform="macos"]) {
  --mb-switch-width: 38px;
}

:where([data-platform="ios"], [data-platform="ipados"]) {
  --mb-switch-width: 51px;
}

Different rules use @scope with a lower boundary, so they stop at a nested platform:

@scope ([data-platform="ios"], [data-platform="ipados"]) to (:scope [data-platform]) {
  .mb-button:active {
    scale: 0.97;
  }
}

A plain descendant selector such as [data-platform="ios"] .mb-button would leak into a macOS preview nested inside an iOS page. If you write platform-specific CSS for your own components, use the same two patterns, and reach for the shared tokens (--mb-control-height, --mb-row-height, --mb-hit-target, --mb-text-*) first: most of the time they're all you need.

Force a platform

Pass a platform to the script to skip detection:

<MacbetterScript platform="ios" />

To let people choose, store their choice in localStorage and give the script its key. A saved macos, ipados, ios or auto wins over the platform prop:

<MacbetterScript storageKey="app-platform" />

When the choice changes at runtime, write the new value to localStorage and set document.documentElement.dataset.platform yourself. The script only runs on load.

Nest a platform

Put data-platform on any element to render its subtree as another platform. The attribute restates the body text style, so text inside picks up the right size even before any component does:

<div data-platform="ios">
  <Settings />
</div>

The previews on this site work this way. Here's the same form rendered for all three platforms on one page:

macOS

Network

iPadOS

Network

iOS

Network