Saltar al contenido
Mis Guidelines
Guías
En esta guía (9 elementos)

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 @source for 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:

NamespaceFeeds
--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-4 keeps working next to p-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:

apps/my-app/src/styles/tailwind.css
@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.

Terminal window
# 1. A throwaway component with the classes in question
echo 'export default () => <div className="max-w-3xl p-4 text-sm rounded-lg" />' > src/zzProbe.tsx
# 2. Build through the project's real pipeline
npx rsbuild build # or vite build / next build
# 3. Read what was actually emitted
grep -oE '\.max-w-3xl\{[^}]*\}' dist/static/css/*.css

For 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 tailwindcss as 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-patternWhy it hurts
Trusting a documented default under a custom @theme§2. Measure instead.
Renaming the spacing scale to t-shirt sizesCollides 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 commentThe next reader assumes it was laziness and “fixes” it back to the broken token.
Pasting kit components without translating colorsVisually inconsistent with the rest of the product; the kit’s palette wins over your brand.
hover: where the library offers data-hoverSticky hover states on touch.
Two dark-mode mechanismsThe 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 paletteIf 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.