visually-hidden loses to a Tailwind utility on source order
•7 min read
A screen-reader-only caption kept its padding after being hidden, and the invisible node measured 32 by 16 pixels instead of 1 by 1. The cause was not the hiding technique. It was two selectors of equal weight and the order they were imported in.
A visually-hidden element kept its padding because the class that hides it declares padding: 0 without !important, and a Tailwind padding utility on the same element has identical specificity. When two selectors weigh the same, the later one in source order wins, and the utilities stylesheet is imported after the one holding the hiding class. The result was a node nobody could see that still occupied 32 by 16 pixels of layout instead of the 1 by 1 the technique promises.
32 by 16 pixelsWhat a caption measured after being given the hiding class alongside px-4 pt-4, instead of the 1 by 1 the class is written to produce.
Why does a visually-hidden class lose to a utility class?
A visually-hidden class loses to a utility class because neither one outweighs the other. The hiding class is a single class selector, which the cascade weighs as (0,1,0). A Tailwind padding utility is also a single class selector, weighed identically. Specificity therefore cannot decide between them, and the cascade falls through to its next tiebreaker, which is document order: whichever declaration the browser read last is applied. In a project where the utilities are imported after the stylesheet defining the hiding class, that is always the utility.
The declarations involved are not exotic. A conventional visually-hidden class sets position: absolute, a width and height of 1px, overflow: hidden, a clip-path, a negative margin, and padding: 0. Every one of those is doing real work. The padding reset is the one that failed here, because padding is a property a layout utility commonly sets on the same element, and it decides whether the 1px box stays 1px or grows by whatever padding was applied around it.
Four ways the same conflict can resolve, and why the last one is a design rather than a patch.
Declaration
Selector weight
Position in the cascade
Winner
padding: 0 on .visually-hidden
(0,1,0)
Imported first
Loses the tie
padding on a Tailwind utility
(0,1,0)
Imported second
Wins the tie
padding: 0 !important
(0,1,0) plus importance
Either
Wins, and breaks every legitimate override
No padding utility on the element
Not applicable
Not applicable
The reset applies as intended
Why are the utilities imported after everything else?
The import order that causes this collision was chosen deliberately, and reversing it would break something worse. Unlayered CSS beats layered CSS outright, whatever the specificity. A project that already has a large unlayered stylesheet and dozens of unlayered CSS Modules cannot put Tailwind's utilities into a cascade layer, because an ordinary element rule in the unlayered sheet would then defeat a utility and Tailwind would look installed while doing nothing. The utilities are therefore imported unlayered too, and after the existing stylesheet, so ties on equal specificity fall to the utility.
The accessibility floor is written by redefining tokens inside media queries. That only keeps working if every Tailwind utility emits a var() reference rather than a compiled copy.
Market4 turns one release note into a changelog page, a blog post, a mail-out and a week of social posts — and then tells you which of them brought anyone back.
No card to start. Cancel from the settings screen, not from an email.
That decision is correct for the general case and is what makes an incremental migration possible at all. A route can adopt utilities without every legacy rule fighting them. The cost is that any legacy class and any utility on the same element are decided by source order rather than by intent, which is a coin flip from the author's point of view. The project's own guidance is that a screen gets migrated by deleting its module class, not by layering a utility on top of one.
55Unlayered CSS Module files in the panel, which is why Tailwind's utilities cannot be put in a cascade layer without losing to them.
What does the failure actually look like on the page?
The failure is invisible in the ordinary sense and very visible in the layout. A caption given the hiding class and horizontal and top padding utilities produced a box of 32 by 16 pixels: clipped so no text renders, positioned absolutely so it does not push siblings, and still large enough to be a hole in a layout nobody can see in order to explain it. Nothing throws, nothing logs, and a visual regression test that only compares rendered text passes cleanly.
This is the family of defect that a build diary exists to record, because the symptom and the cause sit in different files. The symptom is a gap in a card. The cause is an import statement in a layout file and the absence of an exclamation mark in a stylesheet written years earlier. Similar distance between symptom and cause showed up when a variable shadowed a function and produced a blank footer, where the rendered output gave no hint about which line was responsible.
How should the conflict be fixed?
The fix that shipped withholds the utilities rather than overriding them. The element takes either the hiding class or the padding utilities, never both, chosen by the same condition that decides whether the caption is visible. When the caption is hidden, no padding utility is emitted, so there is nothing for the reset to lose to and the reset is not needed. When the caption is visible, the padding applies normally and the hiding class is absent.
Make the two class sets mutually exclusive on the element, chosen by the condition that already exists.
Do not add !important to the hiding class. It wins this fight and then wins every fight, including the ones where a caller legitimately needs to override a property.
Do not raise the specificity of the hiding class either. That fixes today's collision and hides tomorrow's, because the next utility to collide will do so silently again.
Assert the geometry in a test rather than the class name. A test that checks for the class passes while the box is 32 by 16.
Treat any element carrying both a legacy class and a utility as undecided until you have checked which one the browser applied.
The fourth item is the one that generalises beyond CSS. A test asserting that an element has a particular class is asserting an intention, and the defect here is precisely that the intention was not honoured. A test asserting a bounding box of 1 by 1, or asserting that the computed padding is zero, would have caught this at the moment the utilities were added. That distinction between checking what was declared and checking what happened is the lesson worth taking away, and it applies equally to two lists that were supposed to stay in step and quietly drifted.
Should I just add !important to my visually-hidden class?
It works and it costs more than it saves. Marking the padding reset important means the hiding class wins every collision, including the ones where a caller has a legitimate reason to set a property on that element. It also converts a visible layout bug into an invisible override that only shows up when somebody tries to change something and cannot. Making the two class sets mutually exclusive solves the same problem without taking that power away.
Why not put Tailwind's utilities in a cascade layer?
Because unlayered CSS beats layered CSS regardless of specificity. In a codebase with a large unlayered stylesheet and dozens of unlayered CSS Modules, putting the utilities in a layer means an ordinary element rule in the old sheet defeats them, and Tailwind appears installed while changing nothing. Layering only works when everything is layered. Migrating an existing project means importing the utilities unlayered and accepting that ties are decided by import order.
How do I detect this class of bug automatically?
Assert geometry rather than class names. A screen-reader-only element should have a bounding box of about 1 by 1 pixel and computed padding of zero, and both are readable from a browser-driven test. Checking that the element carries the hiding class only proves the intention was expressed, which is exactly the thing that was not in doubt. One geometry assertion covers every future utility that might collide, without needing to know which one it will be.
Does this affect any hiding technique or just the clip-based one?
It affects any technique that relies on setting properties a utility might also set. The clip-and-1px approach is vulnerable through padding, margin, width and height. A technique using position and offscreen coordinates is vulnerable through position and inset. The vulnerability is not in the technique but in the fact that both declarations are single class selectors, which means specificity cannot separate them and order decides.
Why is Tailwind's preflight not imported here?
Because the existing stylesheet and its CSS Modules were written against browser defaults that preflight removes. Importing it would restyle headings, lists, buttons and form controls across every screen at once, which is a much larger change than adopting utilities on one route. The existing stylesheet acts as the project's preflight instead, and the individual Tailwind layers are imported without it. That choice is what leaves the reset and the utility on equal footing.
The link measured 297 by 44 pixels and reported a pass. Tapping its centre hit the paragraph behind it. An inline box that wraps is not one rectangle, and the pseudo-element meant to enlarge it was positioned against the same broken geometry.
A table header declared sticky sat still while the rows scrolled past it, with no error anywhere. Two separate mistakes: a wrapper that could not overflow, and the sticky rule on the wrong element. Here is what each one does and how to tell them apart.