Tabs Slack Cable

Tabs

Classic tabs whose active-indicator is a single SVG path drawn as a slack cable instead of a rigid bar: it sags mid-jump proportional to distance, tautens flat with a small spring snap, and previews the destination with a 3px tug when you hover a neighboring tab.

Install
npx shadcn add https://design.helpmarq.com/r/tabs-slack-cable.json
Source
registry/core/tabs-slack-cable/component.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

// ---------------------------------------------------------------------------
// SlackRail — classic tabs whose active-indicator is a single SVG path drawn
// as a slack cable instead of a rigid underline bar. At rest it is a taut 2px
// line under the active tab. On selection change both endpoints spring
// toward the new tab's edges (mass scaled by jump distance, so long jumps
// accelerate slower and settle with a touch more overshoot — "feel heavier")
// while the path's control point drops into a quadratic sag proportional to
// how far the endpoints still have left to travel, flattening back to a
// straight line as they arrive with a small underdamped snap. Hovering a
// non-active tab tugs whichever endpoint is spatially nearest it 3px in that
// direction, previewing where the cable would go. Full WAI-ARIA tabs pattern
// (roving tabindex, automatic activation on Left/Right/Home/End); the cable
// itself is aria-hidden and purely decorative. No canvas — DOM + SVG + CSS.
// ---------------------------------------------------------------------------

export interface SlackRailItem {
  value: string;
  label: string;
  content: ReactNode;
}

export interface SlackRailProps {
  items: SlackRailItem[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  "aria-label": string;
  className?: string;
}

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

function clamp(n: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, n));
}

const BASELINE_Y = 7; // rail SVG viewport y for the resting line
const RAIL_H = 14; // rail SVG viewport height (room for the sag to drop into)
const STROKE_W = 2;
const SAG_DIVISOR = 8;
const SAG_MAX = 6;
const TUG_PX = 3;
const SETTLE_EPS = 0.04;
const FORCE_SETTLE_MS = 900;
const SPRING_K = 220; // stiffness, s^-2
const SPRING_C = 26.4; // damping — combined with mass this gives near-critical
// damping on short hops and a visibly underdamped small overshoot on long
// ones, since mass grows with travel distance while c/k stay fixed.
const MASS_PER_PX = 1 / 260;
const TUG_TAU = 0.09; // exponential ease-toward time constant, seconds

interface Endpoint {
  x: number;
  v: number;
  target: number;
}

export function SlackRail({
  items,
  value,
  defaultValue,
  onValueChange,
  className,
  "aria-label": ariaLabel,
  ...rest
}: SlackRailProps) {
  const baseId = useId();
  const reduced = useReducedMotion();

  const isControlled = value !== undefined;
  const [internalValue, setInternalValue] = useState(
    () => value ?? defaultValue ?? items[0]?.value,
  );
  const activeValue = isControlled ? (value as string) : internalValue;
  const activeIndex = Math.max(
    0,
    items.findIndex((it) => it.value === activeValue),
  );

  const select = useCallback(
    (next: string) => {
      if (!isControlled) setInternalValue(next);
      onValueChange?.(next);
    },
    [isControlled, onValueChange],
  );

  const tablistRef = useRef<HTMLDivElement | null>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const left = useRef<Endpoint>({ x: 0, v: 0, target: 0 });
  const right = useRef<Endpoint>({ x: 0, v: 0, target: 0 });
  const travelDistance = useRef(0);
  const transitionStart = useRef(0);
  const tugLeft = useRef(0);
  const tugRight = useRef(0);
  const tugTargetLeft = useRef(0);
  const tugTargetRight = useRef(0);
  const laidOut = useRef(false);

  const rafId = useRef<number | null>(null);
  const lastTick = useRef(0);

  const pathRef = useRef<SVGPathElement | null>(null);
  const [hoverIndex, setHoverIndex] = useState<number | null>(null);
  const [fadeVisible, setFadeVisible] = useState(true);

  const measure = useCallback((index: number) => {
    const list = tablistRef.current;
    const btn = tabRefs.current[index];
    if (!list || !btn) return null;
    const listRect = list.getBoundingClientRect();
    const btnRect = btn.getBoundingClientRect();
    return {
      left: btnRect.left - listRect.left,
      right: btnRect.right - listRect.left,
    };
  }, []);

  const applyPath = useCallback(() => {
    const p = pathRef.current;
    if (!p) return;
    const finalLeft = left.current.x + tugLeft.current;
    const finalRight = right.current.x + tugRight.current;
    const totalTravel = travelDistance.current;
    const remainL =
      totalTravel > 0.5
        ? clamp(Math.abs(left.current.target - left.current.x) / totalTravel, 0, 1)
        : 0;
    const remainR =
      totalTravel > 0.5
        ? clamp(Math.abs(right.current.target - right.current.x) / totalTravel, 0, 1)
        : 0;
    const sagFrac = Math.max(remainL, remainR);
    const sagMax = Math.min(totalTravel / SAG_DIVISOR, SAG_MAX);
    const sag = sagMax * sagFrac;
    const controlX = (finalLeft + finalRight) / 2;
    const controlY = BASELINE_Y + sag;
    p.setAttribute(
      "d",
      `M ${finalLeft} ${BASELINE_Y} Q ${controlX} ${controlY} ${finalRight} ${BASELINE_Y}`,
    );
  }, []);

  const tick = useCallback(() => {
    const now = performance.now();
    const dt = clamp((now - lastTick.current) / 1000, 0, 0.05);
    lastTick.current = now;

    const mass = 1 + travelDistance.current * MASS_PER_PX;

    let stillMoving = false;
    for (const ep of [left.current, right.current]) {
      const a = (SPRING_K * (ep.target - ep.x) - SPRING_C * ep.v) / mass;
      ep.v += a * dt;
      ep.x += ep.v * dt;
      if (Math.abs(ep.target - ep.x) > SETTLE_EPS || Math.abs(ep.v) > SETTLE_EPS) {
        stillMoving = true;
      }
    }

    if (now - transitionStart.current > FORCE_SETTLE_MS) {
      left.current.x = left.current.target;
      left.current.v = 0;
      right.current.x = right.current.target;
      right.current.v = 0;
      stillMoving = false;
    }

    let tugMoving = false;
    const easeTo = (cur: number, target: number) => {
      const next = cur + (target - cur) * (1 - Math.exp(-dt / TUG_TAU));
      if (Math.abs(target - next) > 0.02) tugMoving = true;
      return next;
    };
    tugLeft.current = easeTo(tugLeft.current, tugTargetLeft.current);
    tugRight.current = easeTo(tugRight.current, tugTargetRight.current);

    applyPath();

    if (stillMoving || tugMoving) {
      rafId.current = requestAnimationFrame(tick);
    } else {
      rafId.current = null;
    }
  }, [applyPath]);

  const wake = useCallback(() => {
    if (rafId.current == null) {
      lastTick.current = performance.now();
      rafId.current = requestAnimationFrame(tick);
    }
  }, [tick]);

  // Lay out / retarget the rail whenever the active tab or the tab layout
  // (resize, font load) changes.
  useLayoutEffect(() => {
    const pos = measure(activeIndex);
    if (!pos) return;

    if (!laidOut.current) {
      // First layout: snap in place, no travel, no sag.
      left.current = { x: pos.left, v: 0, target: pos.left };
      right.current = { x: pos.right, v: 0, target: pos.right };
      travelDistance.current = 0;
      laidOut.current = true;
      applyPath();
      return;
    }

    const oldCenter = (left.current.x + right.current.x) / 2;
    const newCenter = (pos.left + pos.right) / 2;
    travelDistance.current = Math.abs(newCenter - oldCenter);
    left.current.target = pos.left;
    right.current.target = pos.right;
    transitionStart.current = performance.now();

    if (reduced) {
      left.current = { x: pos.left, v: 0, target: pos.left };
      right.current = { x: pos.right, v: 0, target: pos.right };
      travelDistance.current = 0;
      applyPath();
      setFadeVisible(false);
      requestAnimationFrame(() => requestAnimationFrame(() => setFadeVisible(true)));
      return;
    }

    wake();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeIndex, reduced]);

  // Re-measure on container resize (font load, responsive width changes) and
  // keep the SVG's viewBox in lockstep with the tablist's actual pixel width
  // so path coordinates (measured in real px) map 1:1 onto the rendered rail.
  const [railWidthPx, setRailWidthPx] = useState(0);
  useEffect(() => {
    const list = tablistRef.current;
    if (!list) return;
    const resnap = () => {
      setRailWidthPx(list.getBoundingClientRect().width);
      const pos = measure(activeIndex);
      if (!pos) return;
      left.current = { x: pos.left, v: 0, target: pos.left };
      right.current = { x: pos.right, v: 0, target: pos.right };
      travelDistance.current = 0;
      applyPath();
    };
    resnap();
    const ro = new ResizeObserver(resnap);
    ro.observe(list);
    return () => ro.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    return () => {
      if (rafId.current != null) cancelAnimationFrame(rafId.current);
    };
  }, []);

  // Hover tug: nudge whichever endpoint is spatially nearest the hovered,
  // non-active tab 3px toward it.
  useEffect(() => {
    if (reduced) return;
    if (hoverIndex === null || hoverIndex === activeIndex) {
      tugTargetLeft.current = 0;
      tugTargetRight.current = 0;
      wake();
      return;
    }
    const pos = measure(hoverIndex);
    if (!pos) return;
    const hoverCenter = (pos.left + pos.right) / 2;
    const distL = Math.abs(hoverCenter - left.current.x);
    const distR = Math.abs(hoverCenter - right.current.x);
    if (distL <= distR) {
      tugTargetLeft.current = Math.sign(hoverCenter - left.current.x) * TUG_PX;
      tugTargetRight.current = 0;
    } else {
      tugTargetRight.current = Math.sign(hoverCenter - right.current.x) * TUG_PX;
      tugTargetLeft.current = 0;
    }
    wake();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [hoverIndex, reduced]);

  const focusTab = (index: number) => {
    tabRefs.current[index]?.focus();
  };

  const onTabKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    const count = items.length;
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
        next = (activeIndex + 1) % count;
        break;
      case "ArrowLeft":
        next = (activeIndex - 1 + count) % count;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = count - 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    focusTab(next);
    select(items[next].value);
  };

  return (
    <div className={className} {...rest}>
      <div
        ref={tablistRef}
        role="tablist"
        aria-label={ariaLabel}
        className="relative flex gap-1"
      >
        {items.map((item, i) => {
          const isActive = i === activeIndex;
          return (
            <button
              key={item.value}
              ref={(el) => {
                tabRefs.current[i] = el;
              }}
              role="tab"
              id={`${baseId}-tab-${item.value}`}
              aria-selected={isActive}
              aria-controls={`${baseId}-panel-${item.value}`}
              tabIndex={isActive ? 0 : -1}
              onClick={() => select(item.value)}
              onKeyDown={onTabKeyDown}
              onPointerEnter={() => setHoverIndex(i)}
              onPointerLeave={() =>
                setHoverIndex((cur) => (cur === i ? null : cur))
              }
              className={`rounded-sm px-3 py-2 text-sm transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
                isActive
                  ? "font-medium text-foreground"
                  : "text-muted hover:text-foreground"
              }`}
            >
              {item.label}
            </button>
          );
        })}
        <svg
          aria-hidden
          focusable="false"
          viewBox={`0 0 ${Math.max(railWidthPx, 1)} ${RAIL_H}`}
          preserveAspectRatio="none"
          width={railWidthPx}
          height={RAIL_H}
          className="pointer-events-none absolute left-0 top-full mt-1 overflow-visible"
          style={
            reduced
              ? { opacity: fadeVisible ? 1 : 0, transition: "opacity 120ms ease-out" }
              : undefined
          }
        >
          <path
            ref={pathRef}
            fill="none"
            stroke="var(--foreground)"
            strokeWidth={STROKE_W}
            strokeLinecap="round"
            vectorEffect="non-scaling-stroke"
          />
        </svg>
      </div>
      {items.map((item, i) => (
        <div
          key={item.value}
          role="tabpanel"
          id={`${baseId}-panel-${item.value}`}
          aria-labelledby={`${baseId}-tab-${item.value}`}
          tabIndex={0}
          hidden={i !== activeIndex}
          className="pt-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}
Use when

ordinary tabbed navigation where the transition itself should read as physical motion under tension — the cable's sag communicates how far you jumped and its taut settle confirms arrival, with a hover-tug preview of where a click would land. Pick segmented-control-fling instead for a segmented control choosing between a small fixed set of mutually exclusive views via a solid thrown thumb the user directly grabs and flings — tabs-slack-cable's mechanism is a deforming line reacting to selection, never a draggable filled block.

Build spec

A full WAI-ARIA tabs widget (role=tablist/tab/tabpanel, roving tabindex, automatic activation on ArrowLeft/ArrowRight/Home/End) whose selection indicator is one aria-hidden SVG <path> under the tab row instead of a CSS-transitioned bar. At rest the path is a flat 2px --foreground stroke spanning exactly the active tab's measured left/right edges. On selection change both endpoints are independently driven by a damped spring (stiffness 220 s^-2, damping 26.4, integrated semi-implicit-Euler on a single rAF loop with zero React state on the hot path) toward the new tab's edges; the spring's effective mass is 1 + travelDistance/260, where travelDistance is the old-to-new active-tab center jump measured once at transition start — so a short hop between adjacent tabs stays near-critically damped while a long jump across the row accelerates and settles more slowly and with a touch more overshoot, reading as heavier. Each frame the path is rebuilt as a quadratic Bezier: the control point's y drops below the flat baseline by min(travelDistance/8, 6)px scaled by how much of the total travel the slower endpoint still has left (1 at the instant the jump starts, 0 once both endpoints arrive), so the cable visibly pays out and sags mid-flight and flattens back to a straight line exactly as it arrives, with the spring's own slight overshoot read as the small snap into place. Hovering a non-active tab (pointerenter/pointerleave) nudges whichever endpoint sits spatially nearest that tab 3px in its direction, eased in with an exponential ease-toward (tau 90ms, the ease-out-expo family) and eased back to 0 on pointerleave — a preview of where the cable would travel, never committing selection. A ResizeObserver keeps the SVG's viewBox locked 1:1 to the tablist's pixel width and re-snaps the rail instantly (no animation) on layout change, e.g. font load or container resize. Tab labels are --muted at rest, --foreground with a hover transition when active or hovered; focus uses focus-visible:outline-accent, never paired with a base outline-none. Panels are real DOM nodes linked by aria-controls/aria-labelledby and kept mounted with the hidden attribute so ids always resolve. prefers-reduced-motion collapses the whole spring/sag mechanism: a selection change snaps both endpoints straight to the destination with zero sag, the hover tug is disabled outright, and the path instead plays a 120ms opacity crossfade so the reposition still reads as a change rather than a silent jump. Pure DOM + SVG + CSS — no canvas anywhere.

Tags
tabsnavigationsvgspringphysicsaccessibility