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.
Step-by-step resolution
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.
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.
Related
- View Transition Types & Nested Groups — the parent topic
- Choosing Types for Route Direction — the same idea for page navigation
- Transitioning visibility and content-visibility — a CSS-only cross-fade alternative for tabs