Part
import { useEffect, useId, useRef, useState } from 'react';
const RANGES = [
{ label: 'Day', body: '1,204 visits today, a little up on yesterday.' },
{ label: 'Week', body: '8,630 visits, with Tuesday the busiest day.' },
{ label: 'Month', body: '34,900 visits, and a third of them on phones.' },
{ label: 'Year', body: '368,000 visits since January.' },
{ label: 'All', body: '1.2 million visits since the site launched.' },
];
const W = 240;
const COL = W / RANGES.length;
const center = (i: number) => i * COL + COL / 2;
const css = `
.tabs-part { width: ${W}px; }
.tabs-part__list { position: relative; display: grid; grid-template-columns: repeat(${RANGES.length}, 1fr); padding: 4px 0; border-radius: 12px; background: light-dark(#ebedf0, #232529); }
.tabs-part__pill { position: absolute; top: 4px; left: ${-COL / 2 + 2}px; width: ${COL - 4}px; height: 28px; border-radius: 9px; background: light-dark(#373a40, #f1f2f3); }
.tabs-part__tab { position: relative; height: 28px; padding: 0; border: 0; background: none; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.tabs-part__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -2px; border-radius: 9px; }
.tabs-part__label { display: inline-block; color: light-dark(#5f626a, #a7abb2); }
.tabs-part__tab[aria-selected='true'] .tabs-part__label { color: light-dark(#fbfcfd, #121417); }
.tabs-part__panel { padding: 12px 2px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function PartTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const labels = useRef<(HTMLSpanElement | null)[]>([]);
const pill = useRef<HTMLSpanElement>(null);
const st = useRef({ x: center(0), v: 0, raf: 0 });
// The pill slides on a spring, and the labels it passes over step up out of its way, more the
// faster it goes, then settle back. Only once it has stopped does the label under it turn dark.
useEffect(() => {
const s = st.current;
const goal = center(sel);
const draw = () => {
pill.current!.style.transform = `translateX(${s.x}px)`;
const fast = Math.min(1, Math.abs(s.v) / 260);
labels.current.forEach((el, i) => {
const near = Math.max(0, 1 - Math.abs(center(i) - s.x) / 30);
el!.style.transform = `translateY(${-9 * near * fast}px) scale(${1 - 0.12 * near * fast})`;
el!.style.color = i === sel && near > 0.6 && fast < 0.5 ? 'light-dark(#fbfcfd, #121417)' : i === sel ? 'light-dark(#373a40, #f1f2f3)' : 'light-dark(#5f626a, #a7abb2)';
});
};
cancelAnimationFrame(s.raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(s, { x: goal, v: 0 });
return draw();
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-120 * (s.x - goal) - 17 * s.v) * dt;
s.x += s.v * dt;
if (Math.abs(s.x - goal) < 0.05 && Math.abs(s.v) < 0.5) {
Object.assign(s, { x: goal, v: 0 });
return draw();
}
draw();
s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
const n = RANGES.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-part">
<div className="tabs-part__list" role="tablist" aria-label="Range" onKeyDown={onKeyDown}>
<span className="tabs-part__pill" ref={pill} style={{ transform: `translateX(${center(0)}px)` }} aria-hidden="true" />
{RANGES.map((r, i) => (
<button
key={r.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-part__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)}
>
<span
className="tabs-part__label"
ref={(el) => {
labels.current[i] = el;
}}
>
{r.label}
</span>
</button>
))}
</div>
{RANGES.map((r, i) => (
<div key={r.label} className="tabs-part__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{r.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsPart(el) on the mounted element instead.
<div class="tabs-part">
<div class="tabs-part__list" role="tablist" aria-label="Range">
<span class="tabs-part__pill" style="transform:translateX(24px)" aria-hidden="true"></span>
<button class="tabs-part__tab" type="button" role="tab" id="tabs-part-tab-0" aria-selected="true" aria-controls="tabs-part-panel-0" tabindex="0">
<span class="tabs-part__label">Day</span>
</button>
<button class="tabs-part__tab" type="button" role="tab" id="tabs-part-tab-1" aria-selected="false" aria-controls="tabs-part-panel-1" tabindex="-1">
<span class="tabs-part__label">Week</span>
</button>
<button class="tabs-part__tab" type="button" role="tab" id="tabs-part-tab-2" aria-selected="false" aria-controls="tabs-part-panel-2" tabindex="-1">
<span class="tabs-part__label">Month</span>
</button>
<button class="tabs-part__tab" type="button" role="tab" id="tabs-part-tab-3" aria-selected="false" aria-controls="tabs-part-panel-3" tabindex="-1">
<span class="tabs-part__label">Year</span>
</button>
<button class="tabs-part__tab" type="button" role="tab" id="tabs-part-tab-4" aria-selected="false" aria-controls="tabs-part-panel-4" tabindex="-1">
<span class="tabs-part__label">All</span>
</button>
</div>
<div class="tabs-part__panel" role="tabpanel" id="tabs-part-panel-0" aria-labelledby="tabs-part-tab-0">1,204 visits today, a little up on yesterday.</div>
<div class="tabs-part__panel" role="tabpanel" id="tabs-part-panel-1" aria-labelledby="tabs-part-tab-1" hidden="">8,630 visits, with Tuesday the busiest day.</div>
<div class="tabs-part__panel" role="tabpanel" id="tabs-part-panel-2" aria-labelledby="tabs-part-tab-2" hidden="">34,900 visits, and a third of them on phones.</div>
<div class="tabs-part__panel" role="tabpanel" id="tabs-part-panel-3" aria-labelledby="tabs-part-tab-3" hidden="">368,000 visits since January.</div>
<div class="tabs-part__panel" role="tabpanel" id="tabs-part-panel-4" aria-labelledby="tabs-part-tab-4" hidden="">1.2 million visits since the site launched.</div>
</div>.tabs-part { width: 240px; }
.tabs-part__list { position: relative; display: grid; grid-template-columns: repeat(5, 1fr); padding: 4px 0; border-radius: 12px; background: light-dark(#ebedf0, #232529); }
.tabs-part__pill { position: absolute; top: 4px; left: -22px; width: 44px; height: 28px; border-radius: 9px; background: light-dark(#373a40, #f1f2f3); }
.tabs-part__tab { position: relative; height: 28px; padding: 0; border: 0; background: none; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.tabs-part__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: -2px; border-radius: 9px; }
.tabs-part__label { display: inline-block; color: light-dark(#5f626a, #a7abb2); }
.tabs-part__tab[aria-selected='true'] .tabs-part__label { color: light-dark(#fbfcfd, #121417); }
.tabs-part__panel { padding: 12px 2px 0; min-height: calc(3.1em + 12px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsPart(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 COL = 240 / n;
var INK = { on: 'light-dark(#fbfcfd, #121417)', lit: 'light-dark(#373a40, #f1f2f3)', dim: 'light-dark(#5f626a, #a7abb2)' };
var pill = root.querySelector('.tabs-part__pill');
var labels = root.querySelectorAll('.tabs-part__label');
function center(i) { return i * COL + COL / 2; }
var s = { x: center(sel), v: 0 };
var raf = 0;
function draw() {
pill.style.transform = 'translateX(' + s.x + 'px)';
var fast = Math.min(1, Math.abs(s.v) / 260);
labels.forEach(function (el, i) {
var near = Math.max(0, 1 - Math.abs(center(i) - s.x) / 30);
el.style.transform = 'translateY(' + -9 * near * fast + 'px) scale(' + (1 - 0.12 * near * fast) + ')';
el.style.color = i === sel && near > 0.6 && fast < 0.5 ? INK.on : i === sel ? INK.lit : INK.dim;
});
}
// The pill slides on a spring, and the labels it passes over step up out of its way, more the
// faster it goes, then settle back. Only once it has stopped does the label under it turn dark.
function run() {
cancelAnimationFrame(raf);
var goal = center(sel);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = { x: goal, v: 0 };
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-120 * (s.x - goal) - 17 * s.v) * dt;
s.x += s.v * dt;
if (Math.abs(s.x - goal) < 0.05 && Math.abs(s.v) < 0.5) {
s = { x: goal, v: 0 };
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
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;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
run();
}
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);
draw();
return function () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-part').forEach(initTabsPart);Goal: Create a "Part" tab set.
Visual behavior: A segmented range picker whose labels step up out of the way as the pill slides beneath them, more the faster it goes, then settle back.
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.
- The pill follows the chosen segment on a damped spring in requestAnimationFrame.
- Each label lifts and shrinks by how close the pill is times how fast it moves, so labels it passes quickly step out of its way and settle back as it slows.
- Only once the pill has stopped under its label does that label turn dark.
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 jumps.
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 "Part" tab set.
Visual behavior: A segmented range picker whose labels step up out of the way as the pill slides beneath them, more the faster it goes, then settle back.
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.
- The pill follows the chosen segment on a damped spring in requestAnimationFrame.
- Each label lifts and shrinks by how close the pill is times how fast it moves, so labels it passes quickly step out of its way and settle back as it slows.
- Only once the pill has stopped under its label does that label turn dark.
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 jumps.
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
A segmented range picker whose labels step up out of the way as the pill slides beneath them, more the faster it goes, then settle back.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, spring
- Techniques
- raf, spring, 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. Under prefers-reduced-motion the pill jumps.
More tabs
See allCliff paths, a cold sea and chips on the harbour wall.
Old beech woods with a river to follow all day.
Miles of sand, and the stars at night are something else.
Hut to hut along the ridge, with snow into June.