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.
Step-by-step resolution
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
opacityandtranslate;visibilitycosts 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.
Verification checklist
Constraints and trade-offs
visibility: hiddenkeeps layout space; for content that should collapse, usedisplay: nonewithallow-discrete.- A child with
visibility: visibleinside a hidden parent is still shown; useinertordisplayfor guaranteed removal. inertdoes not hide content visually, so pair it with a visual hide.opacitybelow 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-changewhen 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.
Related
- Hardware-Accelerated Properties — the parent topic
- Transitioning visibility and content-visibility — the discrete side of hiding
- Animating Popover and Dialog Entry and Exit — top-layer elements that handle this natively