Skip to main content
apps/web styles with Tailwind CSS v4 for utility-first classes and shadcn/ui for accessible, owned-in-your-repo components. No CSS modules, no styled-components — utilities and semantic theme tokens.

Tailwind v4

Tailwind is wired in through the Vite plugin (@tailwindcss/vite) and a single stylesheet, src/globals.css, that imports the framework and declares your theme:
src/globals.css
Theme tokens defined in @theme become utilities automatically — --color-primary gives you bg-primary / text-primary, --radius-md gives you rounded-md. Style with these semantic tokens, never hardcoded hex values, so light and dark modes stay consistent:
  • bg-background / text-foreground
  • bg-primary / text-primary-foreground
  • text-muted-foreground, border-border, bg-card
Use mobile-first breakpoints (sm:, md:, lg:) and state variants (hover:, focus-visible:, disabled:) as usual.

Dark mode

Dark mode is class-based. globals.css registers a dark variant and redefines the same tokens under .dark, so every component reacts to one class toggle:
src/globals.css
The .dark class is driven by next-themes through the ThemeProvider in src/components/theme-provider.tsx:
src/components/theme-provider.tsx
Because everything reads theme tokens, no component needs to know which theme is active.

Conditional classes with cn()

Compose and de-conflict classes with cn() from @/lib/utils — a clsx + tailwind-merge wrapper, so the last conflicting utility wins:
src/lib/utils.ts

shadcn/ui components

UI primitives live in src/components/ui/button.tsx, input.tsx, card.tsx, dialog.tsx, form.tsx, table.tsx, and more. They’re plain files in your repo (like everything in Ship), so you own and can edit them. Import with the @/ alias:
Add more primitives with the shadcn CLI:
Prefer wrapping shadcn primitives in your own components over editing them in place — it keeps the base components clean and your customisations in one obvious spot.

Icons

Use lucide-react for icons:

Component organisation

The web app is a TanStack Start SPA with file-based routes in src/routes/**. Components live in two places — shared ones under src/components, and route-private ones colocated with the route that uses them: TanStack Router ignores any folder or file prefixed with -, so route-private pieces never become routes. Colocate them next to the route — e.g. src/routes/-sign-up-components/ holds the components only the sign-up route needs.
Avoid useEffect for styling state and data sync — there’s a skill that enforces it. Reach for derived values, TanStack Query, and route loaders instead.