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.

Hiding properties inside a fadevisibility interpolates specially; content-visibility needs allow-discrete.Hiding properties inside a fadeNeeds allow-discreteKeeps layout spacePreserves rendering statevisibility:hiddenNoYesYescontent-visibility:hiddenYesBox yes, contents skippedYes, cacheddisplay: noneYesNoNo
visibility interpolates specially; content-visibility needs allow-discrete.

Step-by-step resolution

Fading cached tab panelsPanels stack in one grid cell and cross-fade.Fading cached tab panels1Stack all panels in the same grid cell so they overlap.No layout jump between tabs2Hide inactive panels with content-visibility: hidden and opacity: 0.Contents skipped but cached3Transition opacity and content-visibility with allow-discrete.Outgoing panel stays rendered while fading4Set contain-intrinsic-size on panels if sizes vary.Stable container height5Move focus to the new panel or tab per the tabs pattern.Keyboard users follow the change6Under reduced motion, switch without fading.
Panels stack in one grid cell and cross-fade.

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-visibility flips 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.

Switching from panel A to panel BPanel A stays rendered while it fades; panel B renders from its cache immediately.Switching from panel A to panel BPanel A opacity1 to 00200 msPanel A content-visibilityvisiblehidden200 msPanel B content-visibilityvisible from frame 1200 msPanel B opacity0 to 11200 ms1 to 00visiblehiddenvisible from frame 10 to 1
Panel A stays rendered while it fades; panel B renders from its cache immediately.

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-visibility transitions with allow-discrete need recent browser support; older browsers switch instantly.
  • visibility: visible on a child overrides a hidden parent, which can leak content.
  • Find-in-page does not search content-visibility: hidden content.

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.