Tabs whose underline is a typewriter carriage on a rail — forward moves glide on a spring and stretch with speed, backward moves snap home faster with a ding-bounce, and the panel line-feeds in from the travel direction.
npx shadcn add https://design.helpmarq.com /r/tabs-carriage.jsonregistry/core/tabs-carriage/component.tsx"use client";
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
export type CarriageTab = {
id: string;
label: string;
content: ReactNode;
};
// Typewriter-carriage tab indicator: the underline is a carriage on a rail.
// Forward moves glide on a spring with velocity-proportional stretch; a
// backward move is a carriage RETURN — faster, harder overshoot, and a small
// vertical ding-bounce on arrival. Direct-DOM rAF, zero React state on the
// hot path.
export function CarriageReturn({
tabs,
defaultTab,
className = "",
}: {
tabs: CarriageTab[];
/** id of the initially selected tab (defaults to the first) */
defaultTab?: string;
className?: string;
}) {
const baseId = useId();
const [active, setActive] = useState(
() => defaultTab ?? tabs[0]?.id ?? ""
);
const listRef = useRef<HTMLDivElement>(null);
const carriageRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
// spring state lives in a ref — the loop never touches React
const sim = useRef({
x: 0,
w: 0,
y: 0,
vx: 0,
vw: 0,
vy: 0,
tx: 0,
tw: 0,
raf: 0,
settledOnce: false,
reduced: false,
});
const activeIndex = Math.max(
0,
tabs.findIndex((t) => t.id === active)
);
// retarget the springs whenever the active tab (or layout) changes
useEffect(() => {
const list = listRef.current;
const carriage = carriageRef.current;
if (!list || !carriage) return;
const s = sim.current;
s.reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const measure = () => {
const el = tabRefs.current.get(active);
if (!el) return;
const wasLeft = s.tx;
s.tx = el.offsetLeft;
s.tw = el.offsetWidth;
if (!s.settledOnce || s.reduced) {
// first paint / reduced motion: place, don't animate
s.x = s.tx;
s.w = s.tw;
s.vx = s.vw = s.vy = 0;
s.y = 0;
carriage.style.transform = `translateX(${s.x}px)`;
carriage.style.width = `${s.w}px`;
s.settledOnce = true;
return;
}
// backward travel = carriage return: kick harder, add the ding dip
if (s.tx < wasLeft - 1) {
s.vx -= (wasLeft - s.tx) * 4;
s.vy = 60; // downward ding, springs back
}
wake();
};
const K = 380; // s^-2
const ZETA = 0.72;
const step = (dt: number) => {
const c = 2 * ZETA * Math.sqrt(K);
s.vx += (K * (s.tx - s.x) - c * s.vx) * dt;
s.vw += (K * (s.tw - s.w) - c * s.vw) * dt;
s.vy += (900 * (0 - s.y) - 2 * 0.5 * Math.sqrt(900) * s.vy) * dt;
s.x += s.vx * dt;
s.w += s.vw * dt;
s.y += s.vy * dt;
};
let last = 0;
const loop = (t: number) => {
const dt = Math.min((t - (last || t)) / 1000, 1 / 30);
last = t;
step(dt);
const settled =
Math.abs(s.tx - s.x) < 0.3 &&
Math.abs(s.tw - s.w) < 0.3 &&
Math.abs(s.vx) < 4 &&
Math.abs(s.y) < 0.3 &&
Math.abs(s.vy) < 4;
if (settled) {
s.x = s.tx;
s.w = s.tw;
s.y = 0;
s.vx = s.vw = s.vy = 0;
}
// velocity-proportional stretch; squash falls out of the overshoot
const stretch = 1 + Math.min(Math.abs(s.vx) / 2600, 0.45);
carriage.style.transform = `translateX(${s.x}px) translateY(${s.y * 0.06}px) scaleX(${stretch})`;
carriage.style.width = `${s.w}px`;
if (settled) {
carriage.style.transform = `translateX(${s.x}px)`;
s.raf = 0;
return;
}
s.raf = requestAnimationFrame(loop);
};
const wake = () => {
if (!s.raf) {
last = 0;
s.raf = requestAnimationFrame(loop);
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(list);
return () => {
ro.disconnect();
if (s.raf) cancelAnimationFrame(s.raf);
s.raf = 0;
};
}, [active]);
// panel line-feed: new content enters from the travel direction
const prevIndex = useRef(activeIndex);
useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
if (sim.current.reduced) return;
const dir = activeIndex >= prevIndex.current ? 1 : -1;
prevIndex.current = activeIndex;
panel.animate(
[
{ opacity: 0, transform: `translateX(${dir * 12}px)` },
{ opacity: 1, transform: "translateX(0)" },
],
{ duration: 220, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }
);
}, [activeIndex]);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
let next = -1;
if (e.key === "ArrowRight") next = (activeIndex + 1) % tabs.length;
else if (e.key === "ArrowLeft")
next = (activeIndex - 1 + tabs.length) % tabs.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = tabs.length - 1;
if (next === -1) return;
e.preventDefault();
const id = tabs[next].id;
setActive(id);
tabRefs.current.get(id)?.focus();
};
const activeTab = tabs[activeIndex];
return (
<div className={className}>
<div className="relative border-b border-border">
<div
ref={listRef}
role="tablist"
aria-label="Sections"
className="flex"
onKeyDown={onKeyDown}
>
{tabs.map((tab) => {
const selected = tab.id === active;
return (
<button
key={tab.id}
ref={(el) => {
if (el) tabRefs.current.set(tab.id, el);
else tabRefs.current.delete(tab.id);
}}
role="tab"
id={`${baseId}-tab-${tab.id}`}
aria-selected={selected}
aria-controls={`${baseId}-panel-${tab.id}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActive(tab.id)}
className={`relative rounded-sm px-4 py-2.5 text-sm transition-colors duration-150 hover:bg-foreground/[0.06] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent ${
selected
? "font-medium text-foreground"
: "text-muted hover:text-foreground"
}`}
>
{tab.label}
</button>
);
})}
</div>
{/* the carriage: rides the rail (the border-b) */}
<div
ref={carriageRef}
aria-hidden="true"
className="pointer-events-none absolute -bottom-px left-0 h-0.5 bg-foreground"
style={{ transformOrigin: "center" }}
/>
</div>
{activeTab && (
<div
ref={panelRef}
role="tabpanel"
id={`${baseId}-panel-${activeTab.id}`}
aria-labelledby={`${baseId}-tab-${activeTab.id}`}
tabIndex={0}
className="rounded-sm px-1 py-4 text-sm leading-relaxed text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{activeTab.content}
</div>
)}
</div>
);
}
Build a tab component whose active-tab underline behaves like a typewriter carriage riding the tablist's bottom border rail. The indicator's x-position and width are each driven by an underdamped spring (k = 380 s^-2, zeta = 0.72) toward the active tab's offsetLeft/offsetWidth, stepped in a single direct-DOM rAF loop with zero React state on the hot path; the loop sleeps when position, width, and velocity are all under epsilon and wakes on retarget. While traveling, the carriage stretches horizontally in proportion to |velocity| (scaleX up to 1.45, transform-origin center) so speed is legible, and the squash on arrival falls out of the spring's overshoot rather than being keyframed. Direction matters: a move to a LOWER index is a carriage RETURN — the spring gets an extra leftward velocity kick proportional to the travel distance plus a small downward y impulse on its own stiffer spring (k = 900, zeta = 0.5), producing the typewriter ding dip-and-recover on arrival; rightward moves stay calm. The tab panel line-feeds on every change: content enters with a 220 ms translateX from the travel direction using an ease-out-expo Web Animations call. First paint and any ResizeObserver-detected layout change seat the carriage instantly with velocities zeroed — never animate a resize. Full tablist semantics: role=tablist/tab/tabpanel, aria-selected, aria-controls/labelledby via useId, roving tabindex with automatic activation on ArrowLeft/ArrowRight (wrapping) and Home/End, the panel itself focusable, selected tab in foreground weight-medium against muted siblings, token-relative accent focus-visible rings only. Under prefers-reduced-motion the carriage repositions instantly with no stretch, kick, or panel slide. Colors come only from theme tokens (the carriage is bg-foreground on the border rail).