Skip to main content
ASoc
Tutorial

Tailwind `display: none`: 6 Uses, and the Keyboard Bug That Needed `invisible` Instead

A real tab-order bug this codebase's own comments document, and why `hidden` couldn't have fixed it — `display: none` can't be part of a CSS transition.

The ASoc Team8 min read

Tailwind's hidden utility sets display: none — gone from layout, gone from the accessibility tree, gone from the tab order, instantly. This codebase uses it 6 times across 4 files. A second utility, invisible (visibility: hidden), shows up 4 times instead, and every one of those four exists because hidden can't do the one thing they need: fade or slide smoothly, without popping.

The two ways to hide something

hidden (display: none)invisible (visibility: hidden)opacity-0
Layout spaceCollapses — the element takes up nothingPreserved — the box is still therePreserved
Screen readersSkippedSkippedStill announced
Tab orderRemovedRemoved (browser default; see below)Still focusable
Can transition smoothlyNo — an abrupt swap by defaultYes, alongside opacityYes

The row that decides most real usage is the last one. display is a discrete property: without the newer transition-behavior: allow-discrete (which this codebase's Tailwind v4 setup doesn't opt into), an element is either fully there or fully gone — no fade, no slide, no in-between frame. visibility gets special-cased by browsers when paired with a transition-duration: switching to hidden waits until the transition ends, switching to visible happens immediately. Pair it with opacity and you get an honest fade, with the element correctly out of the accessibility tree the moment it's actually gone.

The census

$ node scripts/audit/count-display-utilities.mjs
utility        uses  files
flex            141   58
inline-flex      53   29
block            27   15
grid             23   19
hidden            6    4
inline-block      3    3
inline            3    2
contents          1    1
TOTAL          257

hidden is 6 of 257 display utilities. invisible isn't in that table at all — it's a visibility utility, not a display one, so the audit script (built for the tailwind-inline-block post) doesn't see it. A separate grep does:

$ grep -rn '\binvisible\b' src/components --include=*.tsx
src/components/organisms/Header.tsx:118        (mobile nav drawer)
src/components/molecules/TemplateCard.tsx:97   (hover-preview overlay)
src/components/molecules/UseCaseCard.tsx:91    (hover-preview overlay)
src/components/molecules/ProductDownloadGroup.tsx:60  (hover-download overlay)

Four uses, four files — almost the same shape as hidden's 6-in-4, and the split between the two utilities is exactly the "can it transition" question above.

The bug hidden would have reintroduced

The clearest case is the mobile nav drawer in Header.tsx. Below the xl breakpoint, the drawer sits off-screen via translate-x-full until it's opened. An early version toggled it with xl:hidden-style logic; the comment left in the code names the exact defect that caused:

// src/components/organisms/Header.tsx
// `pointer-events-none` stops the mouse but not the Tab key, so the
// closed drawer's links stayed focusable below xl: a keyboard user on
// a phone tabbed through nav links parked off-screen. `invisible`
// takes them out of the tab order; it is in the transition list so
// visibility flips only after the slide-out finishes, and `xl:visible`
// keeps the desktop bar — the same element — interactive.
className={`fixed inset-y-0 right-0 z-9999 flex w-[85%] max-w-xs flex-col
  justify-between overflow-y-auto bg-white px-6 py-8 shadow-2xl
  transition-[transform,visibility] duration-300 ease-in-out
  xl:pointer-events-auto xl:visible xl:static … ${
    navOpen
      ? "visible translate-x-0"
      : "pointer-events-none invisible translate-x-full"
  }`}

pointer-events-none is enough to stop a mouse from clicking links that are visually off-screen — but a keyboard user doesn't click, they Tab, and pointer-events has no effect on focus order. Without invisible, every link in the closed drawer stayed reachable by Tab, in a drawer the sighted mouse user couldn't see was even there. hidden would fix that instantly, but it can't also be in a transition-* list — the slide-out animation (translate-x-full, 300ms) needs the element rendered and visible for its full duration, only losing focusability and paint after it finishes sliding away. visibility is the one property that can do both: stay in the transition list, and still end up correctly excluded from the tab order.

The other three: hover-preview overlays

TemplateCard, UseCaseCard, and ProductDownloadGroup all use invisible for the same pattern — a blurred overlay that reveals a Preview or Download control on hover:

// src/components/molecules/TemplateCard.tsx
<div className="invisible absolute inset-0 z-10 flex items-center justify-center
  rounded-xl bg-[rgba(152,162,179,0.32)] opacity-0 backdrop-blur-[15px]
  duration-200 group-hover/media:visible group-hover/media:opacity-100">
  <button aria-hidden="true" tabIndex={-1}>
    {/* … */}
  </button>
</div>

Same reasoning as the drawer: duration-200 needs the overlay rendered while it fades in and out, so hidden is off the table. But notice this code doesn't lean on invisible alone to keep the button out of the tab order — the button also carries an explicit tabIndex={-1} and aria-hidden="true". That's deliberate belt-and-suspenders: visibility: hidden reliably removes an element from the tab order in every current browser, but the codebase doesn't make the a11y guarantee depend on one CSS property doing double duty. It's the same rule the project states in CLAUDE.md: a hover overlay is a pointer convenience only, never the single way in — a touch screen has no hover, and this component ships an always-visible Preview button below the card for exactly that reason. invisible handles the desktop-mouse case; the explicit tabIndex/aria-hidden and the separate always-visible button handle everyone else.

Where plain hidden is exactly right

The other three files in the census don't animate anything, so display: none costs nothing:

// src/components/organisms/Trust.tsx — a divider only shown at md and up
<span className="mx-12 h-full w-px bg-gray-200 max-md:hidden"></span>

// src/components/molecules/PreviewModal.tsx — label text that appears at lg
<span className="hidden lg:inline">{d}</span>

// src/components/molecules/DownloadMenu.tsx — the dropdown panel
<div className={`absolute top-full right-0 z-30 pt-2 ${open ? "" : "hidden"}`}>

The DownloadMenu comment is explicit about why hidden — not unmounting the panel entirely — is still the right call even without an animation: "the panel keeps its place in the tab order calculation and the links stay measurable for tests." Keeping the element in the DOM (just not rendered) avoids a conditional-mount/unmount cycle that would otherwise shift sibling layout and make the menu harder to assert against in tests. There's no fade here, so none of the invisible reasoning applies — hidden is strictly simpler, and simpler wins when nothing needs to animate through the transition.

Troubleshooting

SymptomCauseFix
An element pops instead of fading outhidden/display: none can't participate in a CSS transition by defaultSwitch to invisible + opacity-0, both listed in transition-*
A closed drawer/menu's links are still reachable by Tabpointer-events-none blocks clicks, not keyboard focusAdd invisible (or hidden if nothing needs to animate) so focus is actually removed, not just clicks
invisible element still gets announced by a screen readerUncommon, but don't rely on CSS alone for critical a11y stateAdd aria-hidden="true" and tabIndex={-1} on the interactive child explicitly
Toggling hidden on a flex/grid child breaks the layout when shown againhidden always resolves to display: none, not the previous display valueUse the Tailwind display utility for the shown state explicitly, e.g. hidden md:flex rather than hidden plus a bare md:block sibling
Fade-in transition doesn't fire on first renderThe element started at its "hidden" state and the transition class was applied in the same paintToggle the visible state on the next frame/tick (e.g. after mount), not synchronously

Frequently asked questions

Does display: none remove an element from the accessibility tree? Yes — display: none (Tailwind's hidden) is invisible to assistive technology and unreachable by keyboard, same as visibility: hidden. The difference between the two is layout (display: none collapses the box entirely; visibility: hidden keeps its space) and transitionability (only visibility can be part of a smooth CSS transition).

Is opacity-0 a substitute for hidden or invisible? No, and using it alone is a real accessibility bug: an opacity-0 element is still in the tab order and still announced by screen readers, just invisible to sighted mouse users. It only belongs paired with invisible (for the fade) or on an element that's meant to stay interactable while visually transparent (a skip link, for instance).

Why not use hidden md:block everywhere instead of invisible? Because hidden can't be part of a transition. If the shown/hidden state needs to fade or slide, display can't do it without transition-behavior: allow-discrete (not part of this project's setup) — visibility combined with opacity is the pattern that actually animates.

Does removing pointer-events-none fix a hidden-but-clickable element? No — pointer-events-none only stops mouse/touch interaction. It does nothing for keyboard focus. An element needs invisible or hidden (or an explicit tabIndex={-1}) to actually leave the tab order; pointer-events-none alone was exactly the gap that let the drawer bug in this codebase happen.

Should every hover-reveal overlay ship a non-hover fallback? In this codebase, yes, without exception — every invisible/group-hover:visible overlay audited above sits behind an always-visible button somewhere else in the same component (below the card, or in a footer row). A hover-only affordance excludes touch devices entirely and keyboard users unless it's also paired with group-focus-within:, so treating the hover state as a convenience layered on top of a reachable default — rather than the only way in — is what keeps invisible's tab-order removal from also removing the feature for anyone who can't hover.

Templates in this post

ASoc Guard (a cybersecurity landing page), ASoc Haven (a real-estate marketing site) and ASoc Hearth (a smart-home product site) all ship the same Header mobile drawer and hover-preview cards audited above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the rest of this codebase's display-utility census, see Tailwind inline-block; for the other side of a hover interaction, Tailwind group-hover.

Keep reading

Tutorial10 min read

Tailwind Font Weight: 9 Named Steps, 4 This Codebase Uses

193 font-weight utility calls across this codebase, spanning only 4 of Tailwind's 9 named steps — traced to two atoms that set the hierarchy once.

Read more
Tutorial9 min read

Tailwind Grid: 26 Files, and Not One col-span

A 538-page site's whole grid vocabulary is three utilities — plus the display:contents trick that reorders a product page on mobile without duplicating state.

Read more