Tumble
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Hourly', body: 'Clear until four, then cloud building from the west.' },
{ label: 'Daily', body: 'Dry all week, with a chance of rain on Saturday.' },
{ label: 'Radar', body: 'A band of showers 80 km out, moving east.' },
];
const PAD = 4;
const GAP = 2;
const css = `
.tabs-tumble { width: 260px; }
.tabs-tumble__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); height: 42px; padding: ${PAD}px; border-radius: 13px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-tumble__pill {
position: absolute;
top: ${PAD}px;
bottom: ${PAD}px;
left: calc(${PAD}px + var(--tabs-tumble-at) * (100% - ${PAD * 2}px) / 3 + ${GAP}px);
width: calc((100% - ${PAD * 2}px) / 3 - ${GAP * 2}px);
border-radius: 9px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)), 0 4px 10px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.3));
}
.tabs-tumble__tab { position: relative; border: 0; border-radius: 9px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.2s; }
.tabs-tumble__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-tumble__tab--under { color: light-dark(#fbfcfd, #121417); }
.tabs-tumble__tab:focus-visible { outline: none; }
.tabs-tumble__list:has(:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-tumble__panel { padding: 14px 6px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function TumbleTabs() {
const id = useId();
const [sel, setSel] = useState(0);
// Where the pill lies right now; it reaches sel one flip at a time.
const [at, setAt] = useState(0);
// The label the pill is resting under, if any: none while it is turning over.
const [under, setUnder] = useState<number | null>(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const pill = useRef<HTMLSpanElement>(null);
const run = useRef({ target: 0, at: 0, busy: false, alive: true });
useEffect(() => {
const r = run.current;
r.alive = true;
return () => {
r.alive = false;
};
}, []);
const tumble = async () => {
const r = run.current;
if (r.busy) return;
r.busy = true;
const el = pill.current!;
setUnder(null);
while (r.alive && r.at !== r.target) {
const dir = Math.sign(r.target - r.at);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
r.at = r.target;
setAt(r.at);
break;
}
// Hinged on the edge it is heading for, it turns over onto the next slot like a domino.
el.style.transformOrigin = dir > 0 ? `calc(100% + ${GAP}px) 50%` : `-${GAP}px 50%`;
const steps = Math.abs(r.target - r.at);
const flip = el.animate(
[
{ transform: 'perspective(260px) rotateY(0deg)', filter: 'brightness(1)' },
{ transform: `perspective(260px) rotateY(${dir * -90}deg)`, filter: 'brightness(0.62)', offset: 0.5 },
{ transform: `perspective(260px) rotateY(${dir * -180}deg)`, filter: 'brightness(1)' },
],
{ duration: steps > 1 ? 230 : 320, easing: steps > 1 ? 'cubic-bezier(0.5, 0, 0.6, 1)' : 'cubic-bezier(0.45, 0, 0.3, 1)', fill: 'forwards' },
);
await flip.finished;
// Landed: move the pill onto the neighbouring slot, the right way round, in the same frame the flip lets go.
r.at += dir;
el.style.setProperty('--tabs-tumble-at', String(r.at));
flip.cancel();
setAt(r.at);
}
r.busy = false;
setUnder(r.at);
};
const select = (i: number) => {
setSel(i);
run.current.target = i;
tumble();
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = TABS.length;
const 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.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-tumble">
<div className="tabs-tumble__list" role="tablist" aria-label="Forecast" onKeyDown={onKeyDown}>
<span ref={pill} className="tabs-tumble__pill" style={{ '--tabs-tumble-at': at } as React.CSSProperties} aria-hidden="true" />
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className={`tabs-tumble__tab${under === i ? ' tabs-tumble__tab--under' : ''}`}
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
{t.label}
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-tumble__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsTumble(el) on the mounted element instead.
<div class="tabs-tumble">
<div class="tabs-tumble__list" role="tablist" aria-label="Forecast">
<span class="tabs-tumble__pill" style="--tabs-tumble-at:0" aria-hidden="true"></span>
<button class="tabs-tumble__tab tabs-tumble__tab--under" type="button" role="tab" id="tabs-tumble-tab-0" aria-selected="true" aria-controls="tabs-tumble-panel-0" tabindex="0">Hourly</button>
<button class="tabs-tumble__tab" type="button" role="tab" id="tabs-tumble-tab-1" aria-selected="false" aria-controls="tabs-tumble-panel-1" tabindex="-1">Daily</button>
<button class="tabs-tumble__tab" type="button" role="tab" id="tabs-tumble-tab-2" aria-selected="false" aria-controls="tabs-tumble-panel-2" tabindex="-1">Radar</button>
</div>
<div class="tabs-tumble__panel" role="tabpanel" id="tabs-tumble-panel-0" aria-labelledby="tabs-tumble-tab-0">Clear until four, then cloud building from the west.</div>
<div class="tabs-tumble__panel" role="tabpanel" id="tabs-tumble-panel-1" aria-labelledby="tabs-tumble-tab-1" hidden="">Dry all week, with a chance of rain on Saturday.</div>
<div class="tabs-tumble__panel" role="tabpanel" id="tabs-tumble-panel-2" aria-labelledby="tabs-tumble-tab-2" hidden="">A band of showers 80 km out, moving east.</div>
</div>.tabs-tumble { width: 260px; }
.tabs-tumble__list { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); height: 42px; padding: 4px; border-radius: 13px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529); }
.tabs-tumble__pill {
position: absolute;
top: 4px;
bottom: 4px;
left: calc(4px + var(--tabs-tumble-at) * (100% - 8px) / 3 + 2px);
width: calc((100% - 8px) / 3 - 4px);
border-radius: 9px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)), 0 4px 10px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.3));
}
.tabs-tumble__tab { position: relative; border: 0; border-radius: 9px; background: none; color: light-dark(#5f626a, #a7abb2); font: inherit; font-size: 13px; font-weight: 500; cursor: pointer; transition: color 0.2s; }
.tabs-tumble__tab:hover { color: light-dark(#42454c, #e5e6e9); }
.tabs-tumble__tab--under { color: light-dark(#fbfcfd, #121417); }
.tabs-tumble__tab:focus-visible { outline: none; }
.tabs-tumble__list:has(:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-tumble__panel { padding: 14px 6px 0; min-height: calc(3.1em + 14px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsTumble(root) {
var GAP = 2;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var pill = root.querySelector('.tabs-tumble__pill');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
// Where the pill lies right now; it reaches sel one flip at a time.
var at = sel, busy = false, alive = true;
// The label the pill is resting under, if any: none while it is turning over.
function under(i) {
tabs.forEach(function (tab, k) { tab.classList.toggle('tabs-tumble__tab--under', k === i); });
}
function tumble() {
if (busy) return;
busy = true;
under(-1);
function next() {
if (!alive || at === sel) {
busy = false;
under(at);
return;
}
var dir = Math.sign(sel - at);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
at = sel;
pill.style.setProperty('--tabs-tumble-at', at);
return next();
}
// Hinged on the edge it is heading for, it turns over onto the next slot like a domino.
pill.style.transformOrigin = dir > 0 ? 'calc(100% + ' + GAP + 'px) 50%' : '-' + GAP + 'px 50%';
var steps = Math.abs(sel - at);
var flip = pill.animate(
[
{ transform: 'perspective(260px) rotateY(0deg)', filter: 'brightness(1)' },
{ transform: 'perspective(260px) rotateY(' + dir * -90 + 'deg)', filter: 'brightness(0.62)', offset: 0.5 },
{ transform: 'perspective(260px) rotateY(' + dir * -180 + 'deg)', filter: 'brightness(1)' },
],
{ duration: steps > 1 ? 230 : 320, easing: steps > 1 ? 'cubic-bezier(0.5, 0, 0.6, 1)' : 'cubic-bezier(0.45, 0, 0.3, 1)', fill: 'forwards' }
);
flip.finished.then(function () {
// Landed: move the pill onto the neighbouring slot, the right way round, in the same frame the flip lets go.
at += dir;
pill.style.setProperty('--tabs-tumble-at', at);
flip.cancel();
next();
});
}
next();
}
function select(i) {
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
tumble();
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
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 () {
alive = false;
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-tumble').forEach(initTabsTumble);Goal: Create a "Tumble" tab set.
Visual behavior: Forecast tabs whose pill turns over end on end like a domino, hinged on the edge it is heading for, once for every tab it crosses.
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.
- Each flip is a Web Animation of perspective() rotateY() through 180 degrees with transform-origin on the slot boundary, so the pill lands exactly on the next slot, darkening as it goes edge on. When it finishes, the pill moves to that slot and the animation is cancelled in the same frame.
- A small loop flips one slot at a time until the pill reaches the selected tab, faster for longer trips, and takes up a new target mid-way. A label turns dark only once the pill rests under it.
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. Under prefers-reduced-motion the pill moves straight to the new tab.
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 "Tumble" tab set.
Visual behavior: Forecast tabs whose pill turns over end on end like a domino, hinged on the edge it is heading for, once for every tab it crosses.
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.
- Each flip is a Web Animation of perspective() rotateY() through 180 degrees with transform-origin on the slot boundary, so the pill lands exactly on the next slot, darkening as it goes edge on. When it finishes, the pill moves to that slot and the animation is cancelled in the same frame.
- A small loop flips one slot at a time until the pill reaches the selected tab, faster for longer trips, and takes up a new target mid-way. A label turns dark only once the pill rests under it.
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. Under prefers-reduced-motion the pill moves straight to the new tab.
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
Forecast tabs whose pill turns over end on end like a domino, hinged on the edge it is heading for, once for every tab it crosses.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, 3d
- Techniques
- 3d transform, perspective, state machine
- 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. Under prefers-reduced-motion the pill moves straight to the new tab.