Theming
Pick a theme, then bring your accent, corners and density. A theme is one StyleX file you own.
A theme is theme.stylex.ts: one file of variables you own. Components read only tokens, and tokens read these variables, so every component follows the theme in light and dark.
Theme builder
Pick a theme, then change its accent, radius and sizing if you like, and copy the file. The page wears the theme while you build it.
// Polaris: gray grays, ink accent, medium radius, default sizing.
import * as stylex from "@stylexjs/stylex";
export const theme = stylex.defineVars({
"--neutral-1": "var(--gray-1)",
"--neutral-2": "var(--gray-2)",
"--neutral-3": "var(--gray-3)",
"--neutral-4": "var(--gray-4)",
"--neutral-5": "var(--gray-5)",
"--neutral-6": "var(--gray-6)",
"--neutral-7": "var(--gray-7)",
"--neutral-8": "var(--gray-8)",
"--neutral-9": "var(--gray-9)",
"--neutral-10": "var(--gray-10)",
"--neutral-11": "var(--gray-11)",
"--neutral-12": "var(--gray-12)",
"--neutral-a1": "var(--gray-a1)",
"--neutral-a2": "var(--gray-a2)",
"--neutral-a3": "var(--gray-a3)",
"--neutral-a4": "var(--gray-a4)",
"--neutral-a5": "var(--gray-a5)",
"--neutral-a6": "var(--gray-a6)",
"--neutral-a7": "var(--gray-a7)",
"--neutral-a8": "var(--gray-a8)",
"--neutral-a9": "var(--gray-a9)",
"--neutral-a10": "var(--gray-a10)",
"--neutral-a11": "var(--gray-a11)",
"--neutral-a12": "var(--gray-a12)",
"--accent-1": "var(--neutral-1)",
"--accent-2": "var(--neutral-2)",
"--accent-3": "var(--neutral-3)",
"--accent-4": "var(--neutral-4)",
"--accent-5": "var(--neutral-5)",
"--accent-6": "var(--neutral-6)",
"--accent-7": "var(--neutral-7)",
"--accent-8": "var(--neutral-8)",
"--accent-9": "var(--neutral-9)",
"--accent-10": "var(--neutral-10)",
"--accent-11": "var(--neutral-11)",
"--accent-12": "var(--neutral-12)",
"--accent-a1": "var(--neutral-a1)",
"--accent-a2": "var(--neutral-a2)",
"--accent-a3": "var(--neutral-a3)",
"--accent-a4": "var(--neutral-a4)",
"--accent-a5": "var(--neutral-a5)",
"--accent-a6": "var(--neutral-a6)",
"--accent-a7": "var(--neutral-a7)",
"--accent-a8": "var(--neutral-a8)",
"--accent-a9": "var(--neutral-a9)",
"--accent-a10": "var(--neutral-a10)",
"--accent-a11": "var(--neutral-a11)",
"--accent-a12": "var(--neutral-a12)",
"--accent-contrast": "var(--neutral-1)",
"--background": "var(--neutral-1)",
"--sidebar": "var(--neutral-2)",
"--text-primary": "var(--neutral-12)",
"--text-secondary": "var(--neutral-11)",
"--text-muted": "color-mix(in oklab, var(--neutral-10), var(--neutral-11))",
"--fill-subtle": "var(--neutral-a2)",
"--fill": "var(--neutral-a3)",
"--fill-strong": "var(--neutral-a4)",
"--fill-opaque": "var(--neutral-3)",
"--border-subtle": "var(--neutral-4)",
"--border": "var(--neutral-6)",
"--border-strong": "var(--neutral-9)",
"--inverted-solid": "var(--neutral-12)",
"--inverted-on-solid": "var(--neutral-1)",
"--font-mono": '"Geist Mono Variable", "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace',
"--font-sans": '"Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, sans-serif',
"--overlay": "var(--black-a5)",
"--accent-solid": "var(--neutral-12)",
"--accent-on-solid": "var(--neutral-1)",
"--focus-ring": "var(--neutral-8)",
"--selection": "var(--neutral-a5)",
"--radius": "0.5rem",
"--size-control-xxs": "1.25rem",
"--size-control-xs": "1.5rem",
"--size-control-sm": "1.75rem",
"--size-control-md": "2rem",
"--size-control-lg": "2.25rem",
"--space-xs": "0.5rem",
"--space-sm": "0.75rem",
"--space-md": "1rem",
"--space-lg": "1.5rem",
"--space-xl": "3rem",
});
Accents are Radix Colors scales by name, so they're tuned for light and dark already, and the file imports them from @radix-ui/colors, which comes with Constella. Ink is the monochrome accent: the solid fill is the gray ink, as in Polaris. Amber, yellow, lime, mint and sky take dark text on their solid fill.
Themes
Themes are named after stars, each hinting at its color. A theme sets colors only: its grays and a default accent. Radius and sizing are yours to pick on top.
| Theme | Grays | Accent | Note |
|---|---|---|---|
polaris | Gray | Ink | The default; ships as theme.stylex.ts |
vega | Slate | Indigo | A blue-white star |
antares | Sand | Orange | A red supergiant, kept clear of danger |
Polaris is the theme.stylex.ts Constella installs.
Usage
Constella installs lib/constella/theme.stylex.ts with Polaris in it. To theme your app, replace it with the builder's file, or edit it by hand: it's yours, and nothing else in the system needs changing.
// lib/constella/theme.stylex.tsimport "@radix-ui/colors/indigo.css";import "@radix-ui/colors/indigo-dark.css";import "@radix-ui/colors/indigo-alpha.css";import "@radix-ui/colors/indigo-dark-alpha.css";import * as stylex from "@stylexjs/stylex";
export const theme = stylex.defineVars({ "--accent-9": "var(--indigo-9)", // … "--radius": "12px",});Dark mode is still the .dark class on <html>: Radix's dark scales switch on it, so every theme works in both.
More than one theme
To let people switch themes at runtime, keep your default in theme.stylex.ts and make each other one a createTheme of it, applied to <html>. It only lists what differs.
import * as stylex from "@stylexjs/stylex";import { theme } from "@/lib/constella/theme.stylex";
const vega = stylex.createTheme(theme, { "--accent-9": "var(--indigo-9)", // …});
<html {...stylex.props(vega)} lang="en">Apply a theme to <html>, not a subtree. Popups render in a portal at the end of <body>, so they only see a theme set on the root.
Variables
Everything in theme.stylex.ts. Variables keep their literal names, so base.css can read them.
Scales
--neutral-1 … --neutral-12 and --neutral-a1 … --neutral-a12, the same for --accent, and --accent-contrast for text on accent step 9. Each step points at a Radix scale by name; import that scale's four files at the top.
Roles
What components ask for. Each is a scale step, so changing one changes only that role, in light and dark.
| Variable | Default | For |
|---|---|---|
--background | --neutral-1 | The page |
--sidebar | --neutral-2 | The sidebar, a step off the page |
--text-primary | --neutral-12 | Content |
--text-secondary | --neutral-11 | Supporting copy |
--text-muted | Halfway from --neutral-10 to -11 | Metadata, placeholders, counts |
--fill-subtle, --fill, --fill-strong | --neutral-a2, -a3, -a4 | Hover, selected (a row, the current nav item), pressed |
--fill-opaque | --neutral-3 | A fill nothing shows through: avatars, tracks, user messages |
--border-subtle, --border, --border-strong | --neutral-4, -6, -9 | Dividers and the composer's ring, control borders, an empty checkbox's edge |
--accent-solid | --neutral-12 | Primary buttons, checked controls |
--accent-on-solid | --neutral-1 | Text and icons on the accent |
--inverted-solid, --inverted-on-solid | --neutral-12, --neutral-1 | Tooltips |
--focus-ring | --neutral-8 | The focus outline |
--selection | --neutral-a5 | Selected text |
--overlay | --black-a5 | Behind a dialog |
Muted text and raised surfaces differ between light and dark, so base.css sets them from the scales.
Shape and density
| Variable | Default | For |
|---|---|---|
--radius | 8px | Controls; menus and cards derive theirs from it |
--size-control-xxs … -lg | 20 … 36px | Control heights |
--space-xs … --space-xl | 8 … 48px | Spacing; xxxs and xxs stay fixed |
The other corners step from --radius by min(--radius, 4px), so each nests concentrically in the next and all reach 0 together:
| Corner | Value | At 8px |
|---|---|---|
xs | radius − step | 4px |
sm | radius | 8px |
md | radius + step | 12px |
lg | radius + 2 steps | 16px |
xl | radius + 3 steps | 20px |
Status colors
Danger, success, warning and info stay red, green, amber and blue in every theme, so they mean the same thing in any product. Avoid an accent close to a status hue. A red primary button reads as destructive.