Skip to content

Cascade fragility: Tailwind utilities and CSS modules compete unlayered #26

Description

@arzafran

Problem

The starter imports Tailwind utilities unlayered:

/* styles/css/index.css */
@import "tailwindcss/utilities.css";

CSS modules are also unlayered. When a CSS module rule and a Tailwind utility set the same property on the same element, both have specificity (0,0,1,0) and the cascade winner is decided by source order. That order is not stable between dev and prod:

  • Dev: each module loads as a separate sheet, appended after Tailwind. CSS module wins.
  • Prod: Vite bundles everything into one stylesheet; Tailwind utilities can land last. Tailwind utility wins.

Result: a component that renders correctly in dev silently flips behavior in prod (or vice versa). Failures are typically positioning/sizing — text blocks shifting, elements rendering at wrong dimensions — which only surface in QA after a deploy.

Concrete instance in the starter

components/image.module.css declares .image { display: block; } and applies it directly to the rendered <img>. Any caller mixing a Tailwind display utility on the same element (e.g. <Image className=\"hidden dt:block\" />) ends up with two rules at specificity (0,0,1,0) competing on display, with the dev/prod source-order flip described above.

The same applies to the object-fit variants in that file, and to any future module that sets a property Tailwind also covers (positioning, sizing, padding, margin, gap, color, transform, etc.).

Workarounds available today

Two patterns can mitigate this without a full layer rollout:

  1. Drop the rule's specificity to zero with :where(). :where(.image) { display: block; } collapses the selector to (0,0,0), so any caller utility wins by default. Localized fix per offending module.
  2. Don't split one property between Tailwind and a CSS module on the same element. Convention only — pick one owner per property. If s.shell sets left, drop left-1/2/dt:left-auto from the className.

Both work, but they're per-component opt-ins. New code that doesn't follow them silently inherits the dev/prod flip.

Proposed fix: layer everything except CSS modules

CSS @layer is the right tool. Once Tailwind, the project's globals, and third-party CSS are all in layers, unlayered CSS modules win unconditionally — no cascade-order dependence, no :where() workaround, no per-component convention.

Target cascade order (low → high priority):

@layer reset, third-party, utilities, components;
/* unlayered: CSS modules — highest */

Implementation outline:

  1. styles/css/index.css — add an explicit @layer declaration so layer priority doesn't depend on first-appearance order, then move imports into layers:

    @layer reset, third-party, utilities, components;
    
    @import "tailwindcss/utilities.css" layer(utilities);
    @import "./reset.css" layer(reset);
    @import "./tailwind.css" layer(utilities);  /* custom @utility output */
    @import "./global.css" layer(components);
    /* root.css, fonts.css, media.css can stay unlayered — :root vars / @font-face / @custom-media don't enter the cascade */
  2. Audit third-party CSS sources. Many libraries ship CSS via JS side-effect imports, not @import, so they currently sit unlayered. Each needs to be re-imported explicitly via @import \"<path>\" layer(third-party); in index.css. Sources to enumerate per-project (varies with which libraries are installed):

    • @responsive-image/react (e.g. .ri-img, .ri-responsive { width: 100% })
    • Smooth-scroll / overlay-scroll libraries if used (e.g. Overlay Scrollbars)
    • Headless UI libraries that ship default styles (Base UI, Radix, etc. — varies)
    • Theatre.js if used

    The easiest way to enumerate: build the project, inspect the emitted CSS bundle, and grep for selectors that don't come from Tailwind, the project's own modules, or the styles/css files.

  3. Drop any :where() workarounds added solely for cascade reasons. The wrapper is no longer needed once layers carry the priority. :where() used for selector-list flattening (e.g. matching one of several roots without bumping specificity) stays.

  4. Visual QA pass on every page in projects spun from the starter, after the change. Rules that were silently winning or losing on source-order will now obey layer order, and that will surface latent visual differences. Plan the rollout for a calm sprint, not during active feature work.

Risks

  • Missed third-party source. If a library's CSS import is missed in step 2, it stays unlayered and outranks Tailwind — same direction of failure as today, but for one specific selector instead of the cascade as a whole. Mitigated by enumerating side-effect CSS imports systematically (inspect the prod build's CSS output).
  • Lightning CSS @layer handling. The starter's browserslist (iOS >= 16, Safari >= 16) supports @layer natively, so Lightning CSS shouldn't flatten it — verify with a prod build and inspect the emitted CSS to confirm layers survived.
  • Behavior change on previously-fragile call sites. Pages that happened to render correctly because Tailwind was winning without a layer will keep working (Tailwind still wins via the utilities layer over third-party). Pages that relied on a CSS module beating Tailwind via source order will need the CSS module to remain unlayered — which it will, since CSS modules are not touched by this change.

When to do this

Defer until either:

  • The convention has accumulated past ~3–4 :where() workarounds across the codebase, or
  • A planned QA window is available (mid-sprint, not feature-crunch).

Until then, the "don't split a property" rule + selective :where() is sufficient. Document the convention in the starter's CLAUDE.md / AGENTS.md so projects spun from the starter inherit the rule too.

Acceptance criteria

  • @layer reset, third-party, utilities, components; declared in styles/css/index.css.
  • All side-effect-imported third-party CSS re-imported into the third-party layer.
  • Any :where() workaround added solely to neutralize cascade order is removed.
  • Visual QA pass complete on a project that uses the starter, with no regressions.
  • Convention documented in the starter's agent guides.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions