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.
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
| Seed | What it drives |
|---|---|
--mb-accent | Filled buttons, tinted fills, text selection, focus rings, and accent-color for native inputs |
--mb-on-accent | Text and icons on the accent. Needs 4.5:1 against it |
--mb-accent-text | The accent as text on the background: links, plain and tinted buttons. Needs 4.5:1 against --mb-bg |
--mb-accent-secondary | A second brand color for illustration and accents that aren't actions |
Surfaces and ink
| Seed | What it drives |
|---|---|
--mb-ink | Text at full strength. Every label level is an opacity of it, and so are separators and control edges |
--mb-bg | The window or page background |
--mb-bg-secondary | Grouped pages, the background behind inset lists |
--mb-bg-tertiary | Groups and cards on a secondary background |
--mb-bg-elevated | Popovers, menus and sheets, and the base of most materials |
--mb-bg-sidebar | Sidebars and source lists |
--mb-tint | The 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
| Seed | What it drives |
|---|---|
--mb-radius-scale | Multiplies every radius token. 1 is Apple's corners; lower is squarer, higher is rounder. Capsules stay capsules |
--mb-font | Text and controls |
--mb-font-display | Titles. Defaults to --mb-font |
--mb-font-rounded | The mb-text-rounded class |
--mb-font-mono | Code 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>
)
}| Prop | Type | Default | Description |
|---|---|---|---|
accent | ColorPair | — | Required. Becomes --mb-accent. |
onAccent | ColorPair | — | --mb-on-accent. |
accentText | ColorPair | — | --mb-accent-text. |
accentSecondary | ColorPair | — | --mb-accent-secondary. |
ink | ColorPair | — | --mb-ink. |
bg, bgSecondary, bgTertiary, bgElevated, bgSidebar | ColorPair | — | The background seeds. |
tint | ColorPair | — | --mb-tint. |
palette | Partial<Record<SystemColor, ColorPair>> | — | Overrides for any system color, keyed red, orange, yellow, green, mint, teal, cyan, blue, indigo, purple, pink or brown. |
radiusScale | number | — | --mb-radius-scale. |
font, fontDisplay, fontMono | string | — | 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
| Theme | Character |
|---|---|
ember | The default. Trinidad and Gold Drop orange on Racing Green, with Racing Green text on the accent |
system | The neutral Apple look: system blue on true grays, Apple's system palette |
nebula | Violet on cool ink, --mb-radius-scale: 1.15 |
graphite | Monochrome 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-textand--mb-on-accentreach 4.5:1 against what they sit on.- Control edges, focus rings and state fills reach 3:1.
--mb-control-borderis 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.03It 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>