The visual baseline is the clean, neutral shadcn "neutral" theme — deliberately understated so you can re-skin it for your product by editing tokens, not components.
This document covers the tokens, typography, components, and the rules for keeping things consistent.
Three rules:
- No literal colors in components. If you write
text-amber-500, you've broken the system. Usetext-primary,text-status-success, etc. - All tokens are oklch. Perceptual uniformity means lightness is actually
lightness, and
color-mix(in oklab, …)produces predictable results. - Status tones are semantic, not decorative.
--status-successmeans "good / live", not "green". Map your domain statuses onto the three tones; don't hardcode hues.
Tokens are defined in packages/ui/src/styles/globals.css under :root and
.dark, and mapped to Tailwind utility classes via @theme inline in the same
file. Each app imports them through @repo/ui/styles.css.
| Token | Use |
|---|---|
--background / --foreground |
Page bg / body text |
--card / --card-foreground |
Elevated surfaces |
--popover / --popover-foreground |
Floating UI |
| Token | Use |
|---|---|
--primary |
Buttons, links, active states |
--primary-foreground |
Text on --primary surfaces |
--primary-soft |
Subtle backgrounds (chips, tags, hovers) |
--primary-strong |
Hover / pressed primary buttons |
| Token | Use |
|---|---|
--secondary / --secondary-foreground |
Light filler surfaces |
--muted / --muted-foreground |
Disabled / meta; captions, metadata |
--accent / --accent-foreground |
Hovered list rows |
--accent-blue |
Selection / focus accent |
--destructive |
Dangerous actions |
--border / --input / --ring |
Hairlines, inputs, focus rings |
Three functional tones, each with a base color and a *-soft background:
| Token | Tone | Maps to (example Item) |
|---|---|---|
--status-success / --status-success-soft |
success | active |
--status-warning / --status-warning-soft |
warning | draft |
--status-muted / --status-muted-soft |
muted | archived |
Tailwind classes: bg-status-success-soft, text-status-warning,
bg-status-muted-soft, etc. The mapping from a domain status to a tone lives in
one place per app:
apps/web/components/status-badge.tsx—StatusBadge(a shadcnBadge)apps/admin/components/status-pill.tsx—StatusPill(pill + colored dot)
When you replace Item, repoint the Record<YourStatus, Tone> map in those two
components and you're done — nothing else hardcodes a status color.
A separate --sidebar-* scale for the admin navigation, and --chart-1
through --chart-5 (the stock shadcn multi-hue palette, chosen to stay
distinguishable).
Two font families, loaded via next/font/google in each app's layout.tsx:
| CSS variable | Family | Use |
|---|---|---|
--font-sans |
IBM Plex Sans | All UI text |
--font-mono |
IBM Plex Mono | Code, IDs, kbd, numerals |
--font-heading is aliased to --font-sans. To add a display face, load it in
the layout, expose it as a CSS variable, and map it in @theme inline — don't
inline a font-family in a component.
The .display class (in globals.css) applies a heavier weight and tighter
tracking:
<h1 class="display text-4xl">Dashboard</h1>.font-mono, code, kbd, samp, and pre get tabular figures and slashed
zeros (font-variant-numeric: tabular-nums slashed-zero) so numbers and IDs
line up in columns.
<span class="status-dot status-dot--success"></span>
<span class="status-dot status-dot--warning"></span>
<span class="status-dot status-dot--muted"></span>A small colored circle for lists, meta rows, and pills (used by StatusPill).
Shared shadcn components live in packages/ui/src/components/ and are
imported with the package path:
import { Button } from "@repo/ui/components/button";
import { Badge } from "@repo/ui/components/badge";Each one is registered in the exports map of packages/ui/package.json
("./components/*": "./src/components/*.tsx"). App-specific components live
under each app's components/ directory.
Each app has a components.json. Generate from inside an app, or add to
packages/ui if both apps will use it:
cd apps/web
pnpm dlx shadcn@latest add dialogAfter generating, rewrite literal colors to tokens (bg-primary, not
bg-amber-500) before merging. If a component is used in both apps, move it to
packages/ui/src/components/ and add it to the exports map.
- PascalCase for components:
<StatusBadge>,<AdminShell> - kebab-case for files:
status-badge.tsx,admin-shell.tsx - Shared primitives in
packages/ui/src/components/* - App-specific components in the app's own
components/*
Default Tailwind spacing scale. The radius scale derives from a single
--radius (0.625rem) — --radius-sm/md/lg/xl/2xl/3xl/4xl are computed
multiples in @theme inline. Use the named utilities (rounded-md,
rounded-lg), not ad-hoc pixel values.
Both apps ship .dark tokens. next-themes is wired in the providers — toggle
the dark class on <html>:
import { ThemeProvider } from "next-themes";
export function Providers({ children }) {
return (
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
);
}The admin app ships a <ModeToggle> component.
Lucide React for all icons (SVG, tree-shaken, consistent). Don't mix in Heroicons or Material Icons.
// ❌ Wrong
<div className="bg-amber-500 text-white">...</div>
// ✅ Right
<div className="bg-primary text-primary-foreground">...</div>// ❌ Wrong
<div className="rounded-[7px]">...</div>
// ✅ Right
<div className="rounded-md">...</div>// ❌ Wrong
<span className={status === "active" ? "bg-green-500" : "bg-red-500"}>
// ✅ Right — let StatusBadge / StatusPill (and the tone map) decide
<StatusBadge status={status} />Add it to the layout + a @theme inline mapping; never inline font-family.