Transitioning visibility and content-visibility
Part of Discrete & Intrinsic-Size Animation in Modern View Transitions & Scroll APIs.
The problem
A tabbed interface keeps every panel in the DOM so switching tabs is instant and panel state — scroll position, form input — is preserved. Hidden panels use display: none, which discards their rendering and makes the first switch slow for heavy panels. Switching to content-visibility: hidden preserves rendering state for fast restores, but the panels now snap in and out with no fade. Elsewhere, a menu fades out with opacity and visibility: hidden, and the fade works in one direction but the menu appears instantly when opening.
visibility and content-visibility are both discrete, and they behave differently inside transitions.
Root cause analysis: two discrete properties, two rules
visibility has built-in transition behaviour. It has always been animatable in a special way: during a transition between visible and hidden, the interpolated value is visible for the whole duration if either end is visible. So a transition from visible to hidden stays visible until the last frame, and a transition from hidden to visible becomes visible on the first frame. Paired with an opacity transition of the same duration, it produces a fade that is fully visible while fading and fully removed when done — no allow-discrete needed.
The “appears instantly” bug comes from putting different durations or a delay on visibility for the show direction, or from transitioning visibility without a matching opacity transition so there is nothing visual to see.
content-visibility is discrete without special rules. content-visibility: hidden skips rendering of the element’s contents while preserving their rendering state, so showing them again is fast. By default it changes instantly. With transition-behavior: allow-discrete, it follows the same visibility-preserving switch point as display: when changing to hidden it flips at the end, and when changing from hidden it flips at the start. That allows a fade to run while the contents are still rendered.
Accessibility differs. visibility: hidden removes content from the accessibility tree and focus order. content-visibility: hidden also hides content from assistive technology and prevents focus, but the element box itself remains, so the panel container still takes space unless sized otherwise. content-visibility: auto, by contrast, keeps off-screen content accessible and findable — the right value for long lists, not for hidden tabs, as covered in content-visibility for long animated lists.
Step-by-step resolution
Production code pattern
.tabpanels {
display: grid;
}
.tabpanel {
grid-area: 1 / 1; /* panels overlap */
opacity: 1;
content-visibility: visible;
transition:
opacity var(--duration-200) linear,
content-visibility var(--duration-200) allow-discrete;
}
.tabpanel[aria-hidden="true"] {
opacity: 0;
content-visibility: hidden; /* rendering skipped, state cached */
pointer-events: none;
}
/* Menu with visibility: no allow-discrete needed. */
.menu {
opacity: 0;
visibility: hidden;
transition: opacity var(--motion-exit-duration) linear, visibility var(--motion-exit-duration);
}
.menu[data-open] {
opacity: 1;
visibility: visible;
transition: opacity var(--motion-enter-duration) linear, visibility var(--motion-enter-duration);
}
@media (prefers-reduced-motion: reduce) {
.tabpanel, .menu, .menu[data-open] { transition: none; }
}
function selectTab(tab) {
for (const t of tabs) {
const selected = t === tab;
t.setAttribute('aria-selected', String(selected));
t.tabIndex = selected ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls'))
.setAttribute('aria-hidden', String(!selected));
}
}
Rendering Impact: composite for the cross-fade; the incoming panel’s cached rendering is restored without a full layout and paint of its contents.
content-visibilityflips once at the start or end of the transition.
Using aria-hidden as the styling hook keeps the visual state and the accessibility state from drifting apart. The hidden panel is also skipped by assistive technology because its contents are not rendered, but the explicit attribute makes the intent visible and testable.
Measuring the cache benefit
The point of content-visibility: hidden over display: none is restore speed for heavy panels. Measure it: record a Performance trace while switching to a large panel for the first time and a second time. With display: none both switches show a large Layout and Paint for the panel. With content-visibility: hidden, the first reveal after load is similar, but later reveals show much less work because rendering state was kept. For small panels the difference is negligible and visibility or display are simpler.
Verification checklist
Constraints and trade-offs
- Overlapping panels in one grid cell make the container as tall as the tallest panel.
- Cached rendering uses memory for every hidden panel.
content-visibilitytransitions withallow-discreteneed recent browser support; older browsers switch instantly.visibility: visibleon a child overrides a hidden parent, which can leak content.- Find-in-page does not search
content-visibility: hiddencontent.
Frequently asked questions
Does visibility need transition-behavior: allow-discrete?
No. visibility has special interpolation that keeps it visible for the whole transition whenever either end is visible, so it works with ordinary transitions.
Why use content-visibility: hidden instead of display: none?
It preserves the contents’ rendering state, so showing a heavy hidden panel again is faster, while still skipping rendering while hidden.
Is content hidden with content-visibility: hidden accessible?
No. Its contents are not rendered and are not exposed to assistive technology or keyboard focus, which is correct for inactive tab panels.
Why does my menu fade out but appear instantly?
Usually the open state’s transition has a delay or different duration on visibility, or opacity is not transitioned in that direction. Give both the same timing on the open rule.
Related
- Discrete & Intrinsic-Size Animation — the parent topic
- opacity vs visibility for Show and Hide Animations — what each hiding method removes
- content-visibility: hidden for Cached Views — the performance side of cached panels