Skip to content
Join UI

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.

app/globals.css
: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.

app/globals.css
: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.

app/globals.css
: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:

tsx
<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

TokenPurpose
--grey-0--grey-1000The ramp. Every neutral token aliases onto it
--background / --foregroundPage surface and body text
--card / --card-foregroundPanel surfaces, one step above the page
--popover / --popover-foregroundFloating surfaces — menus, dialogs, toasts
--muted / --muted-foregroundRecessed fill and secondary text
--subtleThe quietest fill; asides, table headers
--borderThe resting hairline
--border-hoverThe same hairline one step down, for hover
--border-strongEmphasis rule, at or above 3:1
--inputForm control boundary
--ringFocus outline
--primary / --primary-foregroundThe filled ink surface; the primary action
--primary-hoverIts hover step
--primary-softFlat tint for a selected or held state
--accent / --accent-foregroundThe same ink, used as text or as a marker
--accent-soft / --accent-borderTint and hairline for a held surface
--secondary / --secondary-foregroundQuiet fill with normal ink on top
--destructive / --success / --warningChrome status; aliases onto the hue families
--info / --info-soft / --info-foregroundComponent hue: in progress, informational
--positive / --positive-soft / --positive-foregroundComponent hue: success, complete
--caution / --caution-soft / --caution-foregroundComponent hue: warning
--critical / --critical-soft / --critical-foregroundComponent hue: error, blocked, destructive
--code-bgCode block fill
--selectionText selection wash
--radiusBase radius for the chrome, 0.625rem
--radius-soft-sm / --radius-soft / --radius-soft-lgThe component radius scale — a tag, a control, a card
--elevation-xs--elevation-lgShadow steps, reached as shadow-xsshadow-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:

tsx
<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:

app/globals.css
@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:

css
: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:

tsx
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:

app/globals.css
: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:

app/globals.css
: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:

tsx
<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:

tsx
<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:

tsx
<YourComponent className="border-border-strong" innerClassName="bg-subtle p-8" />

Contrast

The headline pairs, measured against the resolved values above:

PairLightDark
--foreground on --background15.5:117.0:1
--foreground on --card17.7:115.4:1
--muted-foreground on --background5.9:18.8:1
--muted-foreground on --card6.8:18.0:1
--muted-foreground on --muted5.5:16.3:1
--primary-foreground on --primary17.7:117.0:1
--border-strong on --background3.5:13.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:

PairLightDark
--info on --info-soft5.3:16.3:1
--positive on --positive-soft5.3:16.9:1
--caution on --caution-soft4.9:17.2:1
--critical on --critical-soft5.1:15.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.