Heavyweight cotton, garment dyed and cut slightly boxy.
Zip
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{ label: 'Bag', body: 'Two items, ready to go. Change a size or take out anything you no longer want.' },
{ label: 'Delivery', body: 'Standard arrives Thursday. Next day is $6 more if you order before 8pm.' },
{ label: 'Payment', body: 'Card, wallet or bank transfer. Nothing is charged until the order ships.' },
];
const W = 240;
const COL = W / TABS.length;
const PITCH = 6;
const TEETH = W / PITCH;
const css = `
.tabs-zip { width: ${W}px; }
.tabs-zip__list { display: grid; grid-template-columns: repeat(${TABS.length}, 1fr); }
.tabs-zip__tab { border: 0; padding: 6px 0 8px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-zip__tab:hover, .tabs-zip__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-zip__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-zip__track { display: block; width: ${W}px; height: 28px; overflow: visible; }
.tabs-zip__tape { fill: light-dark(#ebedf0, #232529); }
.tabs-zip__tooth { fill: light-dark(#cbced3, #393c41); }
.tabs-zip__tooth--shut { fill: light-dark(#50535b, #c6c8cd); }
.tabs-zip__slider { fill: light-dark(#373a40, #f1f2f3); }
.tabs-zip__pull { fill: light-dark(#42454c, #e5e6e9); }
.tabs-zip__panel { padding: 6px 0 0; min-height: calc(3.1em + 6px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
export function ZipTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const [pose, setPose] = useState({ x: COL / 2, a: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const st = useRef({ x: COL / 2, v: 0, a: 0, av: 0, raf: 0 });
// The slider rides to the chosen tab on a spring. Its pull swings back from the speed and then hangs still.
useEffect(() => {
const s = st.current;
const goal = sel * COL + COL / 2;
cancelAnimationFrame(s.raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(s, { x: goal, v: 0, a: 0, av: 0 });
setPose({ x: goal, a: 0 });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-90 * (s.x - goal) - 17 * s.v) * dt;
s.x += s.v * dt;
const want = Math.max(-35, Math.min(35, -s.v * 0.12));
s.av += (-160 * (s.a - want) - 9 * s.av) * dt;
s.a += s.av * dt;
if (Math.abs(s.x - goal) < 0.05 && Math.abs(s.v) < 0.5 && Math.abs(s.a) < 0.2 && Math.abs(s.av) < 1) {
Object.assign(s, { x: goal, v: 0, a: 0, av: 0 });
setPose({ x: goal, a: 0 });
return;
}
setPose({ x: s.x, a: s.a });
s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
// Behind the slider the teeth are meshed in one line; ahead of it the two rows part in a V.
const gap = (x: number) => Math.max(0, Math.min(6, (x - pose.x - 7) * 0.08));
const n = TABS.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-zip">
<div className="tabs-zip__list" role="tablist" aria-label="Checkout" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-zip__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)}
>
{t.label}
</button>
))}
</div>
<svg className="tabs-zip__track" viewBox={`0 0 ${W} 28`} aria-hidden="true">
<rect className="tabs-zip__tape" y={2} width={W} height={3} rx={1.5} />
<rect className="tabs-zip__tape" y={13} width={W} height={3} rx={1.5} />
{Array.from({ length: TEETH }, (_, i) => {
const x = i * PITCH + 1;
const d = gap(x + 2);
return <rect key={`a${i}`} className={`tabs-zip__tooth${d ? '' : ' tabs-zip__tooth--shut'}`} x={x} y={5 - d} width={4} height={4} rx={1} />;
})}
{Array.from({ length: TEETH }, (_, i) => {
const x = i * PITCH + 4;
const d = gap(x + 2);
return <rect key={`b${i}`} className={`tabs-zip__tooth${d ? '' : ' tabs-zip__tooth--shut'}`} x={x} y={9 + d} width={4} height={4} rx={1} />;
})}
<g transform={`translate(${pose.x} 9)`}>
<g transform={`rotate(${pose.a} 0 4)`}>
<path className="tabs-zip__pull" fillRule="evenodd" d="M-4 3H4V16A4 4 0 0 1-4 16ZM-1.5 10H1.5V15H-1.5Z" />
</g>
<rect className="tabs-zip__slider" x={-8} y={-6} width={16} height={11} rx={3.5} />
</g>
</svg>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-zip__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 initTabsZip(el) on the mounted element instead.
<div class="tabs-zip">
<div class="tabs-zip__list" role="tablist" aria-label="Checkout">
<button class="tabs-zip__tab" type="button" role="tab" id="tabs-zip-tab-0" aria-selected="true" aria-controls="tabs-zip-panel-0" tabindex="0">Bag</button>
<button class="tabs-zip__tab" type="button" role="tab" id="tabs-zip-tab-1" aria-selected="false" aria-controls="tabs-zip-panel-1" tabindex="-1">Delivery</button>
<button class="tabs-zip__tab" type="button" role="tab" id="tabs-zip-tab-2" aria-selected="false" aria-controls="tabs-zip-panel-2" tabindex="-1">Payment</button>
</div>
<svg class="tabs-zip__track" viewBox="0 0 240 28" aria-hidden="true">
<rect class="tabs-zip__tape" y="2" width="240" height="3" rx="1.5"></rect>
<rect class="tabs-zip__tape" y="13" width="240" height="3" rx="1.5"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="1" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="7" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="13" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="19" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="25" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="31" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="37" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="43" y="5" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="49" y="4.68" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="55" y="4.2" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="61" y="3.72" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="67" y="3.24" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="73" y="2.76" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="79" y="2.28" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="85" y="1.8" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="91" y="1.32" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="97" y="0.84" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="103" y="0.36" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="109" y="-0.12" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="115" y="-0.6" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="121" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="127" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="133" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="139" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="145" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="151" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="157" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="163" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="169" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="175" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="181" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="187" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="193" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="199" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="205" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="211" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="217" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="223" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="229" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="235" y="-1" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="4" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="10" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="16" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="22" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="28" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="34" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth tabs-zip__tooth--shut" x="40" y="9" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="46" y="9.08" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="52" y="9.56" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="58" y="10.04" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="64" y="10.52" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="70" y="11" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="76" y="11.48" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="82" y="11.96" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="88" y="12.44" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="94" y="12.92" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="100" y="13.4" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="106" y="13.88" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="112" y="14.36" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="118" y="14.84" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="124" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="130" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="136" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="142" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="148" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="154" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="160" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="166" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="172" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="178" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="184" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="190" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="196" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="202" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="208" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="214" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="220" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="226" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="232" y="15" width="4" height="4" rx="1"></rect>
<rect class="tabs-zip__tooth" x="238" y="15" width="4" height="4" rx="1"></rect>
<g transform="translate(40 9)">
<g transform="rotate(0 0 4)">
<path class="tabs-zip__pull" fill-rule="evenodd" d="M-4 3H4V16A4 4 0 0 1-4 16ZM-1.5 10H1.5V15H-1.5Z"></path>
</g>
<rect class="tabs-zip__slider" x="-8" y="-6" width="16" height="11" rx="3.5"></rect>
</g>
</svg>
<div class="tabs-zip__panel" role="tabpanel" id="tabs-zip-panel-0" aria-labelledby="tabs-zip-tab-0">Two items, ready to go. Change a size or take out anything you no longer want.</div>
<div class="tabs-zip__panel" role="tabpanel" id="tabs-zip-panel-1" aria-labelledby="tabs-zip-tab-1" hidden="">Standard arrives Thursday. Next day is $6 more if you order before 8pm.</div>
<div class="tabs-zip__panel" role="tabpanel" id="tabs-zip-panel-2" aria-labelledby="tabs-zip-tab-2" hidden="">Card, wallet or bank transfer. Nothing is charged until the order ships.</div>
</div>.tabs-zip { width: 240px; }
.tabs-zip__list { display: grid; grid-template-columns: repeat(3, 1fr); }
.tabs-zip__tab { border: 0; padding: 6px 0 8px; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-zip__tab:hover, .tabs-zip__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-zip__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-zip__track { display: block; width: 240px; height: 28px; overflow: visible; }
.tabs-zip__tape { fill: light-dark(#ebedf0, #232529); }
.tabs-zip__tooth { fill: light-dark(#cbced3, #393c41); }
.tabs-zip__tooth--shut { fill: light-dark(#50535b, #c6c8cd); }
.tabs-zip__slider { fill: light-dark(#373a40, #f1f2f3); }
.tabs-zip__pull { fill: light-dark(#42454c, #e5e6e9); }
.tabs-zip__panel { padding: 6px 0 0; min-height: calc(3.1em + 6px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsZip(root) {
var W = 240;
var PITCH = 6;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var teeth = root.querySelectorAll('.tabs-zip__tooth');
var slider = root.querySelector('.tabs-zip__track > g');
var pull = slider.querySelector('g');
var n = tabs.length;
var col = W / n;
var teethPerRow = teeth.length / 2;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var s = { x: sel * col + col / 2, v: 0, a: 0, av: 0 };
var raf = 0;
// Behind the slider the teeth are meshed in one line; ahead of it the two rows part in a V.
function draw() {
for (var i = 0; i < teeth.length; i++) {
var top = i < teethPerRow;
var x = (i % teethPerRow) * PITCH + (top ? 1 : 4);
var d = Math.max(0, Math.min(6, (x + 2 - s.x - 7) * 0.08));
teeth[i].setAttribute('y', top ? 5 - d : 9 + d);
teeth[i].classList.toggle('tabs-zip__tooth--shut', !d);
}
slider.setAttribute('transform', 'translate(' + s.x + ' 9)');
pull.setAttribute('transform', 'rotate(' + s.a + ' 0 4)');
}
// The slider rides to the chosen tab on a spring. Its pull swings back from the speed and then hangs still.
function run() {
cancelAnimationFrame(raf);
var goal = sel * col + col / 2;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s = { x: goal, v: 0, a: 0, av: 0 };
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-90 * (s.x - goal) - 17 * s.v) * dt;
s.x += s.v * dt;
var want = Math.max(-35, Math.min(35, -s.v * 0.12));
s.av += (-160 * (s.a - want) - 9 * s.av) * dt;
s.a += s.av * dt;
if (Math.abs(s.x - goal) < 0.05 && Math.abs(s.v) < 0.5 && Math.abs(s.a) < 0.2 && Math.abs(s.av) < 1) {
s = { x: goal, v: 0, a: 0, av: 0 };
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function select(i) {
if (i === sel) return;
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-zip').forEach(initTabsZip);Goal: Create a "Zip" tab set.
Visual behavior: Checkout tabs on a zip: the slider rides to the chosen tab, meshing the teeth behind it and parting them ahead, its pull swinging from the speed.
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.
- Two rows of SVG teeth sit along the track. Each tooth's offset from the centre line is worked out from its distance ahead of the slider, so behind it the rows mesh into one line and ahead of it they part in a widening V.
- The slider follows the chosen tab on a damped spring in requestAnimationFrame, and its pull is a second spring chasing an angle set by the slider's speed, so it swings back as it moves and hangs still once it stops.
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 slider jumps to the 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 "Zip" tab set.
Visual behavior: Checkout tabs on a zip: the slider rides to the chosen tab, meshing the teeth behind it and parting them ahead, its pull swinging from the speed.
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.
- Two rows of SVG teeth sit along the track. Each tooth's offset from the centre line is worked out from its distance ahead of the slider, so behind it the rows mesh into one line and ahead of it they part in a widening V.
- The slider follows the chosen tab on a damped spring in requestAnimationFrame, and its pull is a second spring chasing an angle set by the slider's speed, so it swings back as it moves and hangs still once it stops.
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 slider jumps to the 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
Checkout tabs on a zip: the slider rides to the chosen tab, meshing the teeth behind it and parting them ahead, its pull swinging from the speed.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring
- Techniques
- raf, spring, svg
- 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 slider jumps to the tab.