ns-ui
Tabs Carriage
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.
Usage
registry/core/tabs-carriage/demo.tsx"use client";
import { CarriageReturn, type CarriageTab } from "./component";
// Hoisted so the component's measurement effect isn't handed a new array
// identity on every parent render.
const TABS: CarriageTab[] = [
{
id: "overview",
label: "Overview",
content:
"The carriage rides the rail. Click a tab to the right and it glides over on a spring, stretching with speed. Click back left and it returns the way a typewriter carriage does — faster, with a small ding on arrival.",
},
{
id: "usage",
label: "Usage",
content:
"Arrow keys move selection with roving focus; Home and End jump to the rail's ends. Selection is automatic — the carriage follows focus.",
},
{
id: "api",
label: "API",
content:
"Pass tabs as { id, label, content }. The indicator measures the live DOM, so labels of any width work, and a resize re-seats the carriage without animation.",
},
{
id: "changelog",
label: "Changelog",
content:
"v1 — spring rail travel, velocity stretch, return-direction kick, line-feed panel entry, reduced-motion fallback to instant placement.",
},
];
export default function CarriageReturnDemo() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-10 px-6">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
ns-ui / tabs-carriage — the indicator is a carriage on a rail
</p>
<div className="w-full max-w-xl rounded-md border border-border bg-surface p-6">
<CarriageReturn tabs={TABS} />
</div>
</div>
);
}Component
registry/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 = "",
}: {
/** the tabs, in order */
tabs: CarriageTab[];
/** id of the initially selected tab (defaults to the first) */
defaultTab?: string;
/** extra classes merged onto the rendered root element */
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-ns-accent ${
selected
? "font-medium text-foreground"
: "text-ns-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-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
{activeTab.content}
</div>
)}
</div>
);
}The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/tabs-carriage.tsx, so point the import there.
Use when the switch itself should feel mechanical and directional, the underline springing, stretching with its own velocity, and returning faster on a backward move than a forward one while the panel line-feeds in from the travel direction; pick tabs-notch-tenon instead when the active tab must be identifiable in a still frame with no motion at all, or tabs-rail-points when the indicator should retain where the selection came from after it arrives.
Install
npx shadcn add https://design.helpmarq.com /r/tabs-carriage.jsonBuild spec
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).
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | CarriageTab[] | — | the tabs, in order |
| defaultTab? | string | — | id of the initially selected tab (defaults to the first) |
| className? | string | — | extra classes merged onto the rendered root element |