View Transition Tabs
Switch tab panels smoothly with the View Transitions API and a no-animation fallback for unsupported browsers.
Interactive demo
Overview
This tab panel uses the View Transitions API to cross-fade content. In unsupported browsers it falls back to an instant switch.
Analytics
Metrics, charts and trends animate in together with the shared view transition, giving the interface a cohesive feel.
Settings
Preferences and configuration options appear with the same transition language, keeping the experience predictable.
What it does
Tab panels cross-fade and slide smoothly when switching tabs, powered by the View Transitions API.
When to use it
- Settings panels with distinct sections.
- Dashboard tabs where visual continuity reduces cognitive load.
- Content switches where the tab indicator should feel connected.
When to avoid it
- Tabs that must switch instantly, such as form steps.
- Very large panels where the transition could feel slow.
- When the content height changes drastically and layout shift matters.
How it works
- Assign view-transition-name to the tab panel container and active tab indicator.
- Start the transition — call
document.startViewTransition(callback)when the user selects a tab. - Update state inside the callback: switch the active tab class and panel content.
- Style the transition with
::view-transition-old(root)and::view-transition-new(root)pseudo-elements. - Fallback — if the API is missing, update the active state instantly.
- Reduced motion — keep the API call but disable the transition pseudo-elements with CSS.
Source code
<div class="tabs" data-tabs>
<div class="tablist" role="tablist" aria-label="Demo tabs">
<button type="button" class="tab is-active" role="tab" aria-selected="true" data-tab="overview">Overview</button>
<button type="button" class="tab" role="tab" aria-selected="false" data-tab="features">Features</button>
<button type="button" class="tab" role="tab" aria-selected="false" data-tab="settings">Settings</button>
</div>
<div class="panels">
<section class="panel is-active" role="tabpanel" data-panel="overview">
<h2>Overview content</h2>
<p>This panel cross-fades with the View Transitions API.</p>
</section>
<section class="panel" role="tabpanel" data-panel="features" hidden>
<h2>Features content</h2>
<p>Fallback browsers switch instantly.</p>
</section>
<section class="panel" role="tabpanel" data-panel="settings" hidden>
<h2>Settings content</h2>
<p>Reduced motion disables the transition.</p>
</section>
</div>
</div> :root {
color-scheme: dark;
--bg: #0a0a0b;
--surface: #16161a;
--border: rgba(255, 255, 255, 0.1);
--text: #f4f4f5;
--muted: #a1a1aa;
--accent: #8b5cf6;
}
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.tabs {
width: min(100% - 2rem, 480px);
}
.tablist {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9999px;
margin-bottom: 1.5rem;
}
.tab {
flex: 1;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--muted);
background: transparent;
border-radius: 9999px;
border: none;
cursor: pointer;
}
.tab.is-active {
color: var(--text);
background: rgba(139, 92, 246, 0.15);
}
.panels {
padding: 1.5rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
}
.panel {
view-transition-name: tab-panel;
}
.panel[hidden] {
display: none;
}
::view-transition-old(tab-panel),
::view-transition-new(tab-panel) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(tab-panel),
::view-transition-new(tab-panel) {
animation-duration: 0.01ms !important;
}
} function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
const tabs = document.querySelectorAll<HTMLButtonElement>("[data-tab]");
const panels = document.querySelectorAll<HTMLElement>("[data-panel]");
let activeId = "overview";
function setActive(id: string): void {
tabs.forEach((tab) => {
const isActive = tab.dataset.tab === id;
tab.classList.toggle("is-active", isActive);
tab.setAttribute("aria-selected", String(isActive));
});
panels.forEach((panel) => {
const isActive = panel.dataset.panel === id;
panel.classList.toggle("is-active", isActive);
panel.hidden = !isActive;
});
activeId = id;
}
const hasViewTransition = "startViewTransition" in document;
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const id = tab.dataset.tab;
if (!id || id === activeId) return;
if (!hasViewTransition || prefersReducedMotion()) {
setActive(id);
return;
}
const start = (document as Document & { startViewTransition?: (cb: () => void) => void }).startViewTransition;
if (start) {
start(() => setActive(id));
} else {
setActive(id);
}
});
});
export {}; Implementation recipe
View Transition Tabs Implementation Recipe
Goal
Switch tab panels with a smooth cross-fade using the View Transitions API, falling back to an instant switch on unsupported browsers.
Requirements
- A tablist with
role="tablist"and tabs withrole="tab". - Panels with
role="tabpanel". view-transition-nameon the panel container.- Fallback for browsers without
document.startViewTransition. - Reduced-motion support.
Files to create
index.htmlstyles.csstabs.ts
Step 1: Build the markup
Create a tablist and panels. Use aria-selected, aria-controls and hidden for accessibility and initial state.
Step 2: Style the tabs and panels
Style active tabs distinctly. Add view-transition-name: tab-panel to the panel sections.
Step 3: Customize the transition
Use the ::view-transition-old and ::view-transition-new pseudo-elements to control timing and easing.
::view-transition-old(tab-panel),
::view-transition-new(tab-panel) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
Step 4: Switch state inside the transition
When a tab is clicked, call document.startViewTransition and update the active tab and panel inside the callback.
document.startViewTransition(() => setActive(id));
Step 5: Provide a fallback
If startViewTransition is missing or reduced motion is enabled, update state directly without animation.
Step 6: Add keyboard navigation
Support Left and Right Arrow keys to move between tabs and activate the next/previous one.
Common pitfalls
- Calling
startViewTransitionwhile another transition is active can throw or behave unexpectedly. - Forgetting
hiddenon inactive panels makes all content visible at once. - Not suppressing the transition under
prefers-reduced-motioncan violate user preferences.
Verification checklist
- Tabs switch with a smooth cross-fade in supported browsers.
- Unsupported browsers switch instantly.
- Active tab has visible focus and
aria-selected="true". - Left/Right Arrow keys move between tabs.
- Reduced motion disables the cross-fade.
- Use a tablist role with aria-selected on each tab.
- Support Left/Right Arrow keys to move between tabs.
- Keep focus on the selected tab after activation.
- Provide aria-controls linking each tab to its panel.
- Respect reduced motion by suppressing the cross-fade.
- View Transitions capture the DOM as an image; keep panels reasonably simple.
- Avoid starting a new transition while another is in flight.
- Animate only opacity and transform in the transition pseudo-elements.
- Use view-transition-name sparingly to reduce capture overhead.
| API / Feature | Support | Fallback strategy |
|---|---|---|
document.startViewTransition | Chrome 111+, Edge 111+, Safari 18+ | Instant tab switch with no cross-fade. |
view-transition-name | Same as startViewTransition | Ignored by unsupported browsers. |
prefers-reduced-motion | All modern browsers | Disable transition pseudo-elements. |