Skip to content

ADR-0011 Styling

Company standard styling — Tailwind CSS v4, with shadcn/ui components on Base UI primitives.

Decision
Tailwind CSS v4 + shadcn/ui on Base UIBuild the app
Status
Accepted
Date
Scope
Company-wide default for all new apps

React (ADR-0001) leaves styling open. We need one way to write styles and one starting set of accessible components, so products share design tokens, components, and review habits instead of reinventing buttons and dialogs per team.

Requirements:

  • Works with streaming SSR in TanStack Start (ADR-0002) — no runtime style injection on the render path
  • Fits the toolchain (ADR-0035) — Vite plugin, formatter support in Oxfmt, no Prettier side-tool
  • Design tokens (color, spacing, radius, type) defined once and themeable (dark mode, per-product brand)
  • Accessible primitives — focus management, keyboard support, ARIA for dialogs, menus, popovers, selects — not hand-rolled per product

Utility-first CSS. v4 moved configuration into CSS (@theme) and ships a Vite plugin.

Pros

  • Largest ecosystem; the default target for component kits (shadcn/ui), docs, and AI coding tools
  • Zero runtime: compiles to one static stylesheet — ideal for streaming SSR
  • @tailwindcss/vite supports Vite 8; tokens live in CSS as variables, so theming is plain CSS
  • Oxfmt sorts Tailwind classes natively (sortTailwindcss) — no Prettier plugin needed
  • Styles live with the markup; no naming, no dead CSS files

Cons

  • Maintainer risk: Tailwind Labs cut 75% of its engineering team in January 2026 (AI-driven revenue drop); new sponsors (Vercel, Google AI Studio) followed, and v4 still ships. MIT-licensed, and its output is plain CSS — a stall would freeze us, not strand us
  • Long class strings; needs cn() and variant helpers to stay readable

Scoped .module.css files, built into Vite.

Pros

  • Zero dependencies, plain CSS, nothing to learn

Cons

  • No shared token or utility system — every product re-invents spacing scales and conventions
  • No component kit ecosystem built on it

Type-safe styles in TypeScript, extracted at build time.

Pros

  • Type-checked tokens and variants; zero runtime
  • Both actively maintained

Cons

  • Smaller ecosystems; shadcn/ui and most kits target Tailwind
  • An extra build-time extraction step and its own DSL to learn

4. Runtime CSS-in-JS (styled-components, Emotion)

Section titled “4. Runtime CSS-in-JS (styled-components, Emotion)”

Pros: familiar to many React developers. Cons: injects styles during render — a cost under streaming SSR; Emotion has not shipped a release since 2024-12; the ecosystem has moved to zero-runtime approaches.

Meta’s atomic CSS-in-JS. Cons: still pre-1.0 (0.19), small ecosystem outside Meta.

  • shadcn/ui — components are copied into our repo by its CLI and styled with Tailwind; we own the code. It now builds on either Radix or Base UI primitives
  • Base UI (@base-ui/react, 1.x stable) — unstyled accessible primitives from the team behind Radix, MUI, and Floating UI; composes with a render prop
  • Radix — the original shadcn/ui primitives, still maintained; more existing community blocks; composes with asChild
  • React Aria Components (Adobe) — the strongest accessibility and i18n coverage, but not a shadcn/ui option
  • Packaged libraries (MUI, Mantine, Chakra) — fast start, but we style around their design system instead of owning ours

Tailwind CSS v4 for styling, and shadcn/ui on Base UI as the starting component layer, are the company standard for all new apps.

Concretely:

  • Tailwind via @tailwindcss/vite, configured in one stylesheet (src/styles/app.css): @import "tailwindcss" plus @theme tokens. No tailwind.config.js
  • Tokens are CSS variables (shadcn/ui’s --background, --foreground, --primary, … mapped through @theme inline). Dark mode and per-product branding swap variables, not classes
  • shadcn/ui with a Base UI style (base-* in components.json). Components live in src/components/ui/ and are ours to edit. Primitives come from @base-ui/react — never the deprecated @base-ui-components/react
  • Class helpers follow shadcn/ui’s defaults: cn() (clsx + tailwind-merge) for merging, class-variance-authority (cva) for variants. Components accept and forward className
  • Class sorting is on in Oxfmt via the root .oxfmtrc.json (ADR-0026): sortTailwindcss with stylesheet: "./apps/web/src/styles/app.css" and functions: ["cn", "cva", "clsx"], so bun run check enforces order
  • No runtime CSS-in-JS and no second styling system in the same app. Plain CSS files are fine for what utilities can’t express (third-party widget overrides, complex keyframes)

The deciding factors: zero runtime on the SSR path, the largest ecosystem, native support in our toolchain (Vite plugin, Oxfmt sorting), and a component layer we own instead of rent. Base UI over Radix for its active stable releases and cleaner render composition. We accept Tailwind Labs’ maintainer risk, mitigated by the MIT license and plain-CSS output.

Easier:

  • One token set and one component starting point across products — design changes are variable edits
  • Accessible dialogs, menus, popovers, and selects come from Base UI instead of per-team effort
  • Components are plain code in the repo: change anything without fighting a library’s API
  • Formatting and class order are enforced by bun run check, so reviews skip style nits

Harder:

  • shadcn/ui components are copied, not installed: upstream fixes arrive only when we pull them with the CLI and review the diff
  • Sharing components across products needs a plan — a company shadcn/ui registry is the natural next step once two products duplicate the same component
  • Most shadcn/ui community blocks and examples assume Radix (asChild); Base UI versions use render and need adapting
  • Watch Tailwind’s release health; if v4 stalls, the exit is freezing the version, not a rewrite

Given up: the type-checked styles of Panda CSS / vanilla-extract, and the maximum accessibility depth of React Aria. Accepted — ecosystem reach and zero-runtime simplicity matter more for a company default; a product with heavy i18n or accessibility needs can write an exception ADR for React Aria.