Styling
The CSS Modules + design-token model that replaced Tailwind in apps/web and packages/ui
apps/web and packages/ui style with vanilla CSS in co-located CSS Modules — there is no Tailwind, no @apply, no utility compiler. This page is the reference for the model; apps/docs (this site) is unaffected and keeps its own Tailwind v4 setup.
Co-location
Every component with non-trivial styling gets a sibling stylesheet:
deal-card.tsx
deal-card.module.cssimport { cx } from '@marketplace/ui'
import s from './deal-card.module.css'
function DealCard({ isSelected, className }: DealCardProps) {
return <div className={cx(s.card, isSelected && s.cardSelected, className)} />
}- Import the module as
s; access classes ass.foo— class names are camelCase for plain dot access (variantDefault, notvariant-default). - A module may only be imported by its own sibling
.tsx— this is what makes co-location a real invariant instead of a convention nobody checks.scripts/lint-styles.shenforces it. - Conditional classes go through
cx()(from@marketplace/ui), never template-literal interpolation:
// not this
className={`${s.base} ${isActive ? s.active : ''}`}
// this
className={cx(s.base, isActive && s.active)}Design tokens
Defined once in apps/web/app/styles/tokens.css, ported verbatim from the old tailwind.config.ts scale:
| Token family | Examples | Notes |
|---|---|---|
| Spacing | --space-4, --space-2-5 | Full default scale, reified |
| Type | --text-sm / --text-sm-lh | Size + line-height pairs |
| Radius | --radius-sm/md/lg/xl/2xl/3xl/full | |
| Shadow | --shadow-sm/md/lg/xl, --shadow-hard* | |
| Border width | --border-1 (hairline), --border-2 | |
| Color | --c-primary, --c-destructive, --c-muted-foreground… | Resolved hsl(var(--x)) aliases over the raw HSL triplets |
| Z-index | --z-* |
Rule: var(--c-*) for opaque colors; hsl(var(--x) / a) only when you need alpha. Never a hex literal or bare rgb()/rgba()/hsla() on a color property — stylelint.config.mjs's intent is tokens-only, though the enforcement for this specific rule lives in scripts/lint-styles.sh as a ratchet (see Guardrails) because a handful of pre-Tailwind literal colors (badge variants, #fff icons) are grandfathered in rather than force-fixed as a side effect of the migration.
Breakpoints are custom media queries, not raw pixel values — custom properties don't work inside @media conditions, so postcss-custom-media resolves @media (--bp-lg) from the declarations in app/styles/media.css.
Cascade layers
Declared once, first thing, in packages/ui/src/styles/ui.css:
@layer reset, tokens, base, ui, globals, app;packages/ui/**/*.module.cssrules live in@layer ui.apps/web/**/*.module.cssrules live in@layer app.appalways beatsuiregardless of selector specificity or chunk load order — this is what lets a caller'sclassNamesafely override a primitive's own styling without!importantor a runtime class-merge utility. Nothing may be unlayered: an unlayered rule beats every layer, silently defeating this model.
Motion
Shared @keyframes live in global stylesheets (packages/ui/src/styles/ui.css, apps/web/app/styles/animations.css) — never inside a .module.css. CSS Modules hashes @keyframes names and rewrites any animation-name/animation reference to match, even when no local @keyframes exists in that module — a module referencing a global keyframe by name directly fails silently.
The fix is a --motion-* indirection:
/* animations.css (global) */
:root {
--motion-panel-drop: kwPanelDrop 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
:root {
--motion-panel-drop: kwPanelDrop 1ms linear both;
}
}
/* some-component.module.css */
.panel {
animation: var(--motion-panel-drop);
}var() contents are opaque to css-loader, so nothing gets rewritten — and reduced-motion becomes a token override instead of a second class or a global selector list.
Custom properties are global
Unlike classes, --custom-property names are not scoped by CSS Modules. A component-local custom property (e.g. a value passed from JS) must be prefixed --kw-<name> to avoid colliding with another component's property of the same short name:
<li className={s.row} style={{ '--kw-row-index': index } as CSSProperties}>.row {
animation-delay: calc(180ms + var(--kw-row-index, 0) * 45ms);
}This is the only legitimate use of inline style={{ }} — everything else is a module class. (app/global-error.tsx is the sole exception: it's Next's root error boundary, replacing <html>/<body> on a catastrophic client error, so it cannot depend on any stylesheet loading and is fully inline by necessity.)
The icon-utilities exception
A closed set of static, non-dynamic className strings — icon sizes and colors like className="h-4 w-4 text-primary" — are deliberately not converted to module classes, and instead exist as real, hand-authored CSS in apps/web/app/styles/utilities.css, reproducing the exact former-Tailwind class names verbatim:
@layer globals {
.h-4 {
height: var(--space-4);
}
.text-primary {
color: var(--c-primary);
}
}This is not Tailwind residue: there's no compiler, no config, no arbitrary-value escape hatch. Every class is declared once, by hand, mapped to a token, and the full set is closed and audited — scripts/lint-styles.sh's Tailwind-residue check fails if a new, unaccounted-for Tailwind-shaped class shows up anywhere else. If you need a size/color combination not already in that file, either add it there (if it's truly a static icon/spacing utility) or give the component a real module class (if it's anything with actual component-specific styling).
className on @marketplace/ui primitives
Every primitive keeps className?: string — cascade layers make it safe, and removing it would mean reinventing prop-based styling for every layout need. The contract:
classNamepositions a primitive in the caller's layout (margin, width, grid-area, order). It must not restyle the primitive's interior (colors, padding, typography, borders) — that needs a variant or a token.
Four multi-element primitives (DialogContent, Select, ScrollArea, Toast) accept a classes?: Partial<Record<Slot, string>> prop for targeting internal slots instead.
Guardrails
pnpm check:stylesRuns scripts/lint-styles.sh (7 checks) then stylelint:
- Tailwind-residue ratchet — any className token that looks like a Tailwind utility (
bg-,px-,rounded-,flex,hover:…) and isn't one of the accounted-for icon-utility tokens. Baseline0. - No inline
style={{ }}except--kw-*custom properties (andapp/global-error.tsx). - Orphaned modules — every
*.module.cssmust have a sibling.tsximporting it. - Cross-file module imports — a module may only be imported by its sibling.
- Dead classes — every class a module exports must appear as
s.<name>in its sibling. - Template-literal class conditionals — zero tolerance; use
cx(). - Raw
<input>/<textarea>in features (ratchet, baseline 25) + raw hex/rgb/hsla on color properties (ratchet, baseline 80 — pre-existing literal colors grandfathered in, may only decrease).
stylelint.config.mjs (repo root) enforces camelCase class selectors, --kw-*-prefixed custom properties, no-unknown-animations (catches the keyframe-rewrite footgun above), and that @layer/@custom-media/@container aren't flagged as unknown at-rules. It deliberately does not extend stylelint-config-standard — its formatting opinions (blank line before every rule, percentage alpha notation) conflict with this codebase's established style.
Both ratchet baselines in scripts/lint-styles.sh may only be lowered, never raised, as violations are cleaned up.