In this guide (9 items)
Tailwind CSS v4
Tailwind CSS
MANDATORY FOR ALL AI AGENTS: Never assume a utility resolves to Tailwind’s documented default. In any project with a custom
@theme, compile and read the emitted CSS before trusting a value (§4). Every visual value comes from a token; literals only as arbitrary values with a comment saying why. In a monorepo, every app that imports a shared theme MUST declare@sourcefor its own files (§3) or its classes are silently not generated.
Conventions: typescript-code-style.md. Mobile styling engines: mobile-ui.md.
1. The v4 mental model
Tailwind v4 has no tailwind.config.js. The theme is CSS custom properties inside @theme, and every utility is derived from a namespace:
| Namespace | Feeds |
|---|---|
--spacing (single base) | p-4, gap-x-2, size-5 → calc(var(--spacing) * n) |
--spacing-* (named keys) | p-md, gap-lg |
--container-* | max-w-md, max-w-3xl |
--text-* | text-sm, text-base |
--color-* | bg-blue-500, text-zinc-950 |
--radius-* | rounded-lg |
Two consequences people miss:
- Defining named keys adds to a namespace; it does not clear it.
p-4keeps working next top-md. - Utilities resolve through one namespace, and which one is not always the one the docs name. That is the source of §2.
2. ⚠️ Namespace shadowing — the trap that costs hours
Named keys in --spacing-* shadow --container-*. A design system that renames the spacing scale to t-shirt sizes silently breaks every width utility.
Measured, not inferred, on Tailwind 4.3.3:
@theme { --spacing-md: 16px; --spacing-3xl: 64px; } /* a "sensible" design system */max-w-md → max-width: var(--spacing-md) /* 16px, not 28rem */max-w-3xl → max-width: var(--spacing-3xl) /* 64px, not 48rem */A max-w-3xl container renders 64 pixels wide. Text wraps one word per line and the page looks like a CSS build failure, which is exactly the wrong place to go looking.
Adding --container-md / --container-3xl does NOT fix it — verified. Spacing still wins.
Rule: in any project whose theme defines named --spacing-* keys, max-w-<name> is unusable. Use arbitrary values and say why:
// `max-w-3xl` son 64px aquí: el design system renombra la escala de spacing// y tapa la de contenedores. Valor explícito a propósito.<div className="max-w-[46rem]">Everything else survives: numeric spacing, the type scale, radii, and the full default palette all resolve normally.
3. ⚠️ Source detection in a monorepo
v4 auto-detects which files to scan relative to the CSS file where @import "tailwindcss" lives — not relative to the app being built.
So when the theme lives in a shared package:
@import "design-system/src/theme.css"; /* the @import "tailwindcss" is in THERE */…Tailwind scans packages/design-system/, never apps/my-app/. The app’s own classes are not generated.
The failure is vicious because it is partial: any class the shared package or a sibling app also uses still works, so colors and padding apply while flex and grid do not. It reads as a layout bug, not a build one.
Every consumer app declares its own sources:
@import "design-system/src/theme.css";@source "../../src/**/*.tsx";Add this to the project’s “new app” checklist. It is not discoverable from the symptom.
4. Verify, don’t assume — the probe recipe
When a value looks wrong, do not reason about namespaces. Compile and read.
# 1. A throwaway component with the classes in questionecho 'export default () => <div className="max-w-3xl p-4 text-sm rounded-lg" />' > src/zzProbe.tsx
# 2. Build through the project's real pipelinenpx rsbuild build # or vite build / next build
# 3. Read what was actually emittedgrep -oE '\.max-w-3xl\{[^}]*\}' dist/static/css/*.cssFor a value already rendering in a browser, measure the computed style instead — walk up the tree and print width, display, maxWidth at each level. The narrowing ancestor is usually not the one you suspect.
Delete the probe afterwards.
5. Dark mode
Class-driven themes need a custom variant; dark: defaults to prefers-color-scheme and will ignore your toggle:
@custom-variant dark (&:where(.dark, .dark *));Never run both mechanisms. A store toggling .dark on <html> plus media-query dark: gives users a theme that flips on OS change and ignores the switch.
6. Adopting a third-party component kit
Kits built by the Tailwind team (Catalyst, the Plus components) and community kits (shadcn/ui) assume stock Tailwind defaults. Dropping them into a project with a custom theme is an adaptation, not a copy.
What transfers unchanged: structure, accessibility, state handling.
What must be translated: colors, and any max-w-<name> (§2).
Three conventions worth stealing whatever kit you use:
CSS variables as the theming seam. A well-built component does not hardcode its palette — it defines --btn-bg, --btn-icon, --btn-hover-overlay and the structural rules consume them. Retheming becomes redefining a handful of variables instead of rewriting selectors. Build your own components this way even if you adopt no kit.
State through data-*, not hover: / focus:. Headless UI v2 emits data-hover, data-focus, data-active, data-disabled. This is deliberate: CSS :hover fires on touch devices and sticks after a tap, and :focus cannot distinguish keyboard from pointer. Prefer the data attributes wherever a headless library provides them.
Slots over props for composition. data-slot="icon" plus child selectors (*:data-[slot=icon]:size-4) lets a parent style children it did not create, without threading props through every layer.
⚠️ Commercial kits (Tailwind Plus) are licensed. A licence covers your use of the source in your project. It does not license copying that source into documentation, skills, or any shared knowledge base. Extract conventions in your own words; leave the code where it is licensed to be.
7. Module Federation
- The theme must be imported by each app; CSS is not shared through the federation runtime.
- Do not register
tailwindcssas a shared singleton. It is build-time tooling, not runtime. - An exposed module’s CSS ships as its own chunk. If a remote’s styles are missing, check the chunk actually loads before suspecting the theme.
- A kit’s runtime dependencies (
@headlessui/react,clsx,motion) stay local to the app that uses them unless two apps genuinely share components.
8. ⚠️ opacity-* on a container caps the contrast of everything inside
CSS opacity creates a composition context. A child cannot recover what the
parent took away — opacity-100 on the child does nothing. Whatever colour you
chose is blended toward the background before it ever reaches the screen.
This is invisible until you audit. Secondary text at opacity-70 is usually
still fine (near-black on white stays around 9:1), so the pattern spreads. Then
one accent-coloured line lands inside that container and quietly drops below AA:
// BAD — the warning inherits the dimming and falls from 6.1:1 to 3.1:1<dl className="text-label opacity-70"> <dd>3 required variables</dd> <dd className="text-warning">Requires enable_s3</dd></dl>
// GOOD — dim each secondary line; the warning is not secondary<dl className="text-label"> <dd className="opacity-70">3 required variables</dd> <dd className="text-warning">Requires enable_s3</dd></dl>Diagnosing it. When axe reports a foreground colour that is not your token,
suspect this before you touch the token. Convert your token to hex and solve for
alpha per channel: if (255 - reported) / (255 - token) gives the same number
for R, G and B, that number is the inherited opacity. Chasing the palette
instead wastes a cycle — I darkened the token twice before measuring.
Reach for text-<color>/70 (colour-level alpha) instead of opacity-70 when
only the text should be dimmed. It composes per-element and leaves children out
of it.
9. Anti-patterns
| Anti-pattern | Why it hurts |
|---|---|
Trusting a documented default under a custom @theme | §2. Measure instead. |
| Renaming the spacing scale to t-shirt sizes | Collides with --container-* and breaks every max-w-<name>. If you must, document it loudly. |
A new app without @source | §3. Half its classes never exist. |
| Arbitrary values with no comment | The next reader assumes it was laziness and “fixes” it back to the broken token. |
| Pasting kit components without translating colors | Visually inconsistent with the rest of the product; the kit’s palette wins over your brand. |
hover: where the library offers data-hover | Sticky hover states on touch. |
| Two dark-mode mechanisms | The toggle and the OS fight each other. |
opacity-* on a container holding accent text | §8. Caps the contrast of every child; the child cannot opt out. |
A warning colour taken from the framework palette | If the design system has destructive but no warning, add the token. Hand-picking amber-700 is the same mistake as pasting a kit’s palette. |