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
@import 'tailwindcss';
@plugin '@tailwindcss/typography';

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-background: hsl(0 0% 100%);
  --color-foreground: hsl(222.2 84% 4.9%);
  --color-primary: hsl(222.2 47.4% 11.2%);
  --color-primary-foreground: hsl(210 40% 98%);
  --color-border: hsl(214.3 31.8% 91.4%);

  --radius-lg: 0.5rem;
  --radius-md: calc(var(--radius-lg) - 2px);
  --radius-sm: calc(var(--radius-lg) - 4px);
}
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
@custom-variant dark (&:where(.dark, .dark *));

.dark {
  --color-background: hsl(59.31 3.23% 6.08%);
  --color-foreground: hsl(0 0% 95%);
  --color-primary: hsl(0 0% 98%);
  --color-border: hsl(240 5% 20%);
}
The .dark class is driven by next-themes through the ThemeProvider in src/components/theme-provider.tsx:
src/components/theme-provider.tsx
import { ThemeProvider as NextThemesProvider } from 'next-themes';

export const ThemeProvider = ({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) => {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};
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
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
import { cn } from '@/lib/utils';

<div className={cn('rounded-lg p-4', isActive && 'bg-primary text-primary-foreground')} />;

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:
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
Add more primitives with the shadcn CLI:
npx shadcn@latest add <component>
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:
import { Plus } from 'lucide-react';

<Button>
  <Plus className="h-4 w-4" /> Add item
</Button>;

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:
LocationScopeImport pattern
src/components/ui/shadcn/ui primitives@/components/ui/button
src/components/shared app components'components' barrel (src/components/index.ts)
src/routes/-<name>/route-private componentsrelative import within the route
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.