A theme in Bones is a short CSS file, not a fork. Every design decision is a custom property prefixed --ub-, and components only ever read the semantic ones (--ub-bg, --ub-accent, ...), never raw palette steps. No provider, no config file; the switches are HTML attributes.

Color steps

The eight color scales run 50 through 950 on one shared lightness ladder, so any step swaps cleanly across hues; gray runs a slightly darker ladder tuned for backgrounds. Gray is the neutral default. Red, orange, green, and blue back the feedback roles (danger, warning, success, info); violet, teal, fuchsia, and rose are secondary colors for badges and accents. Raw steps are for themes to reference; components never read them directly.

gray
red
orange
green
teal
blue
violet
fuchsia
rose
50
100
200
300
400
500
600
700
800
900
950

Dark mode

Add data-theme="dark" to <html> or any subtree (a .dark class works too). There is no JavaScript involved; it's a CSS scope. To default to the system preference without a flash, run this before paint:

<script>
  try {
    var t = localStorage.getItem("ub-theme");
    if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches))
      document.documentElement.setAttribute("data-theme", "dark");
  } catch (e) {}
</script>

Radius: rounded or pill

Interactive elements read --ub-radius-control, which is 8px by default. Set data-radius="pill" anywhere to make every control inside fully rounded.

<html data-radius="pill">

Your own accent

The neutral default uses near-black as the accent. Give it a color by overriding four tokens:

:root {
  --ub-accent: oklch(0.55 0.2 260);
  --ub-accent-hover: oklch(0.5 0.2 260);
  --ub-accent-contrast: white;
  --ub-ring: oklch(0.7 0.15 260);
}

Prebuilt accent themes

Each of the eight color scales ships as a ready accent theme. One import recolors everything that means "primary action" in both modes:

import "@usebones/tokens/themes/blue.css";

Full themes

A full theme replaces every color role, not just the accent. It ships inert and activates with the same attribute that drives dark mode. matrix is a quirky one to try: green phosphor, sharp corners.

import "@usebones/tokens/themes/matrix.css";

<html data-theme="matrix">

Creating a theme

A theme is a plain CSS file imported after index.css. Accent theme: override the four action tokens in both modes (copy themes/blue.css). Full theme: scope every color role under your own data-theme value:

[data-theme="my-theme"] {
  color-scheme: dark; /* or light: pick one, a full theme replaces both */

  /* backgrounds */
  --ub-bg: ...;
  --ub-bg-subtle: ...;
  --ub-bg-muted: ...;
  --ub-bg-muted-hover: ...;
  --ub-surface: ...;
  --ub-overlay: ...;

  /* text: primary, secondary, tertiary, disabled */
  /* borders: border, border-strong */
  /* interactive: accent, accent-hover, accent-contrast, ring */
  /* feedback: danger (+hover/contrast), success, warning, info */
  /* elevation: shadow-sm/md/lg */
  /* optional: the radius scale, like matrix does */
}

The override reference below is the checklist. Keep primary text at 4.5:1 contrast, leave danger red so destructive reads as destructive. --ub-surface-glass derives from your surface at each data-theme or .darkboundary. If you override --ub-surface in another scope, redeclare the glass token there too: CSS resolves derived custom properties before inheriting them. A theme file works from anywhere; to ship one in @usebones/tokens, open a PR adding a file under css/themes/.

Switch to the Matrix

Overlays in a local theme

Popups normally portal to the document body, outside a local CSS scope. Pass portalContainer to the Content part to keep its popup and backdrop inside the themed element. The same prop lives on NavigationMenuRoot and Toaster. Nested overlays inherit their parent portal target when you omit it.

import { useRef } from "react";
import {
  Button, DialogRoot, DialogTrigger, DialogContent, DialogTitle,
} from "@usebones/react";

export function LocalTheme() {
  const scope = useRef<HTMLDivElement>(null);
  return (
    <div ref={scope} data-theme="dark" data-radius="pill">
      <DialogRoot>
        <DialogTrigger render={<Button />}>Open</DialogTrigger>
        <DialogContent portalContainer={scope}>
          <DialogTitle>Local theme</DialogTitle>
        </DialogContent>
      </DialogRoot>
    </div>
  );
}

Choose a container without clipping or transformed ancestors so the overlay can cover the intended area. For a whole-app theme, set attributes on <html> and keep the default target.

.custom-surface {
  --ub-surface: var(--ub-bg-muted);
  --ub-surface-glass: color-mix(in oklab, var(--ub-surface) 90%, transparent);
}

Styling component states

Below the token layer, every part has a stable class (ub-button, ub-select-trigger) and exposes state as data attributes (data-checked, data-highlighted, data-invalid). Each component page lists its hooks under "Styling states".

.ub-combobox-item[data-highlighted] {
  background: var(--ub-accent);
  color: var(--ub-accent-contrast);
}

Density is a token override

Because components read the size tokens for their default size, remapping them in a scope makes everything inside compact by default, no prop drilling:

.dense-section {
  --ub-font-size: var(--ub-font-size-compact);
  --ub-control-height: var(--ub-control-height-compact);
  --ub-icon-size-default: var(--ub-icon-size-compact);
}

The size tokens themselves are covered in Sizes.

Scoped themes

Tokens cascade, so any override can be scoped to a subtree: a sidebar that's always dark, a marketing section with a different accent. Wrap it in a class and override tokens there.

Override reference

Everything a theme can change, in one place. Colors list their light and dark defaults; the rest apply to both modes.

TokenLightDarkControls
--ub-bgwhitegray-950Page background
--ub-bg-subtlegray-50gray-900Wells, code blocks, panels
--ub-bg-mutedgray-200gray-800Hovers, secondary buttons, active pills
--ub-bg-muted-hovergray-300gray-700One step deeper on hover
--ub-surfacewhitegray-900Cards and popups
--ub-surface-glasssurface at 90%surface at 90%Translucent overlays, pairs with blur
--ub-overlayblack 40%black 60%Dimming behind dialogs
--ub-text-primarygray-950gray-50Headings and body text
--ub-text-secondarygray-600gray-400Supporting text, labels
--ub-text-tertiarygray-400gray-500Hints, captions, placeholders
--ub-text-disabledgray-300gray-700Disabled text
--ub-bordergray-200gray-800Hairlines and dividers
--ub-border-stronggray-300gray-700Inputs, select triggers, scrollbars
--ub-accentgray-900gray-50Primary actions, checked states
--ub-accent-hovergray-800gray-200Primary actions on hover
--ub-accent-contrastwhitegray-950Text and icons on the accent
--ub-ringgray-400gray-500Focus rings
--ub-dangerred-600red-500Destructive actions, invalid states
--ub-danger-hoverred-500red-400Destructive actions on hover
--ub-danger-contrastwhitewhiteText on danger
--ub-successgreen-600green-500Positive feedback
--ub-warningorange-500orange-400Caution
--ub-infoblue-600blue-400Informational
--ub-tint-<hue>-bg / -texthue-100 / hue-700 (gray 200 / 700)hue-900 / hue-300 (gray 800 / 300)Soft fill and text per palette hue; badges
--ub-shadow-sm / md / lgsoftstrongerElevation scale for overlays
--ub-z-overlay1000sameOne layer for every floating part; raise above taller app chrome
--ub-radius-xs to xl, full4 to 16px, 9999pxsameContainer rounding steps (radii, not sizes); pill mode also rounds the sm, md, and lg steps further
--ub-radius-control8px (pill: full)sameEvery interactive element
--ub-font-size / -compact16px / 14pxsameThe two text sizes
--ub-control-height / -compact36px / 28pxsameThe two control heights
--ub-icon-size-default / -compact16px / 14pxsameThe two icon sizes
--ub-duration-fast / base / slow120 / 180 / 280mssameAll motion; zero under reduced motion
--ub-ease-out / in-out / springcurvessameEasings; spring for thumbs and pills