Home Recipes View Transition Tabs View Transitions

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.

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

  1. Assign view-transition-name to the tab panel container and active tab indicator.
  2. Start the transition — call document.startViewTransition(callback) when the user selects a tab.
  3. Update state inside the callback: switch the active tab class and panel content.
  4. Style the transition with ::view-transition-old(root) and ::view-transition-new(root) pseudo-elements.
  5. Fallback — if the API is missing, update the active state instantly.
  6. Reduced motion — keep the API call but disable the transition pseudo-elements with CSS.

Source code

HTMLCSSTypeScript
<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 with role="tab".
  • Panels with role="tabpanel".
  • view-transition-name on the panel container.
  • Fallback for browsers without document.startViewTransition.
  • Reduced-motion support.

Files to create

  • index.html
  • styles.css
  • tabs.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 startViewTransition while another transition is active can throw or behave unexpectedly.
  • Forgetting hidden on inactive panels makes all content visible at once.
  • Not suppressing the transition under prefers-reduced-motion can 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.
Accessibility notes
  • 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.
Performance notes
  • 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.
Browser support & fallbacks
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.