Skip to content
Join UI

Tailwind setup

Tailwind CSS v4 tokens and the dark variant.

Join UI targets Tailwind CSS v4. There is no tailwind.config.ts — the theme is declared in CSS, which is also what lets any subtree redeclare it.

Install

pnpm add tailwindcss @tailwindcss/postcss
postcss.config.mjs
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
}

export default config

Import Tailwind

A single import replaces the v3 @tailwind directives:

app/globals.css
@import "tailwindcss";

The dark variant

This is the one line that everything else depends on:

app/globals.css
@custom-variant dark (&:where(.dark, .dark *));

Two things to note. First, :where() keeps the variant's specificity at zero, so a dark: utility never accidentally outranks a plain one. Second — and more importantly — registry components do not use this variant at all. They theme purely through custom properties. The variant exists for the documentation chrome; the components stay portable. Dark mode explains why that distinction matters.

Map tokens onto utilities

@theme inline is what turns --primary into bg-primary, text-primary and border-primary:

app/globals.css
@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-popover: var(--popover);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-subtle: var(--subtle);
  --color-border: var(--border);
  --color-border-hover: var(--border-hover);
  --color-border-strong: var(--border-strong);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --color-primary: var(--primary);
  --color-primary-hover: var(--primary-hover);
  --color-primary-foreground: var(--primary-foreground);
  --color-primary-soft: var(--primary-soft);
  --color-secondary: var(--secondary);
  --color-accent: var(--accent);
  --color-accent-soft: var(--accent-soft);
  --color-accent-border: var(--accent-border);
  --color-destructive: var(--destructive);
  --color-success: var(--success);
  --color-warning: var(--warning);
  --color-code-bg: var(--code-bg);

  --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
  --font-mono: var(--font-jetbrains-mono), ui-monospace, monospace;
}

The two font variables come from next/font in app/layout.tsx — Inter for everything a person reads, JetBrains Mono for anything a machine would also have to read. Substitute your own families there and every utility follows; nothing in registry/components/ names a typeface directly.

The ramp is mapped the same way, which is what gives you bg-grey-100 and text-grey-800 for the rare case where a semantic name would be a lie:

app/globals.css
@theme inline {
  --color-grey-0: var(--grey-0);
  --color-grey-100: var(--grey-100);
  /* … through to --color-grey-1000 */
}

Radius

The scale is redeclared rather than pinned, so every step resolves to a real value and each one has a job:

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;

  /* The component scale, kept as aliases so installed components are stable. */
  --radius-soft-sm: var(--radius-sm);
  --radius-soft: var(--radius-lg);
  --radius-soft-lg: var(--radius-xl);
}

rounded-full is deliberately left alone: a pip or an avatar is a circle, not a rounded rectangle.

Elevation

The shadow scale is mapped through the same mechanism, so shadow-sm and shadow-md pick up the theme's own tinted shadow instead of Tailwind's black default:

app/globals.css
@theme inline {
  --shadow-xs: var(--elevation-xs);
  --shadow-sm: var(--elevation-sm);
  --shadow-md: var(--elevation-md);
  --shadow-lg: var(--elevation-lg);
}

Motion tokens

Durations and easings are tokens too, so timing stays consistent across components:

app/globals.css
:root {
  --duration-instant: 80ms;
  --duration-fast: 140ms;
  --duration-base: 200ms;
  --duration-slow: 320ms;

  --ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-in-out-soft: cubic-bezier(0.65, 0, 0.35, 1);
}

Components reference them through arbitrary values:

tsx
className="transition-colors duration-[var(--duration-fast)] ease-[var(--ease-out-soft)]"

The reduced-motion guard

One global rule catches every CSS transition and animation, including ones in components that forgot to handle it:

app/globals.css
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

This is a backstop, not a strategy. JavaScript-driven animation is not covered by it, so components that animate in JS check useReducedMotion() themselves — see accessibility.

Content detection

Tailwind v4 scans your source automatically; there is no content array to maintain. The one thing it cannot see is a class name you build at runtime:

tsx
// Tailwind cannot see this — the class gets purged.
<div className={`md:col-span-${span}`} />

// Look it up from a static map instead.
const COL_SPAN = { 1: "md:col-span-1", 2: "md:col-span-2" } as const
<div className={COL_SPAN[span]} />

Every Join UI component uses the second form. Any component that takes a numeric layout prop — a span, a column count, a size step — resolves it through a lookup table of complete class names for exactly this reason.