Theming
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.
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/.
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.
| Token | Light | Dark | Controls |
|---|---|---|---|
--ub-bg | white | gray-950 | Page background |
--ub-bg-subtle | gray-50 | gray-900 | Wells, code blocks, panels |
--ub-bg-muted | gray-200 | gray-800 | Hovers, secondary buttons, active pills |
--ub-bg-muted-hover | gray-300 | gray-700 | One step deeper on hover |
--ub-surface | white | gray-900 | Cards and popups |
--ub-surface-glass | surface at 90% | surface at 90% | Translucent overlays, pairs with blur |
--ub-overlay | black 40% | black 60% | Dimming behind dialogs |
--ub-text-primary | gray-950 | gray-50 | Headings and body text |
--ub-text-secondary | gray-600 | gray-400 | Supporting text, labels |
--ub-text-tertiary | gray-400 | gray-500 | Hints, captions, placeholders |
--ub-text-disabled | gray-300 | gray-700 | Disabled text |
--ub-border | gray-200 | gray-800 | Hairlines and dividers |
--ub-border-strong | gray-300 | gray-700 | Inputs, select triggers, scrollbars |
--ub-accent | gray-900 | gray-50 | Primary actions, checked states |
--ub-accent-hover | gray-800 | gray-200 | Primary actions on hover |
--ub-accent-contrast | white | gray-950 | Text and icons on the accent |
--ub-ring | gray-400 | gray-500 | Focus rings |
--ub-danger | red-600 | red-500 | Destructive actions, invalid states |
--ub-danger-hover | red-500 | red-400 | Destructive actions on hover |
--ub-danger-contrast | white | white | Text on danger |
--ub-success | green-600 | green-500 | Positive feedback |
--ub-warning | orange-500 | orange-400 | Caution |
--ub-info | blue-600 | blue-400 | Informational |
--ub-tint-<hue>-bg / -text | hue-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 / lg | soft | stronger | Elevation scale for overlays |
--ub-z-overlay | 1000 | same | One layer for every floating part; raise above taller app chrome |
--ub-radius-xs to xl, full | 4 to 16px, 9999px | same | Container rounding steps (radii, not sizes); pill mode also rounds the sm, md, and lg steps further |
--ub-radius-control | 8px (pill: full) | same | Every interactive element |
--ub-font-size / -compact | 16px / 14px | same | The two text sizes |
--ub-control-height / -compact | 36px / 28px | same | The two control heights |
--ub-icon-size-default / -compact | 16px / 14px | same | The two icon sizes |
--ub-duration-fast / base / slow | 120 / 180 / 280ms | same | All motion; zero under reduced motion |
--ub-ease-out / in-out / spring | curves | same | Easings; spring for thumbs and pills |