Tab Switch
Tab Switch
Animated tab indicator that slides to the active tab using Motion One — two variants: an underline bar and a floating pill. Content panels fade and rise on switch.
Dependencies
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tab Switch — Motion Recipe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
<style>
.tab-indicator {
position: absolute;
bottom: 0;
height: 2px;
background: oklch(65% 0.2 270);
border-radius: 9999px;
transition: none; /* Motion owns this */
}
.tab-panel {
display: none;
}
.tab-panel.is-active {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.tab-indicator {
transition: left 0.15s, width 0.15s !important;
}
}
</style>
</head>
<body class="bg-slate-950 text-slate-200 font-sans">
<div class="mx-auto max-w-3xl px-8 pt-20 pb-32">
<span class="mb-6 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Tab Switch</span>
<h1 class="mb-4 text-4xl font-black leading-tight text-slate-100">Animated tab<br>transitions.</h1>
<p class="mb-12 max-w-sm text-base leading-relaxed text-slate-400">A sliding indicator follows the active tab. Content panels fade and rise as you switch — driven by Motion One's WAAPI.</p>
<!-- Tabs: variant 1 — underline indicator -->
<div class="mb-16">
<p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Underline indicator</p>
<div class="tab-group" data-variant="underline">
<!-- Tab list -->
<div role="tablist" class="relative flex gap-0 border-b border-slate-800" aria-label="Feature tabs">
<div class="tab-indicator" aria-hidden="true"></div>
<button role="tab" data-tab="overview" aria-selected="true" aria-controls="panel-overview"
class="tab-btn relative px-5 py-3 text-sm font-semibold text-slate-100 transition-colors hover:text-slate-100 focus-visible:outline-none">
Overview
</button>
<button role="tab" data-tab="features" aria-selected="false" aria-controls="panel-features"
class="tab-btn relative px-5 py-3 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-300 focus-visible:outline-none">
Features
</button>
<button role="tab" data-tab="pricing" aria-selected="false" aria-controls="panel-pricing"
class="tab-btn relative px-5 py-3 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-300 focus-visible:outline-none">
Pricing
</button>
<button role="tab" data-tab="faq" aria-selected="false" aria-controls="panel-faq"
class="tab-btn relative px-5 py-3 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-300 focus-visible:outline-none">
FAQ
</button>
</div>
<!-- Panels -->
<div id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" class="tab-panel is-active pt-8">
<h3 class="mb-3 text-xl font-bold text-slate-100">Everything you need</h3>
<p class="mb-4 max-w-lg text-sm leading-relaxed text-slate-400">Webspire is a copy-paste pattern registry for modern landing pages. HTML-first, Tailwind-only, AI-composable. No build step, no configuration.</p>
<div class="flex flex-wrap gap-2">
<span class="rounded-full bg-slate-800 px-3 py-1 text-xs font-medium text-slate-300">500+ patterns</span>
<span class="rounded-full bg-slate-800 px-3 py-1 text-xs font-medium text-slate-300">143 CSS snippets</span>
<span class="rounded-full bg-slate-800 px-3 py-1 text-xs font-medium text-slate-300">42 templates</span>
</div>
</div>
<div id="panel-features" role="tabpanel" aria-labelledby="tab-features" class="tab-panel pt-8">
<h3 class="mb-3 text-xl font-bold text-slate-100">What's included</h3>
<ul class="space-y-2 text-sm text-slate-400">
<li class="flex items-center gap-2"><span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>Hero, Navbar, Footer — all variants</li>
<li class="flex items-center gap-2"><span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>Pricing tables with annual toggle</li>
<li class="flex items-center gap-2"><span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>Feature grids, testimonials, CTA sections</li>
<li class="flex items-center gap-2"><span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>Motion recipes (Motion One + GSAP)</li>
</ul>
</div>
<div id="panel-pricing" role="tabpanel" aria-labelledby="tab-pricing" class="tab-panel pt-8">
<h3 class="mb-3 text-xl font-bold text-slate-100">Free to use</h3>
<p class="max-w-lg text-sm leading-relaxed text-slate-400">All patterns are free to copy, adapt, and use in commercial projects. No attribution required. Webspire is free to use — snippets and patterns are yours.</p>
</div>
<div id="panel-faq" role="tabpanel" aria-labelledby="tab-faq" class="tab-panel pt-8">
<h3 class="mb-3 text-xl font-bold text-slate-100">Common questions</h3>
<dl class="space-y-4 text-sm">
<div>
<dt class="font-semibold text-slate-300">Do I need a build step?</dt>
<dd class="mt-1 text-slate-500">No. All patterns are standalone HTML with Tailwind CDN. Copy, paste, done.</dd>
</div>
<div>
<dt class="font-semibold text-slate-300">Works with React / Vue / Svelte?</dt>
<dd class="mt-1 text-slate-500">Yes — the HTML structure is framework-agnostic. Convert to JSX or .vue as needed.</dd>
</div>
</dl>
</div>
</div>
</div>
<!-- Tabs: variant 2 — pill style -->
<div>
<p class="mb-4 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Pill indicator</p>
<div class="tab-group" data-variant="pill">
<div role="tablist" class="relative inline-flex gap-1 rounded-xl bg-slate-900 p-1" aria-label="Code tabs">
<div class="pill-indicator absolute rounded-lg bg-slate-700" aria-hidden="true" style="height:calc(100% - 8px); top:4px;"></div>
<button role="tab" data-tab="html" aria-selected="true" aria-controls="pill-panel-html"
class="pill-btn relative z-10 rounded-lg px-4 py-2 text-sm font-semibold text-slate-100 transition-colors focus-visible:outline-none">
HTML
</button>
<button role="tab" data-tab="astro" aria-selected="false" aria-controls="pill-panel-astro"
class="pill-btn relative z-10 rounded-lg px-4 py-2 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-300 focus-visible:outline-none">
Astro
</button>
<button role="tab" data-tab="vue" aria-selected="false" aria-controls="pill-panel-vue"
class="pill-btn relative z-10 rounded-lg px-4 py-2 text-sm font-semibold text-slate-500 transition-colors hover:text-slate-300 focus-visible:outline-none">
Vue
</button>
</div>
<div id="pill-panel-html" role="tabpanel" class="tab-panel is-active pt-6">
<pre class="overflow-x-auto rounded-xl bg-slate-900 p-5 text-xs leading-relaxed text-slate-300"><code><section class="ws-hero">
<h1 class="text-5xl font-black">Hello world</h1>
<a href="#" class="btn-primary">Get started</a>
</section></code></pre>
</div>
<div id="pill-panel-astro" role="tabpanel" class="tab-panel pt-6">
<pre class="overflow-x-auto rounded-xl bg-slate-900 p-5 text-xs leading-relaxed text-slate-300"><code>---
// Hero.astro
---
<section class="ws-hero">
<h1 class="text-5xl font-black">Hello world</h1>
<a href="#" class="btn-primary">Get started</a>
</section></code></pre>
</div>
<div id="pill-panel-vue" role="tabpanel" class="tab-panel pt-6">
<pre class="overflow-x-auto rounded-xl bg-slate-900 p-5 text-xs leading-relaxed text-slate-300"><code><template>
<section class="ws-hero">
<h1 class="text-5xl font-black">Hello world</h1>
<a href="#" class="btn-primary">Get started</a>
</section>
</template></code></pre>
</div>
</div>
</div>
</div>
<script>
var { animate } = Motion;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var SPRING = [0.16, 1, 0.3, 1];
/* ── Underline tab group ── */
(function() {
var group = document.querySelector('.tab-group[data-variant="underline"]');
var indicator = group.querySelector('.tab-indicator');
var buttons = group.querySelectorAll('.tab-btn');
var panels = group.querySelectorAll('.tab-panel');
function positionIndicator(btn, animate_) {
var targetLeft = btn.offsetLeft;
var targetWidth = btn.offsetWidth;
if (animate_ && !reduced) {
animate(indicator, { left: targetLeft + 'px', width: targetWidth + 'px' }, {
duration: 0.35,
easing: SPRING,
});
} else {
indicator.style.left = targetLeft + 'px';
indicator.style.width = targetWidth + 'px';
}
}
// Init
positionIndicator(buttons[0], false);
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
var targetId = btn.dataset.tab;
buttons.forEach(function(b) {
b.setAttribute('aria-selected', b === btn ? 'true' : 'false');
b.classList.toggle('text-slate-100', b === btn);
b.classList.toggle('text-slate-500', b !== btn);
});
positionIndicator(btn, true);
panels.forEach(function(panel) {
var isTarget = panel.id === 'panel-' + targetId;
if (isTarget) {
panel.classList.add('is-active');
if (!reduced) {
animate(panel, { opacity: [0, 1], y: [8, 0] }, { duration: 0.3, easing: [0.22, 1, 0.36, 1] });
}
} else {
panel.classList.remove('is-active');
}
});
});
});
}());
/* ── Pill tab group ── */
(function() {
var group = document.querySelector('.tab-group[data-variant="pill"]');
var indicator = group.querySelector('.pill-indicator');
var buttons = group.querySelectorAll('.pill-btn');
var panels = group.querySelectorAll('.tab-panel');
function positionPill(btn, animate_) {
var targetLeft = btn.offsetLeft;
var targetWidth = btn.offsetWidth;
if (animate_ && !reduced) {
animate(indicator, { left: targetLeft + 'px', width: targetWidth + 'px' }, {
duration: 0.3,
easing: SPRING,
});
} else {
indicator.style.left = targetLeft + 'px';
indicator.style.width = targetWidth + 'px';
}
}
positionPill(buttons[0], false);
buttons.forEach(function(btn) {
btn.addEventListener('click', function() {
var targetId = btn.dataset.tab;
buttons.forEach(function(b) {
b.setAttribute('aria-selected', b === btn ? 'true' : 'false');
b.classList.toggle('text-slate-100', b === btn);
b.classList.toggle('text-slate-500', b !== btn);
});
positionPill(btn, true);
panels.forEach(function(panel) {
var isTarget = panel.id === 'pill-panel-' + targetId;
if (isTarget) {
panel.classList.add('is-active');
if (!reduced) {
animate(panel, { opacity: [0, 1], y: [6, 0] }, { duration: 0.25, easing: [0.22, 1, 0.36, 1] });
}
} else {
panel.classList.remove('is-active');
}
});
});
});
}());
</script>
</body>
</html>
The indicator position is read from the DOM at click time — btn.offsetLeft and btn.offsetWidth — so it works without hardcoded widths. Motion animates left and width together using the spring easing [0.16, 1, 0.3, 1], which produces the elastic “slide with slight overshoot” feel of native iOS tabs.
On init, positionIndicator(buttons[0], false) sets the starting position without animation. Passing false as the second argument skips the animate() call and sets inline styles directly — no jank on first render.
The panel transition is intentionally minimal: opacity: [0, 1] and y: [8, 0] over 300ms. Panels disappear instantly (classList.remove('is-active')) — no exit animation needed because the sliding indicator already communicates the direction of change.
Both variants share the same logic, extracted into an IIFE per group. The only difference between underline and pill is how the indicator is sized vertically (2px bar vs. full-height rounded rect) and which CSS property sets the initial state.
Full ARIA compliance: each <button> has role="tab", aria-selected, and aria-controls. Each panel has role="tabpanel" and aria-labelledby. prefers-reduced-motion disables the spring animation and falls back to instant indicator repositioning via inline styles.