macbetter

Get started

Theming

A theme answers a few questions about your brand: its accent, its ink, its backgrounds, how round its corners are and which fonts it uses. macbetter derives every other color and state from those answers.

Ember
Trinidad on Racing Green
System
Apple blue on true grays
Nebula
Violet, rounder corners
Graphite
Monochrome, squarer corners

These are the four built-in themes, each scoped to its own card with data-theme.

Seeds and derived tokens

Tokens come in two kinds:

  • Seeds are what a theme sets. They're the first block in tokens.css.
  • Derived tokens are computed from the seeds with color-mix() and relative color syntax. Hover and pressed states, labels, fills, separators, focus rings, materials and shadows are all derived. A theme doesn't set them.

Because derived tokens are declared on every [data-theme] element as well as :root, they recompute wherever a theme changes the seeds.

A theme can set as few seeds as it likes. Any seed it leaves out keeps the Ember default.

The seeds

Brand

SeedWhat it drives
--mb-accentFilled buttons, tinted fills, text selection, focus rings, and accent-color for native inputs
--mb-on-accentText and icons on the accent. Needs 4.5:1 against it
--mb-accent-textThe accent as text on the background: links, plain and tinted buttons. Needs 4.5:1 against --mb-bg
--mb-accent-secondaryA second brand color for illustration and accents that aren't actions

Surfaces and ink

SeedWhat it drives
--mb-inkText at full strength. Every label level is an opacity of it, and so are separators and control edges
--mb-bgThe window or page background
--mb-bg-secondaryGrouped pages, the background behind inset lists
--mb-bg-tertiaryGroups and cards on a secondary background
--mb-bg-elevatedPopovers, menus and sheets, and the base of most materials
--mb-bg-sidebarSidebars and source lists
--mb-tintThe gray that fills and control backgrounds are mixed from. Tint it toward the brand

System palette

--mb-red, --mb-orange, --mb-yellow, --mb-green, --mb-mint, --mb-teal, --mb-cyan, --mb-blue, --mb-indigo, --mb-purple, --mb-pink and --mb-brown. These are the HIG's system colors, for status and categories. --mb-red is also the destructive color and --mb-green the success color and the switch's on state.

Shape and type

SeedWhat it drives
--mb-radius-scaleMultiplies every radius token. 1 is Apple's corners; lower is squarer, higher is rounder. Capsules stay capsules
--mb-fontText and controls
--mb-font-displayTitles. Defaults to --mb-font
--mb-font-roundedThe mb-text-rounded class
--mb-font-monoCode and the mb-text-mono class

Light and dark

Every color seed is written with light-dark(), so one theme covers both schemes:

--mb-accent: light-dark(#ee5700, #f67f01);

The browser picks the first value under a light color-scheme and the second under a dark one. macbetter sets color-scheme: light dark by default, so the system setting decides. data-scheme="light" or data-scheme="dark" on any element pins it for that subtree.

Choose the dark value separately rather than inverting the light one. Dark backgrounds usually need a lighter, more saturated accent to reach the same contrast.

Write a theme in CSS

A theme is a rule that sets seeds under a data-theme name. Load it after macbetter's stylesheet:

[data-theme="harbor"] {
  --mb-accent: light-dark(#0b6e99, #3fb3e6);
  --mb-on-accent: light-dark(#ffffff, #04202e);
  --mb-accent-text: light-dark(#0a5f85, #6cc7ef);
  --mb-ink: light-dark(#0b1a22, #eaf4f8);
  --mb-bg: light-dark(#ffffff, #0b1a22);
  --mb-tint: light-dark(#5d6f78, #86a3b0);
  --mb-radius-scale: 1.1;
}
<html data-theme="harbor">

Write a theme in TypeScript

macbetter/theme builds the same thing from an object. Each color takes a single value or a { light, dark } pair.

createTheme(name, seeds) returns the CSS as a string, for a build step or a <style> tag:

import { createTheme } from "macbetter/theme"

const css = createTheme("harbor", {
  accent: { light: "#0b6e99", dark: "#3fb3e6" },
  onAccent: { light: "#ffffff", dark: "#04202e" },
  accentText: { light: "#0a5f85", dark: "#6cc7ef" },
  palette: { blue: { light: "#0b6e99", dark: "#3fb3e6" } },
  radiusScale: 1.1,
  font: '"Inter", system-ui, sans-serif',
})

themeVariables(seeds) returns the custom properties as an object, ready for a style prop. The element still needs a data-theme attribute, so the derived tokens recompute on it:

import { Button } from "macbetter/button"
import { Switch } from "macbetter/switch"
import { themeVariables } from "macbetter/theme"

const harbor = themeVariables({
  accent: { light: "#0b6e99", dark: "#3fb3e6" },
  onAccent: { light: "#ffffff", dark: "#04202e" },
  accentText: { light: "#0a5f85", dark: "#6cc7ef" },
  ink: { light: "#0b1a22", dark: "#eaf4f8" },
  bg: { light: "#ffffff", dark: "#0b1a22" },
  bgElevated: { light: "#ffffff", dark: "#12262f" },
  tint: { light: "#5d6f78", dark: "#86a3b0" },
})

export default function Example() {
  return (
    <div
      data-theme="harbor"
      data-macbetter=""
      style={{
        ...harbor,
        display: "grid",
        gap: "var(--mb-space-5)",
        padding: "var(--mb-space-7)",
        borderRadius: "var(--mb-group-radius)",
        boxShadow: "0 0 0 var(--mb-hairline) var(--mb-separator)",
        minWidth: 260,
      }}
    >
      <label style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 12 }}>
        Tide alerts
        <Switch defaultChecked />
      </label>
      <div style={{ display: "flex", gap: "var(--mb-space-4)" }}>
        <Button variant="filled">Book a slip</Button>
        <Button variant="tinted">Charts</Button>
      </div>
    </div>
  )
}
PropTypeDefaultDescription
accentColorPair—Required. Becomes --mb-accent.
onAccentColorPair—--mb-on-accent.
accentTextColorPair—--mb-accent-text.
accentSecondaryColorPair—--mb-accent-secondary.
inkColorPair—--mb-ink.
bg, bgSecondary, bgTertiary, bgElevated, bgSidebarColorPair—The background seeds.
tintColorPair—--mb-tint.
palettePartial<Record<SystemColor, ColorPair>>—Overrides for any system color, keyed red, orange, yellow, green, mint, teal, cyan, blue, indigo, purple, pink or brown.
radiusScalenumber—--mb-radius-scale.
font, fontDisplay, fontMonostring—Font stacks. There's no option for the rounded font; set --mb-font-rounded in CSS.

ColorPair is string | { light: string; dark: string }.

Built-in themes

ThemeCharacter
emberThe default. Trinidad and Gold Drop orange on Racing Green, with Racing Green text on the accent
systemThe neutral Apple look: system blue on true grays, Apple's system palette
nebulaViolet on cool ink, --mb-radius-scale: 1.15
graphiteMonochrome for dense tools: the accent is ink, --mb-radius-scale: 0.7

All four are in styles.css. Ember is also the value of :root, so a page with no data-theme looks like Ember.

Contrast

A theme is your responsibility to check. These are the targets:

  • --mb-ink, --mb-accent-text and --mb-on-accent reach 4.5:1 against what they sit on.
  • Control edges, focus rings and state fills reach 3:1. --mb-control-border is derived to do this from --mb-ink.

Check both schemes. contrastRatio() computes the WCAG ratio of two hex colors:

import { contrastRatio } from "macbetter/theme"

contrastRatio("#0a5f85", "#ffffff") // 7.03
contrastRatio("#04202e", "#3fb3e6") // 7.03

It takes six- or three-digit hex only, so check the hex values you write in your seeds, not derived colors. Accessibility shows the built-in themes' ratios. The System theme keeps Apple's blue with white text on it, which falls short of 4.5:1.

Scope a theme to a subtree

data-theme works on any element, not just <html>. Colors inside switch to that theme and re-derive:

<section data-theme="nebula" data-macbetter="">
  <Toolbar />
</section>

Add data-macbetter when the subtree should also take the theme's background and text color.

A nested theme recomputes everything that depends on it, including the control radius and text styles that come from the platform, so data-theme is all you need.

Forcing light or dark on part of a page

data-scheme="light" or data-scheme="dark" sets the scheme for a subtree. Put data-theme on the same element, even if the theme hasn't changed:

<aside data-scheme="dark" data-theme="ember">
  <Inspector />
</aside>