SaaS Starter
Frontend

Tokens

OKLCH variables, their Tailwind class, and the dual-theme system used by the dashboard.

All tokens live in apps/client/app/globals.css. The system supports two themes via class-based switching on <html>: .light and .dark. Every token name resolves to a different value per theme; the same Tailwind utility (bg-card, text-foreground, border-border, etc.) does the right thing in both contexts.

The base scale is reverse-engineered from vercel.com's Geist design system (research lives at docs/design-research/vercel/STYLE-GUIDE.md). Body text is 15px (set on html); shadcn primitives, text-sm (~13px), and text-base (15px) all derive from there.

Always reference the semantic class. Never hardcode an OKLCH literal in a component.

Surfaces (lifted from page bg)

TokenClassLightDark
--backgroundbg-backgroundoklch(0.985 0 0) (~#FAFAFA)oklch(0 0 0) (#000)
--foregroundtext-foregroundoklch(0.21 0 0) (~#171717)oklch(0.94 0 0) (~#EDEDED)
--cardbg-cardoklch(1 0 0) (#FFF)oklch(0.087 0 0) (~#0A0A0A)
--popoverbg-popoveroklch(1 0 0)oklch(0.087 0 0)
--mutedbg-mutedoklch(0.95 0 0)oklch(0.21 0 0)
--muted-foregroundtext-muted-foregroundoklch(0.45 0 0)oklch(0.62 0 0)

The page bg (--background) is the flatter color; cards, popovers, and other elevated surfaces use --card, which sits one lightness step toward the inverse. This avoids alpha-stacking issues when one card is over another.

CTA — paper-white on black, black on white

TokenClassLightDark
--primarybg-primaryoklch(0.21 0 0) (near-black)oklch(0.94 0 0) (paper-white)
--primary-foregroundtext-primary-foregroundoklch(0.98 0 0)oklch(0 0 0)
--accentbg-accentoklch(0 0 0 / 0.06) (transparent black)oklch(1 0 0 / 0.06) (transparent white)
--accent-foregroundtext-accent-foregroundoklch(0.21 0 0)oklch(0.94 0 0)

--primary is the inverse-of-bg fill — the canonical CTA. --accent is not a brand color; it's the hover-overlay (transparent) used by shadcn primitives to indicate active/hover state. For brand color use --brand below.

Brand & info — Vercel blue

TokenClassBoth themes
--brandbg-brand, text-brandoklch(0.591 0.227 256) (~hsla(212, 100%, 48%, 1))
--brand-foregroundtext-brand-foregroundoklch(0.98 0 0)
--infobg-info, text-infoalias of --brand
--ringring-ring--brand (focus rings use the brand color)

Use --brand for links, badges that mean "info", inline highlights, and focus rings. Do not use it as a button bg — buttons stay paper-white/black via --primary. This mirrors Vercel's split: blue conveys "informational accent", contrast conveys "primary action".

Status colors

TokenClassLightDark
--successbg-success, text-successoklch(0.55 0.13 152)oklch(0.62 0.135 152)
--warningbg-warning, text-warningoklch(0.74 0.16 70) (amber)same
--destructivebg-destructive, text-destructiveoklch(0.59 0.21 22) (red)same

Use the <StatusBadge> primitive (components/dashboard/status-badge.tsx) to render these consistently. Always combine color + icon/dot + text — never use color alone (Vercel's a11y rule, see STYLE-GUIDE §3.5).

Stage-coded brand colors

Reserved for environment indicators when we surface deploy stages. Not used by anything yet, but documented so we don't repaint them inconsistently.

TokenClassHexUse
--developtext-develop#0a72efDevelopment env
--previewtext-preview#de1d8dPreview deployments
--shiptext-ship#ff5b4fProduction / shipped

Borders — box-shadow, not 1px solid

The Vercel pattern: card borders are shadows, not border: 1px solid. This avoids box-sizing math (the border doesn't consume layout space) and stack-composes with drop shadows.

--border-hairline:        0 0 0 1px <hairline alpha>;
--border-hairline-inset:  inset 0 0 0 1px <subtler alpha>;
--border-hairline-large:  hairline + drop shadows for raised cards;

Three utilities are exposed in @layer utilities:

  • border-hairline — hairline outer border, the default
  • border-hairline-inset — subtle inner line (use for table headers, scroll containers)
  • border-hairline-lg — composite border + drop shadow for floating cards / popovers

Existing components using border-border (the 1px solid version) keep working. Migrate them gradually as they're rebuilt — don't rewrite untouched shadcn primitives.

Charts

--chart-1 through --chart-5 are monochrome (pure foreground descending to muted), with --chart-5 aliasing --brand for emphasis when one series should pop.

A small sub-scale (--sidebar, --sidebar-foreground, --sidebar-border, --sidebar-accent, …) shifts a couple of lightness steps so the dashboard's nav rail reads as a distinct surface without breaking the monochrome.

Custom utility classes

Defined in globals.css @layer utilities:

  • .label-mono — small uppercase mono label at 0.18em letter-spacing, foreground at 55% alpha. Wrap in <MonoLabel> (see primitives).
  • .border-hairline, .border-hairline-inset, .border-hairline-lg — box-shadow-as-border (see above).
  • .shadow-soft / .shadow-soft-lg — drop shadows tuned for the dark base.
  • .card-premium / .card-lift / .card-glow — landing-page-only effects (UseDeploy hero cards). Don't reach for these in the dashboard.

Radius scale

--radius:      0.5rem (8px effective at base 15px)
--radius-sm:   calc(var(--radius) - 2px)
--radius-md:   var(--radius)
--radius-lg:   calc(var(--radius) + 2px)
--radius-xl:   calc(var(--radius) + 6px)

Reach for rounded-md for most cards, rounded-sm for chips, rounded-lg for floating overlays.

Typography

Geist Sans + Geist Mono are loaded in app/layout.tsx. Use font-sans (default) or font-mono. Body has font-feature-settings: "cv11", "ss01" enabled for Geist's stylistic alternates — don't override globally.

Body font-size is 15px on <html> — between Vercel's 14 (denser) and the default 16 (more accessible). All text-* utilities derive from this base.

On this page