Build1 publisher3 min readPublished
One unlayered selector in globals.css overrode 514 Tailwind v4 border utilities
CogniPrep's hand-written globals.css turned all 514 border-colour utilities in its Tailwind v4 app grey because one * rule sat outside a cascade layer. Moving the rule into @layer base took two lines and made it a default that any utility class can override.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction
What happened
- An unlayered min-height rule on main beat every min-h-* class, leaving full-screen shells about 8 pixels taller than the viewport and a phone's primary button out of reach.
- A divider-hiding [class*='border-t'] rule marked !important also matched border-teal-500 and border-t-24, clipping teal rings and erasing a CSS triangle.
- A grep of the repository now finds about 800 border-colour utilities, up from the 514 the unlayered rule had defeated.
- Narrowing that selector to .border-t left bare border-t drawing nothing, and last week a component stacking two numbers rendered with no rule between them.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint An element default written at the top level of a stylesheet next to Tailwind v4 cannot be undone from markup by any utility class, however specific that class is.
- cost The damage from one unlayered rule grows with the codebase: the roughly 286 border classes added since the 514 count would also have rendered grey.
- decision Teams keeping a hand-written globals.css now have to sort each rule into an overridable default inside @layer base or a deliberate override meant to beat every utility.
Tailwind v4 emits its CSS into three cascade layers, in order: base, components, utilities [4]. The browser resolves layer order before it compares specificity [4]. Anything written outside a layer sits above all three [5]. CogniPrep's developer put the rule in one sentence on dev.to: "Unlayered CSS wins against every layered rule, no matter how specific the layered rule is." [5]
The first incident came from `* { border-color: var(--border); }`, written at the top level of globals.css [6]. A universal selector has specificity zero. It beat `border-transparent` and `hover:border-primary` anyway, because the utilities were inside a layer and it was not [6]. The comment the team later wrote above it says: "While this sat unlayered it silently defeated all 514 border-colour utilities in the app, so border-transparent, border-destructive, border-teal-500, hover:border-primary and the rest all rendered as this one grey." [7]
The visible break was a CSS triangle, drawn with transparent left and right borders and a coloured top border [9]. Grey on all sides turned it into a solid rectangle [9]. A triangle becoming a box is at least easy to spot. Three of the file's four incidents were invisible in the component that broke [3].
The fix wraps the existing rule in `@layer base { ... }` [10]. The `*` rule then becomes a default that any utility can override [10]. The `main` min-height rule got the same two lines [13]. Its new comment also explains the unit. On mobile, `100vh` is the viewport you only get once the browser chrome is hidden, so the box uses `100dvh` [13]. Two of the four incidents were fixed this way [2].
The third incident had nothing to do with layer order. It came from a substring attribute selector marked `!important` [14]. Attribute substring matching catches any class containing the string, and, as the developer wrote, "Tailwind class names contain a lot of strings" [18]. The repair was an exact `.border-t` selector, under a comment that opens "This MUST stay an exact class selector." [15]
I think the one substring selector the team kept is good engineering. `[class*='bg-card']` stays because Tailwind writes opacity modifiers into the class name, so `bg-card/90` has to match too, and no other utility in the app contains `bg-card` [16]. The cost is written into the comment: a border utility on a card is silently dead, so use a tinted surface instead [16]. A substring match is safe only while the string is unique across the app's class names. That uniqueness is the reason the team gives for keeping it [16].
The 514 figure comes from one codebase [7]. It transfers to another app only if that app has an unlayered rule on a property its utilities also set. CogniPrep's 719-line file still holds 17 `!important` declarations, and the developer says most of them are still needed [1][2]. In my view, element defaults in a stylesheet next to Tailwind v4 belong in `@layer base` from the first commit. An unlayered rule should be one its author meant to beat every utility, with a comment that says so.
What to watch
- Whether the 17 !important declarations still in CogniPrep's globals.css get the same exact-selector and layer treatment as the four incidents.
- A lint or build check that flags unlayered rules on properties Tailwind utilities also set, which would have caught the first two incidents before review.
- More bare border-t collisions as new components draw one-pixel rules against the narrowed .border-t selector.