opacity vs visibility for Show and Hide Animations

Part of Hardware-Accelerated Properties in Core CSS Animation Fundamentals.

The problem

A dropdown fades out with opacity: 0 when it closes. It looks correct, and it is fast — opacity is one of the two properties the compositor can animate on its own. But the invisible menu still sits on top of the page. Clicks on the button beneath it do nothing. Keyboard users tab into links they cannot see. A screen reader announces the menu items as if the menu were open.

Swapping to display: none fixes the interaction but kills the fade, because the element disappears on the first frame. The show/hide problem is really four problems — paint, hit-testing, focus and accessibility — and opacity only solves the first.

Root cause analysis: what each property removes

opacity: 0 makes pixels transparent. The element is still laid out, still receives pointer events, still focusable and still exposed to assistive technology. It is the cheapest property to animate: a promoted element’s opacity is a blend factor the compositor applies to an existing texture, with no layout or paint.

visibility: hidden stops the element being painted, stops it being hit by the pointer, removes it from the sequential focus order and hides it from the accessibility tree. It keeps its layout space. Crucially, visibility is animatable in a special way: when transitioning between visible and hidden, the computed value stays visible for the whole transition if either end is visible. That means transition: visibility 200ms keeps the element visible until the end of a hide, and makes it visible from the start of a show.

display: none removes the box entirely, releasing its space. It is discrete and needs transition-behavior: allow-discrete plus @starting-style to animate, as covered in discrete and intrinsic-size animation.

inert is an attribute, not a style. It removes a subtree from focus, pointer interaction, find-in-page and the accessibility tree without changing its appearance, and is the right tool when a whole region — a closed drawer, content behind a modal — should become non-interactive while staying rendered.

What each technique removesOnly the combination of opacity and visibility gives a compositor fade with full removal at the end.What each technique removesPaintedClickableFocusableIn a11y treeKeeps spaceopacity: 0TransparentYesYesYesYesvisibility:hiddenNoNoNoNoYesdisplay: noneNoNoNoNoNoinertYesNoNoNoYes
Only the combination of opacity and visibility gives a compositor fade with full removal at the end.

Step-by-step resolution

A fade that also removes the elementOpacity does the visuals; visibility does the removal, timed by the transition.A fade that also removes the element1Animate opacity only for the visual fade.Compositor-only motion2Add visibility to the same transition list with the same duration.Hidden only after the fade completes3Confirm the element is visible immediately when showing.visibility interpolation favours visible4Add pointer-events: none during the hide if clicks mid-fade matter.No accidental clicks on a fading menu5Use inert on regions that must leave the tab order as a group.No focus into closed panels6Keep a short opacity fade under reduced motion.
Opacity does the visuals; visibility does the removal, timed by the transition.

Production code pattern

.menu {
  opacity: 1;
  visibility: visible;
  translate: 0 0;
  transition:
    opacity 180ms ease-out,
    translate 180ms ease-out,
    visibility 180ms;               /* stays visible for the whole hide, visible at once on show */
}

.menu[data-state="closed"] {
  opacity: 0;
  visibility: hidden;               /* not clickable, not focusable, not announced once hidden */
  translate: 0 -4px;
  pointer-events: none;             /* also ignore clicks during the fade itself */
}

@media (prefers-reduced-motion: reduce) {
  .menu { transition: opacity 120ms linear, visibility 120ms; }
  .menu[data-state="closed"] { translate: none; }
}
// A drawer region: make everything inside non-interactive while closed.
function setDrawer(open) {
  drawer.dataset.state = open ? 'open' : 'closed';
  drawer.inert = !open;              // focus, clicks and a11y tree follow the state instantly
  toggle.setAttribute('aria-expanded', String(open));
}

Rendering Impact: composite for opacity and translate; visibility costs one style recalculation and a paint invalidation when it flips at the end of the hide. The fade itself never touches the main thread per frame.

The single visibility 180ms entry with no easing is enough. Because visibility interpolates as “visible if either end is visible”, it holds visible until the last frame of a hide and switches to visible on the first frame of a show — exactly the asymmetry the effect needs, without separate delay values.

Hiding the menu: which property changes whenvisibility flips on the final frame, so the fade is fully seen before the menu stops responding.Hiding the menu: which property changes whenopacity1 to 00220 msvisibilityvisiblehidden220 mspointer-eventsnone220 ms1 to 00visiblehiddennone
visibility flips on the final frame, so the fade is fully seen before the menu stops responding.

Verification checklist

Constraints and trade-offs

  • visibility: hidden keeps layout space; for content that should collapse, use display: none with allow-discrete.
  • A child with visibility: visible inside a hidden parent is still shown; use inert or display for guaranteed removal.
  • inert does not hide content visually, so pair it with a visual hide.
  • opacity below 1 creates a stacking context, which can change z-order of descendants during the fade.
  • Frequent show/hide on many elements keeps layers promoted; release will-change when idle.

Frequently asked questions

Is opacity: 0 enough to hide an element?

Visually, yes. Functionally, no: the element is still clickable, focusable and exposed to screen readers. Pair it with visibility: hidden, inert or display: none.

Can visibility be transitioned?

Yes. It changes discretely, but the transition keeps it visible whenever either end is visible, so it stays visible through a fade-out and becomes visible at the start of a fade-in.

When should I use inert instead of visibility?

When a region must stay rendered but non-interactive, such as page content behind a modal, or when many descendants need removing from focus at once.