Theming
Design tokens, palettes and radius.
Everything visual in Join UI resolves through CSS custom properties. Change a token and every component follows — including the ones already installed in your project.
The principle
The system has two layers, and they follow different rules. The chrome — navigation, sidebar, buttons, documentation type, code blocks, catalog cards — is neutral. It has no brand hue at all.
That is a decision rather than an omission, and it is worth stating plainly because the obvious alternative is worse. Pick an accent, and it immediately wants to be everywhere: the link, the active row, the inline code span, the selected chip, the step number, the focus ring. Each one is defensible on its own and the result is a page where the colour has stopped meaning anything, because everything has it. What is left reads as decoration.
So emphasis here is carried by three things that do not have that failure mode:
- Ink. The filled dark shape is the loudest thing available, and there is usually one of it per view.
- Material. A panel sits one step above the page, on a hairline and the faintest shadow. That step is what makes a card a card, an active sidebar row active, and a selected tab selected — and because it is defined as a direction rather than a value, it is the same move in both themes.
- Space.
Neither end of either ramp is a pure value. The strongest pair the interface can
draw is 17:1 in dark and 15.5:1 in light — a comfortable reading contrast rather
than a maximal one. Nothing in the chrome reaches for #000 or #fff, and that
is most of what separates this from a high-contrast editorial system. In the
dark theme in particular, a page at true black turns every large surface into a
hole and every hairline into a scratch.
Hue is reserved for the places where it is the information: the four semantic families below, used by registry components for a status chip or a progress marker, by the two serious callout variants, and by syntax highlighting. Even there it is held at low chroma and never allowed to carry the meaning alone.
The neutral ramp
Twelve steps, from white to ink, and every neutral token below is an alias onto one of them.
:root,
.light {
--grey-0: oklch(1 0 0); /* #ffffff — cards, popovers */
--grey-50: oklch(0.978 0.005 78); /* #faf7f4 */
--grey-100: oklch(0.955 0.009 76); /* #f4efea — the page */
--grey-200: oklch(0.928 0.011 74); /* #ece6df */
--grey-300: oklch(0.898 0.011 72); /* #e2dcd6 — the hairline */
--grey-400: oklch(0.845 0.012 70); /* #d1cbc4 */
--grey-500: oklch(0.73 0.012 66); /* #ada6a0 */
--grey-600: oklch(0.6 0.013 62); /* #867f78 */
--grey-700: oklch(0.472 0.014 58); /* #625a54 — secondary text */
--grey-800: oklch(0.36 0.014 54); /* #433b36 */
--grey-900: oklch(0.27 0.013 50); /* #2c2521 */
--grey-1000: oklch(0.21 0.011 48); /* #1d1714 — the ink */
}In oklch the first component is perceptual lightness, so the steps are evenly
spaced to the eye rather than evenly spaced in bytes. The second is chroma and
the third is hue — here between 0.005 and 0.014 at a warm angle, which is
enough that the page resolves to #f4efea instead of #f5f5f5 and the ink to
#1d1714 instead of #000000, and not enough that either reads as a colour.
That trace of warmth is doing most of the work in both directions. A greige page
with white panels on it is soft in a way that a white page with grey panels is
not, and a charcoal that still has a little brown in it is warm in a way that
neutral #111 is not. Neither costs anything structurally.
Dark mode does not negate this ramp; it substitutes a warm-charcoal one that
runs the same direction. --grey-0 is the page in both themes and --grey-1000
is the ink in both, which is why the semantic layer barely changes between them.
The dark ramp is the one this site renders — see dark mode.
Semantic tokens
Nothing outside this block should reference --grey-* directly. Components read
the semantic names, so retuning the ramp retunes the whole interface.
:root,
.light {
/* Surfaces. A card is white and sits one step above the page. */
--background: var(--grey-100);
--foreground: var(--grey-1000);
--card: var(--grey-0);
--card-foreground: var(--grey-1000);
--popover: var(--grey-0);
--popover-foreground: var(--grey-1000);
--muted: var(--grey-200);
--muted-foreground: var(--grey-700);
--subtle: var(--grey-50);
/* Rules. Three weights, and each has one job. */
--border: var(--grey-300); /* the resting hairline */
--border-hover: var(--grey-400); /* the same hairline, on hover */
--border-strong: var(--grey-600); /* an emphasis rule; clears 3:1 */
--input: var(--grey-400);
--ring: var(--grey-900);
/* Emphasis is ink. */
--primary: var(--grey-1000);
--primary-hover: var(--grey-900);
--primary-foreground: var(--grey-0);
--primary-soft: var(--grey-200);
--accent: var(--grey-1000);
--accent-foreground: var(--grey-0);
--accent-soft: var(--grey-200);
--accent-border: var(--grey-400);
--secondary: var(--grey-50);
--secondary-foreground: var(--grey-900);
/* Chrome status, aliased onto the component hue families. */
--destructive: var(--critical);
--destructive-foreground: var(--critical-foreground);
--destructive-soft: var(--critical-soft);
--success: var(--positive);
--success-soft: var(--positive-soft);
--warning: var(--caution);
--warning-soft: var(--caution-soft);
--code-bg: var(--grey-0);
--selection: oklch(0.21 0.011 48 / 0.12);
--radius: 0.625rem;
}--accent and --primary both resolve to ink and differ only in intent:
--primary is a fill, --accent is the same ink used as text or as a marker.
They are kept separate because they are the pair you would repoint if you ever
did want a brand hue — see retuning — and because a
component asking for one should not silently get the other.
--border-hover exists for a small reason worth naming. A card's resting border
and its hover border cannot be the same token, or the hover does nothing; with
no accent to switch to, the hover is one step down the ramp plus a slightly
deeper shadow.
Then map them onto utilities with @theme inline — see
Tailwind setup.
The component palette
Four families carry hue, and each is three tokens with the same shape. --X is
the ink: a solid fill, and the colour of text and icons sitting on the tint.
--X-soft is that tint, used as a background. --X-foreground is whatever sits
on top of the solid --X fill.
:root,
.light {
--info: oklch(0.5 0.11 245); /* in progress, informational */
--info-soft: oklch(0.958 0.019 245);
--info-foreground: oklch(0.99 0.006 245);
/* …and the same three for --positive, --caution and --critical. */
}--positive is green and reads as success or complete, --caution is amber and
reads as a warning, --critical is red and covers error, blocked, and anything
destructive. Chroma is held between 0.095 and 0.145 on the inks and near
nothing on the tints — well under where an off-the-shelf palette would put them.
Against a chrome with no hue in it, a saturated chip does not read as
emphatic, it reads as a different website.
The chrome's --destructive, --success and --warning are aliases onto these
families, so a destructive control in the interface and a --critical chip
inside a component are the same red.
Every family is declared twice, once in :root, .light and once in .dark, so
a component never needs a dark: variant to pick the right hue. That is not
housekeeping: it is what makes a component portable into a light project, and it
remains a hard rule. See dark mode.
All of them are registered in @theme inline, so they arrive as ordinary
utilities:
<span className="inline-flex items-center gap-1.5 rounded-soft-sm bg-info-soft px-2 py-1 text-info">
<Loader2 aria-hidden="true" className="size-3.5" />
In progress
</span>Token reference
| Token | Purpose |
|---|---|
--grey-0 … --grey-1000 | The ramp. Every neutral token aliases onto it |
--background / --foreground | Page surface and body text |
--card / --card-foreground | Panel surfaces, one step above the page |
--popover / --popover-foreground | Floating surfaces — menus, dialogs, toasts |
--muted / --muted-foreground | Recessed fill and secondary text |
--subtle | The quietest fill; asides, table headers |
--border | The resting hairline |
--border-hover | The same hairline one step down, for hover |
--border-strong | Emphasis rule, at or above 3:1 |
--input | Form control boundary |
--ring | Focus outline |
--primary / --primary-foreground | The filled ink surface; the primary action |
--primary-hover | Its hover step |
--primary-soft | Flat tint for a selected or held state |
--accent / --accent-foreground | The same ink, used as text or as a marker |
--accent-soft / --accent-border | Tint and hairline for a held surface |
--secondary / --secondary-foreground | Quiet fill with normal ink on top |
--destructive / --success / --warning | Chrome status; aliases onto the hue families |
--info / --info-soft / --info-foreground | Component hue: in progress, informational |
--positive / --positive-soft / --positive-foreground | Component hue: success, complete |
--caution / --caution-soft / --caution-foreground | Component hue: warning |
--critical / --critical-soft / --critical-foreground | Component hue: error, blocked, destructive |
--code-bg | Code block fill |
--selection | Text selection wash |
--radius | Base radius for the chrome, 0.625rem |
--radius-soft-sm / --radius-soft / --radius-soft-lg | The component radius scale — a tag, a control, a card |
--elevation-xs … --elevation-lg | Shadow steps, reached as shadow-xs … shadow-lg |
--duration-* / --ease-* | Motion timing |
Colour is never the only channel
Hue is available in the component layer, which removes a guard rail a fully greyscale system would enforce for free. The obligation it enforced still stands.
This is also why StatusBadge is neutral. Four registry states — stable,
new, updated, experimental — were each given a hue at one point, and a
catalog page of them turned into a colour chart in which no colour meant
anything. The words were already doing the work. They are now separated by fill
weight instead, and only new is worth interrupting a scan for:
<p className="flex items-center gap-2 text-critical">
<AlertTriangle aria-hidden="true" className="size-4" />
<span className="font-medium">Payment failed</span>
</p>Shape and elevation
The chrome has a real radius scale, and the Tailwind steps resolve through it:
@theme inline {
--radius-xs: 0.25rem; /* a tag */
--radius-sm: 0.375rem; /* an inline code span, a chip */
--radius-md: 0.5rem; /* a menu item */
--radius-lg: 0.625rem; /* a control — buttons, inputs */
--radius-xl: 0.875rem; /* a card, a code block */
--radius-2xl: 1.125rem; /* a dialog */
--radius-3xl: 1.5rem;
}Components round through a scale of their own — --radius-soft-sm,
--radius-soft and --radius-soft-lg, reached as rounded-soft-sm,
rounded-soft and rounded-soft-lg. These are aliases onto sm, lg and xl
above, so anything already installed from the registry keeps rounding exactly as
it did. rounded-full is untouched in both layers, because a pip or an avatar
is a circle rather than a rounded rectangle.
Elevation is four steps and the shadow colour is the theme's ink rather than
black, so a raised surface reads as lit paper instead of as a cut-out. Nothing
in the chrome goes above -md except a portal:
:root {
--elevation-xs: 0 1px 2px -1px oklch(0.24 0.014 52 / 0.07);
--elevation-sm:
0 1px 2px -1px oklch(0.24 0.014 52 / 0.06), 0 2px 6px -2px oklch(0.24 0.014 52 / 0.05);
/* …-md and -lg widen the blur and deepen the opacity from there. */
}Durations and easings are tokens for the same reason colours are — so timing stays consistent across components you installed months apart:
className="transition-colors duration-[var(--duration-fast)] ease-[var(--ease-out-soft)]"Retuning the ramp
Softening or hardening the interface is a change to a few steps of the neutral ramp. Edit those, leave the semantic layer alone, and every surface moves:
:root {
/* Cooler and flatter: a grey page, a graphite ink. */
--grey-100: oklch(0.965 0.002 260);
--grey-300: oklch(0.9 0.004 260);
--grey-1000: oklch(0.24 0.008 265);
}If you do want a brand hue, add it at the emphasis tokens rather than across the
ramp — that is the whole reason --primary and --accent are separate from
--foreground:
:root {
--primary: oklch(0.545 0.192 274);
--primary-hover: oklch(0.468 0.166 274);
--primary-foreground: oklch(0.99 0.005 275);
--ring: oklch(0.63 0.176 275);
}Keep the neutral ramp's chroma under about 0.02 either way. Past that it stops
reading as a warm or cool neutral and starts competing with whatever hue the
components carry.
Scoped themes
Tokens are inherited custom properties, so any subtree can override them. This is a supported pattern, not a hack — inverting a section is a token swap:
<section
style={{
"--background": "var(--grey-1000)",
"--foreground": "var(--grey-0)",
"--border": "var(--grey-700)",
} as React.CSSProperties}
>
<YourComponent>Inverted panel</YourComponent>
</section>This is the same mechanism that lets a component installed in a light project
theme correctly without a single dark: variant — see
dark mode.
Component-level overrides
Every component merges className last through cn, so twMerge resolves the
conflict in your favour:
<YourComponent className="rounded-2xl border-border-strong bg-subtle p-8" />Where a component owns more than one surface, it exposes a second class hook rather than making you fight selectors:
<YourComponent className="border-border-strong" innerClassName="bg-subtle p-8" />Contrast
The headline pairs, measured against the resolved values above:
| Pair | Light | Dark |
|---|---|---|
--foreground on --background | 15.5:1 | 17.0:1 |
--foreground on --card | 17.7:1 | 15.4:1 |
--muted-foreground on --background | 5.9:1 | 8.8:1 |
--muted-foreground on --card | 6.8:1 | 8.0:1 |
--muted-foreground on --muted | 5.5:1 | 6.3:1 |
--primary-foreground on --primary | 17.7:1 | 17.0:1 |
--border-strong on --background | 3.5:1 | 3.8:1 |
The headline pair tops out around 17:1 rather than the 21:1 a black-on-white system gets, and that is the point — it is the deliberate cost of surfaces that stop short of both extremes.
--muted-foreground is the token to watch. It carries most of the body copy and
it is the one people lighten first when a page feels heavy; in the light theme
it has roughly two steps of headroom above 4.5:1 and no more. The dark theme has
more room, which is a trap rather than a licence — light text on a dark surface
looks heavier than it measures, so the temptation there runs the other way.
The four component families are tuned so their ink clears 4.5:1 against their own tint, which is the pairing that actually ships:
| Pair | Light | Dark |
|---|---|---|
--info on --info-soft | 5.3:1 | 6.3:1 |
--positive on --positive-soft | 5.3:1 | 6.9:1 |
--caution on --caution-soft | 4.9:1 | 7.2:1 |
--critical on --critical-soft | 5.1:1 | 5.5:1 |
--caution has the least margin of the four. If you deepen a -soft tint,
deepen its ink in the same commit.
Two ratios in this system are deliberately low and are not failures. --card
over --background is about 1.1:1 and --border on --card about 1.4:1 —
neither is text, and both are doing the job a shadow and a hairline are supposed
to do at a glance rather than under measurement. Where a boundary is the only
thing identifying an interactive control, pair it with a fill or reach for
--border-strong, which clears 3:1.