Theming

Pick a theme, then bring your accent, corners and density. A theme is one StyleX file you own.

View as Markdown

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.

New
Volume
60
// 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.

ThemeGraysAccentNote
polarisGrayInkThe default; ships as theme.stylex.ts
vegaSlateIndigoA blue-white star
antaresSandOrangeA 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.

VariableDefaultFor
--background--neutral-1The page
--sidebar--neutral-2The sidebar, a step off the page
--text-primary--neutral-12Content
--text-secondary--neutral-11Supporting copy
--text-mutedHalfway from --neutral-10 to -11Metadata, placeholders, counts
--fill-subtle, --fill, --fill-strong--neutral-a2, -a3, -a4Hover, selected (a row, the current nav item), pressed
--fill-opaque--neutral-3A fill nothing shows through: avatars, tracks, user messages
--border-subtle, --border, --border-strong--neutral-4, -6, -9Dividers and the composer's ring, control borders, an empty checkbox's edge
--accent-solid--neutral-12Primary buttons, checked controls
--accent-on-solid--neutral-1Text and icons on the accent
--inverted-solid, --inverted-on-solid--neutral-12, --neutral-1Tooltips
--focus-ring--neutral-8The focus outline
--selection--neutral-a5Selected text
--overlay--black-a5Behind a dialog

Muted text and raised surfaces differ between light and dark, so base.css sets them from the scales.

Shape and density

VariableDefaultFor
--radius8pxControls; menus and cards derive theirs from it
--size-control-xxs … -lg20 … 36pxControl heights
--space-xs … --space-xl8 … 48pxSpacing; 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:

CornerValueAt 8px
xsradius − step4px
smradius8px
mdradius + step12px
lgradius + 2 steps16px
xlradius + 3 steps20px

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.