Overview
- Harbour, 6am
- Market stalls
- Fog on the pier
import { useEffect, useId, useRef, useState } from 'react';
const VIEWS = [
{ label: 'Photos', items: ['Harbour, 6am', 'Market stalls', 'Fog on the pier'] },
{ label: 'Notes', items: ['Poster sizes', 'Ink order', 'Call the printer'] },
{ label: 'Files', items: ['brief.pdf', 'moodboard.fig', 'invoice-1042.pdf'] },
];
const W = 240;
const H = 120;
const GAP = 24;
const ZOOM = 0.36;
const cx = (i: number) => i * (W + GAP) + W / 2;
const view = (s: number, c: number) => `translate(${W / 2}px, ${H / 2}px) scale(${s}) translate(${-c}px, ${-H / 2}px)`;
const css = `
.tabs-overview { width: ${W}px; }
.tabs-overview__list { display: flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: light-dark(#ebedf0, #232529); }
.tabs-overview__tab { flex: 1; height: 26px; border: 0; border-radius: 7px; background: none; color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-overview__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-overview__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-overview__view { position: relative; height: ${H}px; border-radius: 14px; overflow: hidden; background: light-dark(#fbfcfd, #121417); }
.tabs-overview__track { position: absolute; left: 0; top: 0; display: flex; gap: ${GAP}px; transform-origin: 0 0; }
.tabs-overview__panel { box-sizing: border-box; width: ${W}px; height: ${H}px; padding: 14px; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-overview__panel b { display: block; margin-bottom: 8px; font-size: 13px; color: light-dark(#373a40, #f1f2f3); }
.tabs-overview__panel ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; font-size: 12px; color: light-dark(#50535b, #c6c8cd); }
.tabs-overview__panel li { display: flex; align-items: center; gap: 8px; }
.tabs-overview__panel li::before { content: ''; width: 14px; height: 14px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }
`;
export function OverviewTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const track = useRef<HTMLDivElement>(null);
// Switching pulls back to show every panel side by side, slides along to the new one and zooms in
// on it. It starts from wherever the view is, so a quick second pick carries on smoothly.
useEffect(() => {
const el = track.current!;
const m = new DOMMatrix(getComputedStyle(el).transform);
const s0 = m.a || 1;
const c0 = (W / 2 - m.e) / s0;
el.getAnimations().forEach((a) => a.cancel());
const c1 = cx(sel);
el.style.transform = view(1, c1);
if (Math.abs(c0 - c1) < 1 || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
el.animate(
[
{ transform: view(s0, c0), easing: 'ease-in-out' },
{ transform: view(ZOOM, c0), offset: 0.3, easing: 'ease-in-out' },
{ transform: view(ZOOM, c1), offset: 0.68, easing: 'ease-in-out' },
{ transform: view(1, c1) },
],
{ duration: 950 },
);
}, [sel]);
const n = VIEWS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-overview">
<div className="tabs-overview__list" role="tablist" aria-label="Library" onKeyDown={onKeyDown}>
{VIEWS.map((v, i) => (
<button
key={v.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-overview__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
{v.label}
</button>
))}
</div>
<div className="tabs-overview__view">
<div className="tabs-overview__track" ref={track} style={{ transform: view(1, cx(0)) }}>
{/* Every panel stays laid out so the overview can show them all; only the chosen one is exposed. */}
{VIEWS.map((v, i) => (
<div
key={v.label}
className="tabs-overview__panel"
role="tabpanel"
id={`${id}-panel-${i}`}
aria-labelledby={`${id}-tab-${i}`}
aria-hidden={sel !== i}
inert={sel !== i}
>
<b>{v.label}</b>
<ul>
{v.items.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</div>
))}
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsOverview(el) on the mounted element instead.
<div class="tabs-overview">
<div class="tabs-overview__list" role="tablist" aria-label="Library">
<button class="tabs-overview__tab" type="button" role="tab" id="tabs-overview-tab-0" aria-selected="true" aria-controls="tabs-overview-panel-0" tabindex="0">Photos</button>
<button class="tabs-overview__tab" type="button" role="tab" id="tabs-overview-tab-1" aria-selected="false" aria-controls="tabs-overview-panel-1" tabindex="-1">Notes</button>
<button class="tabs-overview__tab" type="button" role="tab" id="tabs-overview-tab-2" aria-selected="false" aria-controls="tabs-overview-panel-2" tabindex="-1">Files</button>
</div>
<div class="tabs-overview__view">
<div class="tabs-overview__track" style="transform:translate(120px, 60px) scale(1) translate(-120px, -60px)">
<div class="tabs-overview__panel" role="tabpanel" id="tabs-overview-panel-0" aria-labelledby="tabs-overview-tab-0" aria-hidden="false">
<b>Photos</b>
<ul>
<li>Harbour, 6am</li>
<li>Market stalls</li>
<li>Fog on the pier</li>
</ul>
</div>
<div class="tabs-overview__panel" role="tabpanel" id="tabs-overview-panel-1" aria-labelledby="tabs-overview-tab-1" aria-hidden="true" inert="">
<b>Notes</b>
<ul>
<li>Poster sizes</li>
<li>Ink order</li>
<li>Call the printer</li>
</ul>
</div>
<div class="tabs-overview__panel" role="tabpanel" id="tabs-overview-panel-2" aria-labelledby="tabs-overview-tab-2" aria-hidden="true" inert="">
<b>Files</b>
<ul>
<li>brief.pdf</li>
<li>moodboard.fig</li>
<li>invoice-1042.pdf</li>
</ul>
</div>
</div>
</div>
</div>.tabs-overview { width: 240px; }
.tabs-overview__list { display: flex; gap: 4px; margin-bottom: 10px; padding: 3px; border-radius: 10px; background: light-dark(#ebedf0, #232529); }
.tabs-overview__tab { flex: 1; height: 26px; border: 0; border-radius: 7px; background: none; color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-overview__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-overview__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-overview__view { position: relative; height: 120px; border-radius: 14px; overflow: hidden; background: light-dark(#fbfcfd, #121417); }
.tabs-overview__track { position: absolute; left: 0; top: 0; display: flex; gap: 24px; transform-origin: 0 0; }
.tabs-overview__panel { box-sizing: border-box; width: 240px; height: 120px; padding: 14px; border-radius: 14px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.tabs-overview__panel b { display: block; margin-bottom: 8px; font-size: 13px; color: light-dark(#373a40, #f1f2f3); }
.tabs-overview__panel ul { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; font-size: 12px; color: light-dark(#50535b, #c6c8cd); }
.tabs-overview__panel li { display: flex; align-items: center; gap: 8px; }
.tabs-overview__panel li::before { content: ''; width: 14px; height: 14px; border-radius: 4px; background: light-dark(#dbdde1, #2e3035); }function initTabsOverview(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var W = 240;
var H = 120;
var GAP = 24;
var ZOOM = 0.36;
var track = root.querySelector('.tabs-overview__track');
function cx(i) { return i * (W + GAP) + W / 2; }
function view(s, c) { return 'translate(' + W / 2 + 'px, ' + H / 2 + 'px) scale(' + s + ') translate(' + -c + 'px, ' + -H / 2 + 'px)'; }
// Switching pulls back to show every panel side by side, slides along to the new one and zooms in
// on it. It starts from wherever the view is, so a quick second pick carries on smoothly.
function travel() {
var m = new DOMMatrix(getComputedStyle(track).transform);
var s0 = m.a || 1;
var c0 = (W / 2 - m.e) / s0;
track.getAnimations().forEach(function (a) { a.cancel(); });
var c1 = cx(sel);
track.style.transform = view(1, c1);
if (Math.abs(c0 - c1) < 1 || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
track.animate(
[
{ transform: view(s0, c0), easing: 'ease-in-out' },
{ transform: view(ZOOM, c0), offset: 0.3, easing: 'ease-in-out' },
{ transform: view(ZOOM, c1), offset: 0.68, easing: 'ease-in-out' },
{ transform: view(1, c1) },
],
{ duration: 950 }
);
}
function select(i) {
if (i === sel) return;
var prev = sel;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
// Every panel stays laid out so the overview can show them all; only the chosen one is exposed.
panels.forEach(function (panel, k) {
panel.setAttribute('aria-hidden', String(k !== i));
panel.inert = k !== i;
});
travel();
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
track.getAnimations().forEach(function (a) { a.cancel(); });
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-overview').forEach(initTabsOverview);Goal: Create a "Overview" tab set.
Visual behavior: Library tabs where switching zooms out to show every panel side by side, slides along to the new one and zooms back in on it.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- All panels sit side by side in one track inside a clipping view. Switching runs a Web Animation on the track: scale down to see them all, pan to the new one, scale back up.
- A pick mid-way reads the current transform and starts from there, so quick picks carry on smoothly.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Panels that are not chosen are aria-hidden and inert, so they cannot be reached while laid out for the overview. Under prefers-reduced-motion it pans without zooming.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Overview" tab set.
Visual behavior: Library tabs where switching zooms out to show every panel side by side, slides along to the new one and zooms back in on it.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- All panels sit side by side in one track inside a clipping view. Switching runs a Web Animation on the track: scale down to see them all, pan to the new one, scale back up.
- A pick mid-way reads the current transform and starts from there, so quick picks carry on smoothly.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Panels that are not chosen are aria-hidden and inert, so they cannot be reached while laid out for the overview. Under prefers-reduced-motion it pans without zooming.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Library tabs where switching zooms out to show every panel side by side, slides along to the new one and zooms back in on it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- keyframes, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Decoration is aria-hidden. Panels that are not chosen are aria-hidden and inert, so they cannot be reached while laid out for the overview. Under prefers-reduced-motion it pans without zooming.