Chapter one
A winter studio, a borrowed press and a stack of offcuts.
Overview
A walnut desk lamp with a linen shade and a dimmer in the base.
Materials
Reviews
import { useId, useLayoutEffect, useRef, useState } from 'react';
const TABS = [
{ label: 'Overview', body: 'A walnut desk lamp with a linen shade and a dimmer in the base.' },
{ label: 'Materials', body: 'Oiled American walnut, washed linen and a brushed brass stem.' },
{ label: 'Reviews', body: 'Rated 4.8 from 212 reviews. “Warm light, beautifully made.”' },
];
const SMALL = 13;
const BIG = 22;
const FLY = { duration: 560, easing: 'cubic-bezier(0.32, 0.72, 0.2, 1)' };
const css = `
.tabs-carry { position: relative; width: 270px; }
.tabs-carry__list { display: flex; gap: 20px; }
.tabs-carry__tab { position: relative; border: 0; padding: 6px 0; background: none; font: inherit; font-size: ${SMALL}px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-carry__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-carry__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-carry__tab::after {
content: '';
position: absolute;
left: 50%;
bottom: -3px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
transform: scale(0);
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.tabs-carry__tab[aria-selected='true']::after { transform: scale(1); }
.tabs-carry__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-carry__panel { padding-top: 18px; }
.tabs-carry__head, .tabs-carry__ghost { margin: 0; font-size: ${BIG}px; font-weight: 600; line-height: 1.2; letter-spacing: -0.01em; color: light-dark(#373a40, #f1f2f3); }
.tabs-carry__head span, .tabs-carry__ghost { display: inline-block; transform-origin: 0 0; }
.tabs-carry__ghost { position: absolute; left: 0; top: 0; opacity: 0; pointer-events: none; white-space: nowrap; }
.tabs-carry__body { margin: 8px 0 0; min-height: 3.1em; color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; animation: tabs-carry-in 0.45s 0.18s both; }
@keyframes tabs-carry-in {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-carry__body { animation: none; }
}
`;
export function CarryTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const root = useRef<HTMLDivElement>(null);
const head = useRef<HTMLSpanElement>(null);
const ghost = useRef<HTMLSpanElement>(null);
const prev = useRef(sel);
// The new heading starts where its tab label is and grows into place; the old one shrinks back to its tab.
useLayoutEffect(() => {
const from = prev.current;
prev.current = sel;
if (from === sel || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const box = root.current!.getBoundingClientRect();
const h = head.current!.getBoundingClientRect();
const label = (i: number) => tabs.current[i]!.querySelector('span')!.getBoundingClientRect();
const [to, back] = [label(sel), label(from)];
const s = SMALL / BIG;
head.current!.animate([{ transform: `translate(${to.left - h.left}px, ${to.top - h.top}px) scale(${s})` }, { transform: 'none' }], FLY);
const g = ghost.current!;
g.textContent = TABS[from].label;
const at = `translate(${h.left - box.left}px, ${h.top - box.top}px)`;
const home = `translate(${back.left - box.left}px, ${back.top - box.top}px) scale(${s})`;
g.animate(
[
{ transform: at, opacity: 1 },
{ transform: home, opacity: 1, offset: 0.92 },
{ transform: home, opacity: 0 },
],
FLY,
);
}, [sel]);
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-carry" ref={root}>
<div className="tabs-carry__list" role="tablist" aria-label="Product details" onKeyDown={onKeyDown}>
{TABS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-carry__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>{t.label}</span>
</button>
))}
</div>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-carry__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<p className="tabs-carry__head">
<span ref={sel === i ? head : undefined}>{t.label}</span>
</p>
{sel === i && <p className="tabs-carry__body">{t.body}</p>}
</div>
))}
<span className="tabs-carry__ghost" ref={ghost} aria-hidden="true" />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsCarry(el) on the mounted element instead.
<div class="tabs-carry">
<div class="tabs-carry__list" role="tablist" aria-label="Product details">
<button class="tabs-carry__tab" type="button" role="tab" id="tabs-carry-tab-0" aria-selected="true" aria-controls="tabs-carry-panel-0" tabindex="0">
<span>Overview</span>
</button>
<button class="tabs-carry__tab" type="button" role="tab" id="tabs-carry-tab-1" aria-selected="false" aria-controls="tabs-carry-panel-1" tabindex="-1">
<span>Materials</span>
</button>
<button class="tabs-carry__tab" type="button" role="tab" id="tabs-carry-tab-2" aria-selected="false" aria-controls="tabs-carry-panel-2" tabindex="-1">
<span>Reviews</span>
</button>
</div>
<div class="tabs-carry__panel" role="tabpanel" id="tabs-carry-panel-0" aria-labelledby="tabs-carry-tab-0">
<p class="tabs-carry__head">
<span>Overview</span>
</p>
<p class="tabs-carry__body">A walnut desk lamp with a linen shade and a dimmer in the base.</p>
</div>
<div class="tabs-carry__panel" role="tabpanel" id="tabs-carry-panel-1" aria-labelledby="tabs-carry-tab-1" hidden="">
<p class="tabs-carry__head">
<span>Materials</span>
</p>
<p class="tabs-carry__body">Oiled American walnut, washed linen and a brushed brass stem.</p>
</div>
<div class="tabs-carry__panel" role="tabpanel" id="tabs-carry-panel-2" aria-labelledby="tabs-carry-tab-2" hidden="">
<p class="tabs-carry__head">
<span>Reviews</span>
</p>
<p class="tabs-carry__body">Rated 4.8 from 212 reviews. “Warm light, beautifully made.”</p>
</div>
<span class="tabs-carry__ghost" aria-hidden="true"></span>
</div>.tabs-carry { position: relative; width: 270px; }
.tabs-carry__list { display: flex; gap: 20px; }
.tabs-carry__tab { position: relative; border: 0; padding: 6px 0; background: none; font: inherit; font-size: 13px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.3s; }
.tabs-carry__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-carry__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-carry__tab::after {
content: '';
position: absolute;
left: 50%;
bottom: -3px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
transform: scale(0);
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.tabs-carry__tab[aria-selected='true']::after { transform: scale(1); }
.tabs-carry__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; border-radius: 4px; }
.tabs-carry__panel { padding-top: 18px; }
.tabs-carry__head, .tabs-carry__ghost { margin: 0; font-size: 22px; font-weight: 600; line-height: 1.2; letter-spacing: -0.01em; color: light-dark(#373a40, #f1f2f3); }
.tabs-carry__head span, .tabs-carry__ghost { display: inline-block; transform-origin: 0 0; }
.tabs-carry__ghost { position: absolute; left: 0; top: 0; opacity: 0; pointer-events: none; white-space: nowrap; }
.tabs-carry__body { margin: 8px 0 0; min-height: 3.1em; color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; animation: tabs-carry-in 0.45s 0.18s both; }
@keyframes tabs-carry-in {
from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.tabs-carry__body { animation: none; }
}function initTabsCarry(root) {
var SMALL = 13;
var BIG = 22;
var FLY = { duration: 560, easing: 'cubic-bezier(0.32, 0.72, 0.2, 1)' };
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var ghost = root.querySelector('.tabs-carry__ghost');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
function select(i) {
if (i === sel) return;
var from = 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; });
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// The new heading starts where its tab label is and grows into place; the old one shrinks back to its tab.
var box = root.getBoundingClientRect();
var head = panels[i].querySelector('.tabs-carry__head span');
var h = head.getBoundingClientRect();
var to = tabs[i].querySelector('span').getBoundingClientRect();
var back = tabs[from].querySelector('span').getBoundingClientRect();
var s = SMALL / BIG;
head.animate([{ transform: 'translate(' + (to.left - h.left) + 'px, ' + (to.top - h.top) + 'px) scale(' + s + ')' }, { transform: 'none' }], FLY);
ghost.textContent = tabs[from].textContent.trim();
var at = 'translate(' + (h.left - box.left) + 'px, ' + (h.top - box.top) + 'px)';
var home = 'translate(' + (back.left - box.left) + 'px, ' + (back.top - box.top) + 'px) scale(' + s + ')';
ghost.animate([{ transform: at, opacity: 1 }, { transform: home, opacity: 1, offset: 0.92 }, { transform: home, opacity: 0 }], FLY);
}
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 () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-carry').forEach(initTabsCarry);Goal: Create a "Carry" tab set.
Visual behavior: Product tabs whose label flies down and grows into the panel heading when picked, while the old heading shrinks back up into its own tab.
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.
- After a switch, the new heading is played from its tab label's position and scale (13px over 22px) back to its own place with the Web Animations API: measure both, animate the difference to nothing (FLIP).
- A spare ghost element takes the old heading's text and flies the other way, from the heading to its tab, fading as it lands. The panel text rises in after a short delay.
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 heading changes in place.
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 "Carry" tab set.
Visual behavior: Product tabs whose label flies down and grows into the panel heading when picked, while the old heading shrinks back up into its own tab.
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.
- After a switch, the new heading is played from its tab label's position and scale (13px over 22px) back to its own place with the Web Animations API: measure both, animate the difference to nothing (FLIP).
- A spare ghost element takes the old heading's text and flies the other way, from the heading to its tab, fading as it lands. The panel text rises in after a short delay.
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 heading changes in place.
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
Product tabs whose label flies down and grows into the panel heading when picked, while the old heading shrinks back up into its own tab.
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 heading changes in place.
Chapter one
A winter studio, a borrowed press and a stack of offcuts.