Tab Panel Transitions with Types

Part of View Transition Types & Nested Groups in Modern View Transitions & Scroll APIs.

The problem

A settings page has five tabs. Design wants the panel content to slide left when moving to a tab on the right and slide right when moving to a tab on the left, like pages in a carousel. The first implementation keeps all panels in a horizontal strip and translates it, which forces every panel to render and makes heights awkward. The second uses a view transition, but the whole page — including the tab bar and header — slides, and the direction is always the same.

Directional panel changes need two things: a transition scoped to the panel region, and a way to tell CSS which direction applies.

Root cause analysis: scope by naming, direction by type

Only named regions animate independently. Without names, a view transition animates the whole page as root. Giving the panel container a view-transition-name, such as tabpanel, creates a separate group for it. Setting the root’s animations to none then leaves the tab bar and the rest of the page perfectly still while only the panel region animates.

Direction is known only at the time of the click. CSS cannot know which tab was previously selected. Script can: compare indices, then pass types: ['tab-next'] or types: ['tab-prev'] to document.startViewTransition(). The :active-view-transition-type() pseudo-class selects keyframes for each, as described in the parent topic.

The panel group needs clipping. Sliding snapshots move outside the panel’s box. By default, a group does not clip its images, so an old panel sliding left would slide over the tab bar or sidebar. overflow: clip on ::view-transition-image-pair(tabpanel) keeps the slide inside the panel area.

Heights can differ. Panels of different heights make the group resize from old to new height while the images slide. With object-fit and top alignment on the snapshots, content does not stretch as the height changes.

Repeated switching skips transitions. Clicking through tabs quickly starts a new transition while one is running, which skips the old one and jumps its snapshots to their end. The final state is always correct; only intermediate animations are dropped.

Tab switches and the type they produceDirection comes from comparing tab indices at click time.Tab switches and the type they produceTab 1Tab 2Tab 3tab-nexttab-nexttab-prevtab-prev
Direction comes from comparing tab indices at click time.

Step-by-step resolution

Directional tab panelsName the panel, type the transition, slide per type.Directional tab panels1Set view-transition-name: tabpanel on the panel container.Panel animates separately2Set root snapshot animations to none for tab types.Tab bar and page stay still3On click, compute tab-next or tab-prev from indices.Direction known4Start the transition with that type and update ARIA and hidden panels in the callback.Accessible state stays correct5Clip the image pair and slide old and new snapshots in opposite directions.Carousel-like motion within the panel6Under reduced motion, switch without a transition.
Name the panel, type the transition, slide per type.

Production code pattern

.tabpanels {
  view-transition-name: tabpanel;
}

/* Keep everything except the panel still during tab transitions. */
html:active-view-transition-type(tab-next, tab-prev)::view-transition-old(root),
html:active-view-transition-type(tab-next, tab-prev)::view-transition-new(root) {
  animation: none;
}

::view-transition-image-pair(tabpanel) {
  overflow: clip;                                  /* slides stay inside the panel area */
}
::view-transition-old(tabpanel),
::view-transition-new(tabpanel) {
  block-size: auto;
  object-fit: none;
  object-position: top;                            /* no stretching as heights differ */
  mix-blend-mode: normal;
  animation-duration: var(--duration-300);
  animation-timing-function: var(--ease-standard);
}

html:active-view-transition-type(tab-next)::view-transition-old(tabpanel) { animation-name: slide-out-left; }
html:active-view-transition-type(tab-next)::view-transition-new(tabpanel) { animation-name: slide-in-right; }
html:active-view-transition-type(tab-prev)::view-transition-old(tabpanel) { animation-name: slide-out-right; }
html:active-view-transition-type(tab-prev)::view-transition-new(tabpanel) { animation-name: slide-in-left; }

@keyframes slide-out-left  { to   { translate: -30% 0; opacity: 0; } }
@keyframes slide-in-right  { from { translate: 30% 0;  opacity: 0; } }
@keyframes slide-out-right { to   { translate: 30% 0;  opacity: 0; } }
@keyframes slide-in-left   { from { translate: -30% 0; opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(tabpanel), ::view-transition-new(tabpanel) { animation: none; }
}
const reduce = matchMedia('(prefers-reduced-motion: reduce)');

function selectTab(nextTab) {
  const current = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
  const next = tabs.indexOf(nextTab);
  if (next === current) return;

  const update = () => {
    tabs.forEach((t, i) => {
      const selected = i === next;
      t.setAttribute('aria-selected', String(selected));
      t.tabIndex = selected ? 0 : -1;
      panels[i].hidden = !selected;
    });
  };

  if (!document.startViewTransition || reduce.matches) return update();
  document.startViewTransition({ update, types: [next > current ? 'tab-next' : 'tab-prev'] });
}

Rendering Impact: composite for the panel slides and fades. Only the panel region is captured as its own group; the root snapshot does not animate, so the tab bar costs nothing beyond being part of the static root image.

Travelling only 30% of the panel width with a fade reads as directional without making users track a full-width slide, which is both calmer and less likely to trigger discomfort than full carousel motion.

Strip-translation carousel against typed view transitionsBoth produce directional motion; they differ in what must be rendered.Strip-translation carousel against typed view transitionsAll panels in a translated stripEvery panel rendered and laid outHeight follows the tallest panelLive content keeps runningHeavier, always renderedTyped view transition on one regionOnly the active panel renderedSnapshots slide per directionHeight animates between panelsLighter, scoped
Both produce directional motion; they differ in what must be rendered.

Keyboard switching

The tabs pattern moves selection with arrow keys, and holding an arrow key repeats selection rapidly. Each change starts a transition that skips the previous one, so the panel jumps between end states rather than animating every step. That is the right outcome: a keyboard user scanning tabs wants the destination, not five queued slides. Keep focus on the tab list during transitions; moving focus into the panel is the user’s choice via Tab. If automatic activation on arrow keys makes transitions feel noisy, use manual activation so panels switch only on Enter or Space.

Verification checklist

Constraints and trade-offs

  • Live content in the outgoing panel freezes during the transition.
  • Rapid switching skips intermediate transitions by design.
  • Types require newer view transition support; without it, the default cross-fade or no animation applies.
  • Clipping on the image pair hides any content that legitimately overflows the panel mid-transition.
  • Deep links that select a tab on load should not animate.

Frequently asked questions

How do I animate only part of the page with a view transition?

Give that region a view-transition-name and disable the root snapshot animations for the relevant transition type.

How can CSS know which direction the tabs moved?

It cannot on its own. Compare tab indices in script and pass a direction type to startViewTransition, then select keyframes with :active-view-transition-type().

Why do my sliding panels overlap the tab bar?

Groups do not clip their snapshots by default. Set overflow: clip on the panel’s ::view-transition-image-pair.

What happens when users switch tabs quickly?

Each new transition skips the running one, which jumps to its end state. The final panel is always correct.