Skip to main content

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.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build 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

PropTypeDefaultDescription
tabsCarriageTab[]—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