display:none vs visibility:hidden vs opacity:0 — what's the difference?
display:none removes from the layout AND a11y tree (no space, not focusable). visibility:hidden reserves space but hides + removes from a11y. opacity:0 reserves space and IS still interactive + announced — usually a bug if you wanted 'hidden'.
Three CSS properties hide elements with completely different semantics. Picking the wrong one breaks layout, accessibility, or both.
display: none
- Element is removed from the render tree.
- Takes no space in layout.
- Not focusable (Tab skips it), not announced by screen readers.
- Children with
display: blockare also removed regardless. - Style/layout changes inside cost nothing.
- Toggling triggers layout when shown again.
Use for: components that should be entirely gone (closed tabs, conditionally rendered sections, mobile-only nav at desktop sizes via media query).
visibility: hidden
- Element is invisible but takes up its layout space.
- Not focusable, not announced by screen readers.
- Children can opt back in with
visibility: visible. - Cheaper to toggle than
displaybecause layout is already known.
Use for: hiding an element while preserving layout (a tooltip slot, a placeholder that flips visible without layout shift).
opacity: 0
- Element is fully transparent.
- Takes layout space.
- Still focusable, still announced by screen readers, still receives clicks (unless
pointer-events: none). - Composited — animates cheaply on the GPU (just opacity, no layout/paint).
Use for: animations only. Rarely the right "hide" — it's a transparency, not a hide. If a button has opacity: 0, sighted users see nothing but tab + click still trigger it. That's a bug.
The accessibility table.
| Method | Layout space | Focusable | SR announces | Click target |
|---|---|---|---|---|
| display:none | no | no | no | no |
| visibility:hidden | yes | no | no | no |
| opacity:0 | yes | yes | yes | yes |
| aria-hidden=true | yes | yes (problem!) | no | yes |
| inert (attribute) | yes | no | no | no |
aria-hidden="true" hides from the a11y tree but does NOT remove focusability — a focusable child with aria-hidden is a known bad combo (focusable but unannounced). Pair with tabindex="-1" or use the modern inert attribute (Safari 15.5+, Chrome 102+) which handles both.
Visually hidden but available to screen readers. Common pattern for icon-only buttons that need an accessible name:
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}Tailwind ships this as .sr-only. Don't use display: none for this — screen readers won't announce it.
Performance differences.
display: none→ re-show triggers layout from scratch.visibility: hidden→ layout already done; show is cheaper.opacity: 0→ composited; animates without layout/paint.
For an animated reveal: opacity: 0 + transform is the right move. Combine with pointer-events: none while transparent so it's not clickable mid-fade.
Hidden attribute. <div hidden> is shorthand for display: none. Equivalent semantics, less specificity.
Toggle pattern (animated).
<div
aria-hidden={!open}
inert={!open as any}
style={{ opacity: open ? 1 : 0, pointerEvents: open ? "auto" : "none", transition: "opacity 200ms" }}
>
{children}
</div>opacity for the transition; inert for a11y + focus blocking.
Code
Follow-up questions
- •Why is opacity:0 usually wrong as a 'hide'?
- •What's the inert attribute for?
- •How is `hidden` different from `display:none`?
- •Why do screen readers ignore visibility:hidden but not aria-hidden focusable elements?
Common mistakes
- •Hiding clickable elements with opacity:0 — still keyboard-accessible bug.
- •Using display:none for icon-only button labels — screen readers can't announce.
- •Setting aria-hidden on a focusable element without tabindex=-1.
- •Toggling display:none every keystroke — repeated layout cost.
Performance considerations
- •visibility:hidden cheaper to toggle than display:none.
- •opacity-only animations stay on the compositor (60fps).
- •Show/hide a giant subtree? Prefer display:none + lazy mount when possible.
Edge cases
- •display:none on a media element pauses playback in some browsers; visibility:hidden does not.
- •An <option> with display:none is hidden in <select> across modern browsers (recently improved).
- •Inert blocks pointer events too — don't use on something users still need to interact with.
Real-world examples
- •Modals: inert + aria-hidden on the backdrop's siblings to trap focus.
- •Tabs: switch tab content via display:none vs hidden vs visibility — many libraries use mount/unmount instead.