Theme studio

Design a theme, preview it live, then export it. Saved in this browser.

Quick picks #10b981
Generated scale
50100200300400500600700800900
Some text is below AA
Design systems

One brand colour, a whole theme

createTheme() turns one brand colour into an 11-step scale, keeps every role readable in light and dark, and warns when a token pair drops below WCAG AA.

NDesign systems team· 5 min read

Nexera UI ships with an emerald brand. Most products need their own. createTheme() takes one colour, generates the full brand scale from it, and checks the result against the same contrast pairs the default theme is tested with, in light and dark mode.

One call

app.tsx
import { createTheme, NexeraProvider } from "@nexera-ui/react";

const theme = createTheme({ brand: "#2563EB" });

export function App({ children }: { children: React.ReactNode }) {
  return (
    <NexeraProvider defaultTheme="system" customTheme={theme}>
      {children}
    </NexeraProvider>
  );
}

Create the theme once, at module level, and pass it to the provider. createTheme() accepts hex colours (#RGB, #RRGGBB, #RRGGBBAA) and rgb() or rgba(). A named colour such as rebeccapurple throws an "unsupported colour" error, so a typo fails loudly.

From one colour to eleven steps

The Figma file defines the brand as Primitives/emerald, eleven steps from 50 to 950. Components never use those steps directly. They use semantic tokens such as brand/primary, links, the focus ring, navigation indicators and the heatmap ramp, and those tokens point at brand steps. Replace the steps and everything that points at them follows.

createTheme() builds the new steps in OKLCH. It keeps the lightness curve of the Figma emerald scale, scales the chroma to your colour and keeps the hue drift, then anchors the curve so step 700 sits at your colour's lightness. Give it #2563EB and step 700 comes back as #2563EB exactly. Give it the Figma emerald #047857 and you get the Figma scale back; a test checks that --nx-brand-500 comes out as #10B981.

Some steps have jobs, and the generator nudges them until they can do those jobs:

  • 700 is the light-mode primary fill and link colour, so it must reach 4.5:1 on white, the page canvas and the brand tint.
  • 600 is the light-mode focus ring, so it must reach 3:1 on white.
  • 500 is the dark-mode primary fill, so it must reach 4.5:1 against the dark label colour.
  • 400 is the dark-mode link and focus ring, so it must reach 4.5:1 on the lightest dark surface.

This is why a light brand colour does not produce a light theme. Ask for yellow #FACC15 and step 700 comes back as #876F1B, dark enough to carry white text and to read as a link.

A label that stays readable

The text on a primary button, brand/on-primary, is derived per mode. createTheme() compares white and the near-black gray/950 against the primary fill and picks whichever has more contrast. A test checks that the yellow brand still gets an on-primary colour with at least 4.5:1.

Warnings you can act on

After building the theme, createTheme() resolves every colour token in both modes and runs the 96 contrast pairs from @nexera-ui/tokens. Each failure becomes a warning with the mode, the two tokens, the measured ratio, the required minimum and a note on what the pair is for.

The validate option decides what happens next:

  • "warn" (the default) logs the warnings with console.warn outside production.
  • "error" throws on any warning, including the known findings described below, so use it in a test that checks your theme's warnings against a list you have accepted.
  • "off" stays quiet. The warnings are still on theme.warnings.

The Figma tokens have five documented contrast findings of their own. When your theme touches one of those pairs, the warning says so with knownFigmaFinding: true, so you can tell an inherited issue from one your colours introduced. With #2563EB, the warnings are three of those known findings, for example sidebar/text-muted on sidebar/active at 4.27:1. A test generates themes from seven brand colours, from blue and red to yellow and grey, and asserts that none of them produces a warning outside that known list.

Overrides beyond the brand

The brand is optional. You can also override single semantic tokens, per mode or for both, and set fonts and radii:

app.tsx
const theme = createTheme({
  name: "Acme",
  brand: "#7C3AED",
  colors: {
    "bg/surface": { light: "#FFFDF7", dark: "#101010" },
    "status/danger/fg": "#B00020",
  },
  fonts: { sans: '"Source Sans 3", sans-serif' },
  radius: { md: "0.25rem" },
});

This example has a mistake the check catches. A single value for status/danger/fg applies to both modes, and a dark red that works on a light danger background sits at 2.25:1 on the dark one. The warning names the pair, status/danger/fg on status/danger/bg in dark mode, with knownFigmaFinding: false. The fix is a per-mode value: { light: "#B00020", dark: "<a lighter red>" }.

Fonts are never bundled; you load them yourself. The token names are the Figma Color collection names, so the names in the design file and in your code match.

What you get back

createTheme() returns an object with a stable id derived from the options, a className such as nx-theme-1328taw, the generated css, the brandScale, the warnings and the resolved colours per mode for tooling.

The provider adds a <style> element with that CSS (pass nonce if you use a Content Security Policy) and puts the class on <html>, or on its wrapper when the provider is nested. The CSS is scoped to that class, so two regions of one page can carry different brands. A light provider nested inside a dark page stays light; a test checks that the dark rules do not reach it.

If you prefer plain CSS, you can still set --nx-brand-700 or any --color-* variable yourself. You lose the generated scale and the contrast check, which are the reasons createTheme() exists. Try it on the components page or read get started for the provider setup.

Keep reading

All posts →

One email when we publish.

New posts and releases, about twice a month. Or follow the RSS feed.