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/postcssconst config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default configImport Tailwind
A single import replaces the v3 @tailwind directives:
@import "tailwindcss";The dark variant
This is the one line that everything else depends on:
@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:
@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:
@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:
@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:
@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:
: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:
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:
@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:
// 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.