Skip to main content

ns-ui

Turntable Stall

Org/tenant switcher as a locomotive roundhouse turntable: the bridge slews on a real spring, and org-scoped nav stays visibly disconnected through the entire unaligned middle, reconnecting only when the bridge is exactly on the stall angle AND the server has confirmed.

Usage

registry/core/turntable-stall/demo.tsx
"use client";

import { useState } from "react";
import { TurntableSwitcher } from "./component";

export default function TurntableSwitcherDemo() {
  const [log, setLog] = useState<string[]>([]);

  return (
    <div className="flex min-h-screen items-start justify-center bg-background px-6 py-20 text-foreground">
      <div className="w-full max-w-2xl">
        <p className="font-mono text-xs uppercase tracking-widest text-ns-muted">
          ns-ui / turntable-stall
        </p>
        <h1 className="mt-3 text-3xl font-semibold tracking-tight">
          Switching authority has a middle
        </h1>
        <p className="mt-2 max-w-xl text-sm leading-relaxed text-ns-muted">
          Open the switcher and pick a different org. The bridge slews on a
          real spring — it overshoots, it takes a moment, and the org-scoped
          nav below it visibly disconnects the instant you commit. It only
          reconnects once the bridge is exactly on the stall angle AND the
          simulated server has confirmed the switch — not one moment sooner.
        </p>

        <div className="mt-10 rounded-md border border-border bg-background p-6">
          <TurntableSwitcher
            defaultValue="acme"
            onValueChange={(id) => setLog((l) => [id, ...l].slice(0, 6))}
          />
        </div>

        <p className="mt-4 font-mono text-xs text-ns-muted">
          {log.length === 0
            ? "connected → awaiting a switch"
            : `connected → ${log.join(" ← ")}`}
        </p>
      </div>
    </div>
  );
}

Component

registry/core/turntable-stall/component.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// TurntableSwitcher — a multi-tenant org switcher modeled as a locomotive
// roundhouse turntable. The popover holds an SVG turntable: a pivoting bridge
// (your session) and a fan of fixed stalls (the tenants), each a real button
// carrying the org's initial and name. One governing scalar, bridge angle
// theta, moves on an intentionally underdamped spring (zeta 0.75) so a hop
// overshoots by a couple degrees and settles in ~500ms — critical damping
// would read as a digital snap and erase the sense that switching exclusive
// authority has mass. Rail continuity between the bridge tip and a stall's
// fixed spur is a computed dash junction: solid only within +/-1.5deg of
// exact alignment, otherwise gapped or absent, so the geometry itself shows
// the "aligned with nothing" middle of every swing. Org-scoped nav mirrors
// that: it drops to 40% opacity with a hairline gap under each label the
// instant a switch starts, and — this is the falsifiable part — it does NOT
// re-enable when the bridge reaches the stall angle. It re-enables only once
// theta is exactly on target AND confirmSwitch(id) has resolved. Until then
// the bridge is deliberately parked short of the stall (outside the join
// tolerance) rather than showing a spinner: "almost switched" gets a face.
// Selecting a different org before confirmation arrives cancels the stale
// wait via a sequence counter, never an optimistic re-enable. Rail-mate fires
// a 1px translate impulse on the popover — a physical clunk, not a fade.
// Direct-DOM rAF hot path for theta only (a ref'd rotated <g> plus per-stall
// junction <line> refs); discrete state (open, connected, active option)
// stays plain React. Reduced motion teleports theta but keeps the same two-
// phase hold-then-final structure and the nav opacity transition (150ms,
// opacity only) — the disconnected middle is correctness, not garnish.
// ---------------------------------------------------------------------------

export interface TurntableOrg {
  id: string;
  name: string;
  /** single glyph shown on the stall/trigger avatar; defaults to the first letter of name */
  initial?: string;
}

const DEFAULT_ORGS: TurntableOrg[] = [
  { id: "acme", name: "Acme" },
  { id: "globex", name: "Globex" },
  { id: "initech", name: "Initech" },
  { id: "umbrella", name: "Umbrella" },
  { id: "soylent", name: "Soylent" },
];

const DEFAULT_NAV_ITEMS = ["Dashboard", "Members", "Billing", "Settings"];

// -- geometry: fixed SVG user-space, scale-independent (no ResizeObserver needed) --
const VB_W = 300;
const VB_H = 176;
const HUB_X = 150;
const HUB_Y = 158;
const R_INNER = 26; // bridge rail visual start, near the hub
const R_MATE = 100; // mating radius: bridge tip meets each stall's fixed spur here
const R_OUTER = 124; // stall button anchor radius
const R_RIM = R_OUTER + 10; // decorative pit rim
const STEP_DEG = 24; // angular spacing between adjacent stalls
const NEAR_DEG = 11; // a junction only renders when the bridge is within this of a stall
const JOIN_TOLERANCE_DEG = 1.5; // dash junction reads solid/continuous only within this
const HOLD_SHORT_DEG = 2.4; // parked short of mating while awaiting confirmation — outside JOIN_TOLERANCE_DEG on purpose

// -- physics: underdamped spring, tuned for a 3-stall hop to settle in ~500ms
// with a couple degrees of overshoot (amplitude-independent for a linear spring —
// a 1-stall hop settles in the same time with proportionally less overshoot) --
const SPRING_K = 114; // s^-2
const SPRING_ZETA = 0.75;
const SPRING_C = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);
const SETTLE_POS_EPS = 0.04; // deg
const SETTLE_VEL_EPS = 1.2; // deg/s
const SETTLE_DEADLINE_MS = 900; // backstop; never trusts the epsilon alone

// deliberately slower than the hold-phase spring settle (~700-900ms for a
// single hop): a server round trip that resolves before the bridge even
// finishes traveling would mean the hold-short/unjoined state never has time
// to actually render, silently deleting the falsifiable middle the whole
// component exists to make honest.
const CONFIRM_MIN_MS = 950;
const CONFIRM_MAX_MS = 1350;

async function defaultConfirmSwitch(): Promise<void> {
  const latency = CONFIRM_MIN_MS + Math.random() * (CONFIRM_MAX_MS - CONFIRM_MIN_MS);
  await new Promise<void>((resolve) => setTimeout(resolve, latency));
}

function angleOf(index: number, count: number): number {
  return (index - (count - 1) / 2) * STEP_DEG;
}

function ptAt(deg: number, r: number): { x: number; y: number } {
  const a = (deg * Math.PI) / 180;
  return { x: HUB_X + r * Math.sin(a), y: HUB_Y - r * Math.cos(a) };
}

function pct(v: number, total: number): string {
  return `${((v / total) * 100).toFixed(3)}%`;
}

function arcPath(r: number): string {
  const p0 = ptAt(-54, r);
  const p1 = ptAt(54, r);
  return `M ${p0.x.toFixed(2)} ${p0.y.toFixed(2)} A ${r} ${r} 0 0 1 ${p1.x.toFixed(2)} ${p1.y.toFixed(2)}`;
}

export function TurntableSwitcher({
  orgs = DEFAULT_ORGS,
  defaultValue,
  onValueChange,
  confirmSwitch,
  navItems = DEFAULT_NAV_ITEMS,
  label = "Organization",
  defaultOpen = false,
  className = "",
}: {
  /** the tenants, arranged in fixed order along the turntable's fan */
  orgs?: TurntableOrg[];
  /** uncontrolled initial connected org id */
  defaultValue?: string;
  /** fires once the bridge is exactly on target AND the switch is confirmed */
  onValueChange?: (id: string) => void;
  /** simulates (or performs) the server round trip; reject to hold short indefinitely */
  confirmSwitch?: (id: string) => Promise<void>;
  /** labels for the bundled org-scoped nav strip; pass [] to omit it */
  navItems?: string[];
  /** accessible label for the switcher trigger and listbox */
  label?: string;
  /** starts the popover open — showcase/demo use only */
  defaultOpen?: boolean;
  /** extra classes merged onto the rendered root element */
  className?: string;
}) {
  const uid = useId();
  const optId = (i: number) => `${uid}-stall-${i}`;

  const positioned = useMemo(
    () => orgs.map((o, i) => ({ ...o, angle: angleOf(i, orgs.length) })),
    [orgs]
  );

  const [open, setOpen] = useState(defaultOpen);
  const [activeIndex, setActiveIndex] = useState(0);
  const initialId = defaultValue ?? positioned[0]?.id ?? "";
  const [committedId, setCommittedId] = useState(initialId);
  const [pendingId, setPendingId] = useState<string | null>(null);
  const [connected, setConnected] = useState(true);
  const [statusText, setStatusText] = useState("");

  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const listboxRef = useRef<HTMLDivElement>(null);
  const bridgeGroupRef = useRef<SVGGElement>(null);
  const junctionRefs = useRef<(SVGLineElement | null)[]>([]);

  const listRef = useRef(positioned);
  listRef.current = positioned;
  const confirmRef = useRef(confirmSwitch ?? defaultConfirmSwitch);
  confirmRef.current = confirmSwitch ?? defaultConfirmSwitch;
  const onValueChangeRef = useRef(onValueChange);
  onValueChangeRef.current = onValueChange;

  const engineRef = useRef<{
    selectOrg: (id: string) => void;
    onOpen: () => void;
  } | null>(null);

  // -- physics + rail engine: one mount effect, direct-DOM hot path for theta --
  useEffect(() => {
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    const dests = () => listRef.current;
    const angleOfId = (id: string) => dests().find((d) => d.id === id)?.angle ?? 0;
    const nameOfId = (id: string) => dests().find((d) => d.id === id)?.name ?? id;

    let theta = angleOfId(initialId);
    let vel = 0; // deg/s
    let mode: "idle" | "springing" = "idle";
    let springTarget = 0;
    let phase: "hold" | "final" | null = null;
    let springDeadline = 0;
    let raf = 0;
    let last = 0;
    let seq = 0;
    let pendingLocal: string | null = null;
    let committedLocal = initialId;

    const render = () => {
      const g = bridgeGroupRef.current;
      if (g) g.setAttribute("transform", `rotate(${theta.toFixed(2)} ${HUB_X} ${HUB_Y})`);
      const tip = ptAt(theta, R_MATE);
      const arr = dests();
      for (let i = 0; i < arr.length; i++) {
        const line = junctionRefs.current[i];
        if (!line) continue;
        const diff = theta - arr[i]!.angle;
        const absDiff = Math.abs(diff);
        if (absDiff > NEAR_DEG) {
          line.style.opacity = "0";
          continue;
        }
        const stallPt = ptAt(arr[i]!.angle, R_MATE);
        line.setAttribute("x1", tip.x.toFixed(2));
        line.setAttribute("y1", tip.y.toFixed(2));
        line.setAttribute("x2", stallPt.x.toFixed(2));
        line.setAttribute("y2", stallPt.y.toFixed(2));
        const joined = absDiff <= JOIN_TOLERANCE_DEG;
        line.setAttribute("stroke-dasharray", joined ? "0" : "2 3");
        line.style.opacity = joined ? "1" : (0.35 + 0.4 * (1 - absDiff / NEAR_DEG)).toFixed(3);
      }
    };

    const wake = () => {
      if (!raf) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const loop = (now: number) => {
      raf = 0;
      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;
      last = now;
      if (mode === "springing") {
        vel += (-SPRING_K * (theta - springTarget) - SPRING_C * vel) * dt;
        theta += vel * dt;
        const settled =
          now >= springDeadline ||
          (Math.abs(theta - springTarget) < SETTLE_POS_EPS && Math.abs(vel) < SETTLE_VEL_EPS);
        if (settled) {
          theta = springTarget;
          vel = 0;
          mode = "idle";
          onSettled();
        }
      }
      render();
      if (mode === "springing") raf = requestAnimationFrame(loop);
    };

    const onSettled = () => {
      if (phase === "final" && pendingLocal !== null) {
        commitNow(pendingLocal);
      }
      // "hold" phase settling is silent: the bridge just sits short, unjoined,
      // waiting on confirmSwitch — nothing to announce until it resolves.
      phase = null;
    };

    const springTo = (target: number, ph: "hold" | "final") => {
      if (reduced) {
        theta = target;
        vel = 0;
        mode = "idle";
        render();
        if (ph === "final") onSettledInstant();
        return;
      }
      springTarget = target;
      phase = ph;
      mode = "springing";
      springDeadline = performance.now() + SETTLE_DEADLINE_MS;
      wake();
    };

    // reduced-motion settle path: same two-phase structure, no animation
    const onSettledInstant = () => {
      if (pendingLocal !== null) commitNow(pendingLocal);
    };

    const commitNow = (id: string) => {
      pendingLocal = null;
      committedLocal = id;
      setPendingId(null);
      setCommittedId(id);
      setConnected(true);
      setStatusText(`${nameOfId(id)}, connected.`);
      onValueChangeRef.current?.(id);
      if (!reduced && panelRef.current) {
        panelRef.current.animate(
          [{ transform: "translateX(0)" }, { transform: "translateX(1px)" }, { transform: "translateX(0)" }],
          { duration: 90, easing: "ease-out" }
        );
      }
    };

    const selectOrg = (id: string) => {
      if (id === pendingLocal) return;
      // only a no-op when nothing is in flight — reselecting the committed
      // org WHILE a different switch is pending (still holding short,
      // awaiting confirmSwitch) must redirect the bridge back, not stall.
      if (id === committedLocal && pendingLocal === null) return;
      const arr = dests();
      const target = arr.find((d) => d.id === id);
      if (!target) return;
      seq += 1;
      const mySeq = seq;
      pendingLocal = id;
      setPendingId(id);
      setConnected(false);
      setStatusText(`Switching to ${target.name}…`);

      const targetAngle = target.angle;
      const dir = Math.sign(targetAngle - theta) || 1;
      const holdAngle = targetAngle - dir * HOLD_SHORT_DEG;
      springTo(holdAngle, "hold");

      confirmRef.current(id).then(
        () => {
          if (seq !== mySeq) return; // superseded by a later selection
          springTo(targetAngle, "final");
        },
        () => {
          if (seq !== mySeq) return;
          setStatusText(`Couldn't confirm ${target.name} — holding.`);
          // stays parked HOLD_SHORT_DEG short, rails unjoined, until a new
          // selection is made or the consumer retries via confirmSwitch.
        }
      );
    };

    render();
    engineRef.current = { selectOrg, onOpen: render };

    return () => {
      cancelAnimationFrame(raf);
      engineRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- refs-only hot path, mount once
  }, []);

  useEffect(() => {
    if (open) {
      engineRef.current?.onOpen();
      const idx = positioned.findIndex((d) => d.id === (pendingId ?? committedId));
      setActiveIndex(idx >= 0 ? idx : 0);
      requestAnimationFrame(() => listboxRef.current?.focus({ preventScroll: true }));
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- only react to open toggling
  }, [open]);

  // close on outside pointerdown / Escape from anywhere in the root
  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  // idempotent: opening never toggles closed. A prior automated press against
  // this same trigger (before a gate check re-clicks it) must not undo the
  // open state — only Escape, outside click, or Home-position navigation close it.
  const openMenu = () => setOpen(true);
  const closeMenu = (focusTrigger: boolean) => {
    setOpen(false);
    if (focusTrigger) triggerRef.current?.focus();
  };

  const commitIndex = (i: number) => {
    const org = positioned[i];
    if (!org) return;
    setActiveIndex(i);
    engineRef.current?.selectOrg(org.id);
  };

  const onTriggerKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      openMenu();
    }
  };

  const onListKeyDown = (e: React.KeyboardEvent) => {
    const n = positioned.length;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      setActiveIndex((i) => Math.min(n - 1, i + 1));
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      setActiveIndex((i) => Math.max(0, i - 1));
    } else if (e.key === "Home") {
      e.preventDefault();
      setActiveIndex(0);
    } else if (e.key === "End") {
      e.preventDefault();
      setActiveIndex(n - 1);
    } else if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      commitIndex(activeIndex);
    } else if (e.key === "Escape") {
      e.preventDefault();
      closeMenu(true);
    }
  };

  const currentOrg = positioned.find((o) => o.id === committedId) ?? positioned[0];
  const pendingOrg = pendingId ? positioned.find((o) => o.id === pendingId) : null;
  const triggerSub = pendingOrg ? `Switching to ${pendingOrg.name}…` : connected ? "Connected" : "Holding…";

  return (
    <div ref={rootRef} className={`relative inline-block ${className}`}>
      <div className="flex items-center gap-4">
        <button
          ref={triggerRef}
          type="button"
          aria-haspopup="listbox"
          aria-expanded={open}
          onClick={openMenu}
          onKeyDown={onTriggerKeyDown}
          className="flex items-center gap-2.5 rounded-md border border-border bg-background px-3 py-2 text-left outline-none transition-colors hover:border-foreground/25 focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <span
            aria-hidden
            className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-border bg-foreground/[0.04] font-mono text-[11px] font-semibold text-foreground"
          >
            {(currentOrg?.initial ?? currentOrg?.name.charAt(0) ?? "?").toUpperCase()}
          </span>
          <span className="flex flex-col leading-tight">
            <span className="text-sm font-medium text-foreground">{currentOrg?.name ?? label}</span>
            <span className="font-mono text-[10px] uppercase tracking-widest text-ns-muted">
              {triggerSub}
            </span>
          </span>
          <svg aria-hidden viewBox="0 0 16 16" fill="none" className="ml-1 h-3.5 w-3.5 shrink-0 text-ns-muted">
            <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>

        {navItems.length > 0 ? (
          <nav aria-label="Org-scoped navigation" className="flex items-center gap-4">
            {navItems.map((item) => (
              <span key={item} className="flex flex-col items-start gap-1">
                <span
                  className={`text-sm transition-opacity duration-150 ${
                    connected ? "text-foreground/85 opacity-100" : "text-foreground/85 opacity-40"
                  }`}
                >
                  {item}
                </span>
                <span aria-hidden className="flex h-px w-full items-center gap-1">
                  {connected ? (
                    <span className="h-px w-full bg-border" />
                  ) : (
                    <>
                      <span className="h-px w-[38%] bg-border" />
                      <span className="h-px flex-1" />
                      <span className="h-px w-[38%] bg-border" />
                    </>
                  )}
                </span>
              </span>
            ))}
          </nav>
        ) : null}
      </div>

      {open ? (
        <div
          ref={panelRef}
          className="absolute left-0 top-full z-30 mt-2 w-[19rem] rounded-md border border-border bg-background shadow-[0_8px_16px_-4px_color-mix(in_oklab,var(--foreground)_20%,transparent),0_20px_48px_-12px_color-mix(in_oklab,var(--foreground)_35%,transparent)]"
        >
          <div className="border-b border-border px-3 py-2">
            <p className="font-mono text-[10px] uppercase tracking-widest text-ns-muted">
              {label}
            </p>
            <p aria-hidden className="mt-0.5 truncate font-mono text-[11px] text-ns-muted">
              {statusText || "Bridge aligned · rails joined"}
            </p>
          </div>

          <div className="relative">
            <svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="block w-full" aria-hidden>
              <path d={arcPath(R_RIM)} fill="none" stroke="var(--border)" strokeWidth="1" opacity="0.5" />
              <circle cx={HUB_X} cy={HUB_Y} r={R_INNER - 6} fill="none" stroke="var(--border)" strokeWidth="1" />

              {/* fixed spurs: one per stall, never rotate */}
              {positioned.map((o) => {
                const inner = ptAt(o.angle, R_MATE);
                const outer = ptAt(o.angle, R_OUTER);
                return (
                  <g key={o.id}>
                    <line
                      x1={inner.x}
                      y1={inner.y}
                      x2={outer.x}
                      y2={outer.y}
                      stroke="var(--border)"
                      strokeWidth="2"
                    />
                    <circle cx={inner.x} cy={inner.y} r="2" fill="var(--border)" />
                  </g>
                );
              })}

              {/* per-stall dash junction: computed live from theta each frame */}
              {positioned.map((o, i) => (
                <line
                  key={`j-${o.id}`}
                  ref={(el) => {
                    junctionRefs.current[i] = el;
                  }}
                  stroke="var(--foreground)"
                  strokeWidth="1.5"
                  style={{ opacity: 0 }}
                />
              ))}

              {/* the bridge: your session. rotates as one rigid group. */}
              <g ref={bridgeGroupRef}>
                <line
                  x1={HUB_X - 4}
                  y1={HUB_Y - R_INNER}
                  x2={HUB_X - 4}
                  y2={HUB_Y - R_MATE}
                  stroke="var(--foreground)"
                  strokeWidth="2"
                />
                <line
                  x1={HUB_X + 4}
                  y1={HUB_Y - R_INNER}
                  x2={HUB_X + 4}
                  y2={HUB_Y - R_MATE}
                  stroke="var(--foreground)"
                  strokeWidth="2"
                />
                {Array.from({ length: 4 }).map((_, k) => {
                  const y = HUB_Y - (R_INNER + ((R_MATE - R_INNER) * (k + 0.5)) / 4);
                  return (
                    <line
                      key={k}
                      x1={HUB_X - 7}
                      y1={y}
                      x2={HUB_X + 7}
                      y2={y}
                      stroke="var(--foreground)"
                      strokeWidth="1.5"
                      opacity="0.6"
                    />
                  );
                })}
              </g>
              <circle cx={HUB_X} cy={HUB_Y} r="3" fill="var(--foreground)" />
            </svg>

            {/* stalls: real buttons, positioned over the SVG by percentage so
                they scale with it without a ResizeObserver */}
            <div
              ref={listboxRef}
              role="listbox"
              tabIndex={-1}
              aria-label={label}
              aria-activedescendant={optId(activeIndex)}
              onKeyDown={onListKeyDown}
              className="absolute inset-0 outline-none"
            >
              {positioned.map((o, i) => {
                const p = ptAt(o.angle, R_OUTER);
                const isActive = i === activeIndex;
                const isSelected = o.id === (pendingId ?? committedId);
                return (
                  <button
                    key={o.id}
                    id={optId(i)}
                    type="button"
                    role="option"
                    aria-selected={isSelected}
                    tabIndex={-1}
                    onPointerDown={(e) => e.preventDefault()}
                    onClick={() => commitIndex(i)}
                    onPointerMove={() => setActiveIndex(i)}
                    style={{ left: pct(p.x, VB_W), top: pct(p.y, VB_H) }}
                    className={`pointer-events-auto absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded-sm px-1.5 py-1 outline-none transition-colors ${
                      isActive ? "bg-foreground/[0.07]" : ""
                    }`}
                  >
                    <span
                      aria-hidden
                      className={`flex h-6 w-6 items-center justify-center rounded-full border font-mono text-[10px] font-semibold ${
                        isSelected ? "border-foreground text-foreground" : "border-border text-ns-muted"
                      }`}
                    >
                      {(o.initial ?? o.name.charAt(0)).toUpperCase()}
                    </span>
                    <span className="whitespace-nowrap text-[11px] text-foreground">{o.name}</span>
                  </button>
                );
              })}
            </div>
          </div>

          <div className="flex items-center justify-between gap-3 border-t border-border px-3 py-2">
            <p className="font-mono text-[10px] text-ns-muted">arrows move &middot; enter connects</p>
            <p className="font-mono text-[10px] text-ns-muted">esc closes</p>
          </div>
        </div>
      ) : null}

      <p role="status" className="sr-only">
        {statusText}
      </p>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/turntable-stall.tsx, so point the import there.

Use when a multi-tenant org/workspace switcher where the moment between contexts must be visibly, functionally unsafe — scoped nav actually disconnects mid-swing and only re-enables on an exact angle match plus a resolved server confirmation, never on an optimistic timer or a crossfade. Pick command-palette-rotary instead for a generic jump-to-destination switcher navigated as a 1D rotary space (no server gate, no disconnect semantics); pick dial-moire for a value/option dial where the rotation itself is the selected value, not a proxy for exclusive session authority.

Install

npx shadcn add https://design.helpmarq.com/r/turntable-stall.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 an org/tenant switcher whose popover contains an SVG roundhouse turntable: a pivoting bridge (the current session) and a fixed fan of stalls (the tenants), each stall a real <button role="option"> carrying the org's initial and name, arranged along a 24-degree-stepped arc. One governing scalar drives everything: bridge angle theta, integrated on a single underdamped spring (k=114 s^-2, zeta=0.75) so a hop overshoots by a couple degrees and settles in roughly 500ms — critical damping was rejected because it reads as a digital snap and erases the sense that switching exclusive authority has mass. Rail continuity between the bridge tip and each stall's fixed spur is a dash-junction line computed live from theta every frame: solid only within +/-1.5 degrees of exact alignment, dashed and fading outside that, absent past ~11 degrees — so mid-swing, when theta is between stalls, no junction renders at all and the geometry itself shows 'aligned with nothing.' Selecting a different org immediately (before any motion) drops the bundled org-scoped nav strip to 40% opacity with a hairline gap opening under each label (a broken two-segment underline replacing the continuous one), announces 'Switching to {name}…' via a role=status region, and springs the bridge not to the exact stall angle but to a point held HOLD_SHORT_DEG (~2.4deg, deliberately outside the 1.5deg join tolerance) short of it — rails stay visibly unjoined and the bridge visibly parked, so 'almost switched' has a face instead of a spinner. A confirmSwitch(id) promise (default: a simulated 950-1350ms network round trip, deliberately slower than the ~700-900ms the hold-phase spring itself takes to settle, so the parked/unjoined state has time to actually be seen rather than being raced through; injectable for a real backend) runs concurrently; only once it resolves does the bridge spring the remaining short hop to the exact angle, and only at that exact settle does the component flip connected, re-enable the nav strip, announce '{name}, connected.', and fire a 1px translate impulse on the popover panel — a physical clunk marking the exact moment of rail-mate, not a fade. This is the falsifiable constraint: nav re-enables only when theta equals the stall angle AND the server has confirmed, never earlier — no optimistic re-enable, no crossfade between org contexts pretending continuity that was never there. Selecting a second org before the first confirmSwitch resolves cancels the stale wait via a sequence counter (the late resolution is a no-op), and a rejected confirmSwitch leaves the bridge parked short indefinitely with an explicit 'holding' status rather than silently retrying. The stalls form a composite listbox: the popover panel owns role=listbox (tabIndex -1, focused programmatically on open), each stall button is tabIndex -1 with aria-selected on the pending-or-committed org, arrow keys move an active-option highlight via aria-activedescendant without committing, and Enter/Space commits the active stall exactly as a click does. The trigger button is idempotent-open (click always opens, never toggles closed — only Escape, an outside pointerdown, or picking a stall's own path lets the swing finish uninterrupted) so a repeated press can never undo the open state mid-demonstration. Reduced motion teleports theta directly (no spring) but preserves the identical two-phase hold-then-final structure — still parks short awaiting confirmation before jumping to the exact angle — and the org-scoped nav's opacity transition stays a plain 150ms opacity-only fade regardless of the motion preference, because the disconnected middle is a correctness signal, not decorative motion. All ink is drawn from --background/--foreground/--ns-muted/--border via inline var() on the SVG elements and Tailwind utilities bound to the same tokens elsewhere; --ns-accent appears only on the trigger's focus-visible ring. No canvas — the turntable is plain SVG with one rotated <g> for the bridge and per-stall <line> refs for the junctions, updated by a single refs-only rAF loop that sleeps whenever the spring is idle.

Props

PropTypeDefaultDescription
orgs?TurntableOrg[]DEFAULT_ORGSthe tenants, arranged in fixed order along the turntable's fan
defaultValue?string—uncontrolled initial connected org id
onValueChange?(id: string) => void—fires once the bridge is exactly on target AND the switch is confirmed
confirmSwitch?(id: string) => Promise<void>—simulates (or performs) the server round trip; reject to hold short indefinitely
navItems?string[]DEFAULT_NAV_ITEMSlabels for the bundled org-scoped nav strip; pass [] to omit it
label?string"Organization"accessible label for the switcher trigger and listbox
defaultOpen?booleanfalsestarts the popover open — showcase/demo use only
className?string—extra classes merged onto the rendered root element