Skip to main content

ns-ui

Indicator Rack

A plan selector built on the indicator-tablet rack of a pre-electronic cash register: four columns of ten printed tablets each, all ten always mounted, where changing the reading is a sort — the old tablet drops as the new one springs up, the two crossing mid-column 25ms apart — never a single glyph mutating in place.

Use when a pricing-page plan selector where the price reveal should read as a physical instrument reading out a value rather than a number tweening or a card swapping, and the columns of the readout benefit from looking mechanically independent; pick split-flap-board instead for a short status string that clatters through a burst of glyphs on a single mutating cell, or counter-carry-ripple when the value is numeric and a carry propagating between columns is the point.

Install

npx shadcn add https://design.helpmarq.com/r/indicator-rack.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.

Source
registry/core/indicator-rack/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// IndicatorRack — a plan selector built on the indicator-tablet rack of a
// pre-electronic cash register (NCR, c. 1900-1950). Every possible reading
// is a separate printed tablet, permanently present, edge-on at the foot of
// its column; pressing a key springs one tablet up into the window while the
// tablet previously showing drops back onto the stack. Changing the reading
// is a SORT — two plates trade places and visibly cross — never a mutation
// of one glyph in place (that's split-flap-board's mechanism; see below).
//
// Four columns (tens / units / cents / term), ten tablets each (digits 0-9),
// all ten always mounted — nothing is created or destroyed on a change, only
// repositioned. Column geometry derives from the container's SMALLER
// dimension: lift = clamp(0.19 * min(w,h), 44, 96)px (the spec's reference
// figures assume a 62px lift). Selecting a plan or a billing term changes
// the digit shown in one or more columns; each changed column runs two
// independent WAAPI animations on its own tablet pair — a spring RAISE
// (40ms latch hold, 210ms lift with a 4px overshoot, 90ms settle = 340ms)
// starting immediately, and a gravity DROP (178ms fall with a 2px
// overshoot-past-rest bounce, 70ms damping = 248ms) starting 25ms later, so
// the latch's release of the old tablet visibly precedes the spring's
// release of the new one — the offset that makes the two plates cross mid
// column instead of trading places invisibly. A rapid re-selection cancels
// whatever is in flight on that column (reading its LIVE computed
// translateY first, so the interrupted plate keeps moving from wherever it
// actually is, never snapping) and re-targets from there; the real DOM
// price text and the aria-live announcement are never gated on the
// animation, they update the instant a selection commits.
//
// Idle, unconditional, always running (this is what keeps the control alive
// at rest with zero input): a 40px shop-light reflection band crosses the
// window every 6.00s; a pivoted "AMT" flag above the window swings ±1.2° on
// a 3.40s period and never damps; a drive-shaft pawl indexes the whole rack
// 0.5px sideways every 1.60s. All three are pure CSS `animation: infinite`
// loops, contrast-capped so none of them competes with the real price text
// sitting outside the window.
//
// prefers-reduced-motion freezes the DECORATIVE rack (aria-hidden) on one
// composed still frame — flag held at its +1.2° extreme, reflection held
// mid-window, and every column's selected tablet held mid-crossing (41px up)
// against a synthetic predecessor (the previous digit, cyclically) held
// mid-fall (22px down) — a pure function of the selected plan and a frozen
// clock, no PRNG, byte-stable over time. The real radios, the real price
// text and the aria-live region are unaffected: the CONTROL stays fully
// functional, only its illustration stops moving and stops literally
// tracking the animation curve.
//
// Distinct from split-flap-board: a flap cell MUTATES in place — one hinged
// leaf rotates through a glyph set, so values it isn't showing don't exist
// on screen. A tablet rack is a SORT — every value is a separate plate
// permanently in frame, and changing the reading costs the travel of two
// plates moving in opposite directions past each other. Distinct from
// counter-carry-ripple: that component is about a carry propagating between
// columns; here the four columns are mechanically independent and read that
// way, no carry ever crosses a column boundary.
// ---------------------------------------------------------------------------

export interface IndicatorRackPlan {
  /** stable identifier, used as the native radio's value */
  value: string;
  label: string;
}

export type IndicatorRackTerm = "monthly" | "annual";

export interface IndicatorRackProps {
  /** accessible name for the plan radiogroup. @default "Plan" */
  label?: string;
  /** the plan options. @default three placeholder plans, Plan A/B/C */
  plans?: IndicatorRackPlan[];
  /** controlled selected plan value */
  value?: string;
  /** initial plan when uncontrolled. @default plans[0]?.value */
  defaultValue?: string;
  /** called with the new plan value when a different plan is selected */
  onValueChange?: (value: string) => void;
  /** controlled billing term */
  term?: IndicatorRackTerm;
  /** initial billing term when uncontrolled. @default "monthly" */
  defaultTerm?: IndicatorRackTerm;
  /** called with the new term when it changes */
  onTermChange?: (term: IndicatorRackTerm) => void;
  /** shared `name` for the plan radios. @default a generated id */
  name?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type Vars = React.CSSProperties & Record<`--${string}`, string | number>;

const DEFAULT_PLANS: IndicatorRackPlan[] = [
  { value: "a", label: "Plan A" },
  { value: "b", label: "Plan B" },
  { value: "c", label: "Plan C" },
];

// The rack reads out the plan's figure the way a register does — leading
// zeros included — so the three digit columns ARE the price text beside them.
// The three placeholder figures are chosen so every column changes on at
// least one transition: a column that never moves reads as decoration.
const PLAN_AMOUNTS: Record<string, number> = { a: 19, b: 42, c: 128 };
const PLAN_DIGITS: Record<string, [number, number, number]> = {
  a: [0, 1, 9],
  b: [0, 4, 2],
  c: [1, 2, 8],
};
const FALLBACK_DIGITS: [number, number, number] = [0, 0, 0];
const TERM_DIGIT: Record<IndicatorRackTerm, number> = { monthly: 0, annual: 1 };
// The term column carries the same ten plates as every other column, but its
// two reachable ones are printed M and A: a bare 0/1 in a price readout reads
// as another digit, which is exactly the wrong thing for it to say.
const TERM_FACES = ["M", "A", "2", "3", "4", "5", "6", "7", "8", "9"] as const;

const COLUMN_LABELS = ["HUND", "TENS", "UNITS", "TERM"] as const;
const TABLET_COUNT = 10;
const STACK_BAND_H = 30; // px — fixed, not derived (spec §3)
const PITCH = 3; // px — fixed pitch between down-stacked tablet edges
const LIFT_RATIO = 0.19;
const LIFT_MIN = 44;
const LIFT_MAX = 96;
const RAISED_Y = 0; // a raised tablet fills the window; rest is below it

const RAISE_LATCH_MS = 40;
const RAISE_SPRING_MS = 210;
const RAISE_SETTLE_MS = 90;
const RAISE_TOTAL_MS = RAISE_LATCH_MS + RAISE_SPRING_MS + RAISE_SETTLE_MS; // 340
const RAISE_OVERSHOOT_PX = 4;
const RAISE_DELAY_MS = 0;

const DROP_FALL_MS = 178;
const DROP_BOUNCE_MS = 70;
const DROP_TOTAL_MS = DROP_FALL_MS + DROP_BOUNCE_MS; // 248
const DROP_BOUNCE_PX = 2;
const DROP_DELAY_MS = 25; // the offset that makes the plates cross

const ANNOUNCE_DEBOUNCE_MS = 350;

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

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;
}

/** Reads the tablet's LIVE on-screen translateY (px) from its computed
 * transform matrix, so an interrupted animation can be re-targeted from
 * where the plate actually is rather than snapping to its rest style. */
function readTranslateY(el: HTMLElement): number {
  const t = getComputedStyle(el).transform;
  if (!t || t === "none") return 0;
  const m = t.match(/matrix\(([^)]+)\)/);
  if (!m) return 0;
  const parts = m[1]!.split(",").map((n) => parseFloat(n.trim()));
  return parts[5] ?? 0;
}

function restY(digit: number, lift: number): number {
  return lift + digit * PITCH;
}

interface ColumnHandle {
  tablets: (HTMLDivElement | null)[];
  anims: (Animation | null)[];
  digit: number;
}

function makeColumn(digit: number): ColumnHandle {
  return {
    tablets: new Array(TABLET_COUNT).fill(null),
    anims: new Array(TABLET_COUNT).fill(null),
    digit,
  };
}

export function IndicatorRack({
  label = "Plan",
  plans = DEFAULT_PLANS,
  value,
  defaultValue,
  onValueChange,
  term,
  defaultTerm = "monthly",
  onTermChange,
  name,
  className = "",
}: IndicatorRackProps) {
  const generatedName = useId();
  const groupName = name ?? generatedName;

  const isPlanControlled = value !== undefined;
  const [internalPlan, setInternalPlan] = useState(
    () => defaultValue ?? plans[0]?.value ?? ""
  );
  const committedPlan = isPlanControlled ? (value as string) : internalPlan;
  const activePlan = plans.find((p) => p.value === committedPlan) ?? plans[0];

  const isTermControlled = term !== undefined;
  const [internalTerm, setInternalTerm] = useState<IndicatorRackTerm>(defaultTerm);
  const committedTerm = isTermControlled ? (term as IndicatorRackTerm) : internalTerm;

  const reducedMotion = useReducedMotion();

  const commitPlan = useCallback(
    (v: string) => {
      if (!isPlanControlled) setInternalPlan(v);
      if (v !== committedPlan) onValueChange?.(v);
    },
    [isPlanControlled, committedPlan, onValueChange]
  );
  const commitTerm = useCallback(
    (t: IndicatorRackTerm) => {
      if (!isTermControlled) setInternalTerm(t);
      if (t !== committedTerm) onTermChange?.(t);
    },
    [isTermControlled, committedTerm, onTermChange]
  );

  // -- geometry: derived from the container's smaller dimension -----------
  const rootRef = useRef<HTMLDivElement | null>(null);
  const [lift, setLift] = useState(62);
  useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver((entries) => {
      const box = entries[0]?.contentRect;
      if (!box) return;
      const next = clamp(LIFT_RATIO * Math.min(box.width, box.height), LIFT_MIN, LIFT_MAX);
      setLift(next);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const columnWidth = clamp(lift * 0.62, 28, 64);

  // -- rack digits for the current plan + term -----------------------------
  const [tens, units, cents] = PLAN_DIGITS[activePlan?.value ?? ""] ?? FALLBACK_DIGITS;
  const amount = PLAN_AMOUNTS[activePlan?.value ?? ""] ?? 0;
  const termDigit = TERM_DIGIT[committedTerm];
  const digits = useMemo(() => [tens, units, cents, termDigit], [tens, units, cents, termDigit]);

  const columnsRef = useRef<ColumnHandle[]>(digits.map((d) => makeColumn(d)));

  // -- raise/drop: one column at a time, cancel-and-retarget on interrupt --
  useLayoutEffect(() => {
    if (reducedMotion) {
      columnsRef.current.forEach((col, ci) => {
        col.anims.forEach((a) => a?.cancel());
        col.anims.fill(null);
        col.digit = digits[ci]!;
      });
      return;
    }

    digits.forEach((targetDigit, ci) => {
      const col = columnsRef.current[ci];
      if (!col || col.digit === targetDigit) return;
      const outgoing = col.digit;
      const incoming = targetDigit;
      const outEl = col.tablets[outgoing];
      const inEl = col.tablets[incoming];
      col.digit = targetDigit;

      if (outEl) {
        const fromY = col.anims[outgoing] ? readTranslateY(outEl) : RAISED_Y;
        col.anims[outgoing]?.cancel();
        const toRestY = restY(outgoing, lift);
        const anim = outEl.animate(
          [
            { transform: `translateY(${fromY}px)`, offset: 0 },
            {
              transform: `translateY(${toRestY + DROP_BOUNCE_PX}px)`,
              offset: DROP_FALL_MS / DROP_TOTAL_MS,
              easing: "cubic-bezier(0.55,0,1,0.45)",
            },
            { transform: `translateY(${toRestY}px)`, offset: 1, easing: "ease-out" },
          ],
          { duration: DROP_TOTAL_MS, delay: DROP_DELAY_MS, fill: "forwards", easing: "linear" }
        );
        col.anims[outgoing] = anim;
        anim.onfinish = () => {
          if (col.anims[outgoing] === anim) col.anims[outgoing] = null;
        };
      }

      if (inEl) {
        const fromY = col.anims[incoming] ? readTranslateY(inEl) : restY(incoming, lift);
        col.anims[incoming]?.cancel();
        const anim = inEl.animate(
          [
            { transform: `translateY(${fromY}px)`, offset: 0 },
            { transform: `translateY(${fromY}px)`, offset: RAISE_LATCH_MS / RAISE_TOTAL_MS },
            {
              transform: `translateY(${RAISED_Y - RAISE_OVERSHOOT_PX}px)`,
              offset: (RAISE_LATCH_MS + RAISE_SPRING_MS) / RAISE_TOTAL_MS,
              easing: "cubic-bezier(0.34,1.56,0.64,1)",
            },
            { transform: `translateY(${RAISED_Y}px)`, offset: 1, easing: "ease-out" },
          ],
          { duration: RAISE_TOTAL_MS, delay: RAISE_DELAY_MS, fill: "forwards", easing: "linear" }
        );
        col.anims[incoming] = anim;
        anim.onfinish = () => {
          if (col.anims[incoming] === anim) col.anims[incoming] = null;
        };
      }
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [digits, lift, reducedMotion]);

  useEffect(() => {
    return () => {
      columnsRef.current.forEach((col) => col.anims.forEach((a) => a?.cancel()));
    };
  }, []);

  // -- aria-live: announce on commit only, debounced -----------------------
  const [announceText, setAnnounceText] = useState("");
  const announceTimerRef = useRef<number | undefined>(undefined);
  useEffect(() => {
    window.clearTimeout(announceTimerRef.current);
    announceTimerRef.current = window.setTimeout(() => {
      const billed = committedTerm === "annual" ? "annually" : "monthly";
      setAnnounceText(
        `${activePlan?.label}, ${amount} dollars per month, billed ${billed}.`
      );
    }, ANNOUNCE_DEBOUNCE_MS);
    return () => window.clearTimeout(announceTimerRef.current);
  }, [activePlan, committedTerm, amount]);

  // -- Home/End roving to the rails (arrows + Space are native on radios) --
  const planInputRefs = useRef<(HTMLInputElement | null)[]>([]);
  const termInputRefs = useRef<(HTMLInputElement | null)[]>([]);
  const railKeyDown = useCallback(
    (
      e: React.KeyboardEvent<HTMLInputElement>,
      refs: React.MutableRefObject<(HTMLInputElement | null)[]>,
      commit: (v: string) => void,
      values: string[]
    ) => {
      if (e.key !== "Home" && e.key !== "End") return;
      e.preventDefault();
      const idx = e.key === "Home" ? 0 : refs.current.length - 1;
      const el = refs.current[idx];
      if (el) {
        el.focus();
        el.checked = true;
        commit(values[idx]!);
      }
    },
    []
  );

  const billedLabel = committedTerm === "annual" ? "billed annually" : "billed monthly";

  return (
    <div
      ref={rootRef}
      className={`ns-ir-root ${className}`}
      style={{ "--fr-lift": `${lift}px`, "--fr-col-w": `${columnWidth}px` } as Vars}
    >
      <style>{CSS}</style>

      <div className="ns-ir-rack" aria-hidden="true" data-reduced={reducedMotion || undefined}>
        <div className="ns-ir-window">
          <div className="ns-ir-cur">$</div>
          {digits.map((selectedDigit, ci) => {
            const predecessor = (selectedDigit + TABLET_COUNT - 1) % TABLET_COUNT;
            return (
              <div className="ns-ir-column" key={ci}>
                <div className="ns-ir-stack">
                  {Array.from({ length: TABLET_COUNT }, (_, digit) => {
                    let reducedTransform: string | undefined;
                    if (reducedMotion) {
                      if (digit === selectedDigit) {
                        // still rising: short of seated by 21/62 of the lift
                        reducedTransform = `translateY(${(21 / 62) * lift}px)`;
                      } else if (digit === predecessor) {
                        // still falling: short of its rest by 22/62 of the lift
                        reducedTransform = `translateY(${restY(digit, lift) - (22 / 62) * lift}px)`;
                      } else {
                        reducedTransform = `translateY(${restY(digit, lift)}px)`;
                      }
                    }
                    return (
                      <div
                        key={digit}
                        ref={(el) => {
                          columnsRef.current[ci].tablets[digit] = el;
                        }}
                        className="ns-ir-tablet"
                        style={{
                          transform:
                            reducedTransform ??
                            `translateY(${digit === selectedDigit ? RAISED_Y : restY(digit, lift)}px)`,
                        }}
                      >
                        <span>{ci === 3 ? TERM_FACES[digit] : digit}</span>
                      </div>
                    );
                  })}
                </div>
              </div>
            );
          })}
          <div className="ns-ir-reflection" />
        </div>
        <div className="ns-ir-flag" />
        <div className="ns-ir-labels">
          <span className="ns-ir-labels-cur" />
          {COLUMN_LABELS.map((l) => (
            <span key={l}>{l}</span>
          ))}
        </div>
      </div>

      <div className="ns-ir-controls">
        <div role="radiogroup" aria-label={label} className="ns-ir-radiogroup">
          {plans.map((p, i) => (
            <label key={p.value} className="ns-ir-option">
              <input
                ref={(el) => {
                  planInputRefs.current[i] = el;
                }}
                type="radio"
                className="ns-ir-input"
                name={groupName}
                value={p.value}
                checked={p.value === committedPlan}
                onChange={() => commitPlan(p.value)}
                onKeyDown={(e) =>
                  railKeyDown(e, planInputRefs, commitPlan, plans.map((o) => o.value))
                }
              />
              <span className="ns-ir-option-face">{p.label}</span>
            </label>
          ))}
        </div>

        <div role="radiogroup" aria-label="Billing term" className="ns-ir-radiogroup ns-ir-radiogroup-term">
          {(["monthly", "annual"] as IndicatorRackTerm[]).map((t, i) => (
            <label key={t} className="ns-ir-option ns-ir-option-term">
              <input
                ref={(el) => {
                  termInputRefs.current[i] = el;
                }}
                type="radio"
                className="ns-ir-input"
                name={`${groupName}-term`}
                value={t}
                checked={t === committedTerm}
                onChange={() => commitTerm(t)}
                onKeyDown={(e) =>
                  railKeyDown(e, termInputRefs, (v) => commitTerm(v as IndicatorRackTerm), ["monthly", "annual"])
                }
              />
              <span className="ns-ir-option-face">{t === "monthly" ? "Monthly" : "Annual"}</span>
            </label>
          ))}
        </div>

        <p className="ns-ir-price">
          <span className="ns-ir-price-plan">{activePlan?.label}</span>
          <span className="ns-ir-price-word">
            <span className="ns-ir-price-cur">$</span>
            {amount}
          </span>
          <span className="ns-ir-price-meta">per month, {billedLabel}</span>
        </p>

        <div className="ns-ir-actions">
          <button type="button" className="ns-ir-cta">
            Primary action
          </button>
          <button type="button" className="ns-ir-link">
            Secondary link
          </button>
        </div>
      </div>

      <div role="status" aria-live="polite" aria-atomic="true" className="ns-ir-sr-only">
        {announceText}
      </div>
    </div>
  );
}

const CSS = `
.ns-ir-root{
  display:flex;
  flex-direction:column;
  gap:16px;
  container-type:inline-size;
}
.ns-ir-sr-only{
  position:absolute;
  width:1px;height:1px;
  padding:0;margin:-1px;
  overflow:hidden;
  clip:rect(0,0,0,0);
  white-space:nowrap;
  border:0;
}
.ns-ir-rack{
  position:relative;
  align-self:flex-start;
  padding:10px 10px 4px;
  border:1px solid var(--border);
  border-radius:8px;
  background:var(--background);
}
.ns-ir-window{
  position:relative;
  display:flex;
  gap:calc(var(--fr-col-w) * 0.14);
  height:calc(var(--fr-lift) + ${STACK_BAND_H}px);
  overflow:hidden;
  border-radius:4px;
}
.ns-ir-column{
  position:relative;
  width:var(--fr-col-w);
  height:100%;
  overflow:hidden;
  border-left:1px solid color-mix(in oklab, var(--ns-muted) 30%, transparent);
}
.ns-ir-column:first-child{ border-left:none; }
.ns-ir-stack{
  position:relative;
  width:100%;
  height:100%;
}
.ns-ir-tablet{
  position:absolute;
  top:0;
  left:0;
  width:100%;
  height:var(--fr-lift);
  display:flex;
  align-items:center;
  justify-content:center;
  box-sizing:border-box;
  /* a printed plate, not a hole in the rack: one step off the rack's own
     background so the down-stacked edges read as a stack of plates rather
     than as pinstriping, and the raised plate reads as a face. */
  background:color-mix(in oklab, var(--background) 90%, var(--foreground) 10%);
  color:var(--foreground);
  border-top:1px solid color-mix(in oklab, var(--ns-muted) 75%, transparent);
  border-bottom:1px solid color-mix(in oklab, var(--background) 55%, var(--foreground) 45%);
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:calc(var(--fr-col-w) * 0.72);
  font-weight:500;
  line-height:1;
  font-variant-numeric:tabular-nums;
  will-change:transform;
}
.ns-ir-cur{
  display:flex;
  align-items:center;
  justify-content:center;
  width:calc(var(--fr-col-w) * 0.6);
  height:var(--fr-lift);
  flex:none;
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:calc(var(--fr-col-w) * 0.5);
  color:var(--ns-muted);
}
.ns-ir-labels-cur{
  width:calc(var(--fr-col-w) * 0.6) !important;
  flex:none;
}
.ns-ir-reflection{
  position:absolute;
  top:0;
  left:-40px;
  width:40px;
  height:100%;
  background:linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--background) 90%, var(--foreground) 10%),
    transparent
  );
  mix-blend-mode:overlay;
  opacity:0.14;
  pointer-events:none;
  animation:ns-ir-reflect 6s linear infinite;
}
.dark .ns-ir-reflection{
  background:linear-gradient(
    90deg,
    transparent,
    color-mix(in oklab, var(--foreground) 55%, var(--background) 45%),
    transparent
  );
}
@keyframes ns-ir-reflect{
  from{ transform:translateX(0); }
  to{ transform:translateX(196px); }
}
.ns-ir-flag{
  position:absolute;
  top:2px;
  right:14px;
  width:2px;
  height:7px;
  background:var(--ns-muted);
  transform-origin:top center;
  animation:ns-ir-flag 3.4s ease-in-out infinite;
}
@keyframes ns-ir-flag{
  0%,100%{ transform:rotate(1.2deg); }
  50%{ transform:rotate(-1.2deg); }
}
.ns-ir-rack:not([data-reduced]) .ns-ir-window{
  animation:ns-ir-tick 3.2s ease-in-out infinite;
}
@keyframes ns-ir-tick{
  0%,7%{ transform:translateX(0); }
  50%{ transform:translateX(0.5px); }
  57%,100%{ transform:translateX(0.5px); }
}
.ns-ir-rack[data-reduced] .ns-ir-reflection{
  animation:none;
  transform:translateX(9px);
  opacity:0.14;
}
.ns-ir-rack[data-reduced] .ns-ir-flag{
  animation:none;
  transform:rotate(1.2deg);
}
.ns-ir-rack[data-reduced] .ns-ir-window{
  animation:none;
}
.ns-ir-labels{
  display:flex;
  gap:calc(var(--fr-col-w) * 0.14);
  margin-top:4px;
}
.ns-ir-labels span{
  width:var(--fr-col-w);
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:8px;
  letter-spacing:0.1em;
  text-align:center;
  color:var(--ns-muted);
}

.ns-ir-controls{
  display:flex;
  flex-direction:column;
  gap:12px;
}
.ns-ir-radiogroup{
  display:flex;
  gap:6px;
  flex-wrap:wrap;
}
.ns-ir-option{
  position:relative;
  cursor:pointer;
  -webkit-tap-highlight-color:transparent;
}
.ns-ir-input{
  position:absolute;
  width:1px;height:1px;
  padding:0;margin:-1px;
  overflow:hidden;
  clip:rect(0,0,0,0);
  white-space:nowrap;
  border:0;
}
.ns-ir-option-face{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  min-height:36px;
  padding:6px 14px;
  border:1px solid var(--border);
  border-radius:6px;
  font-size:0.8125rem;
  color:var(--ns-muted);
  transition:color 140ms ease, border-color 140ms ease, background-color 140ms ease;
}
.ns-ir-input:checked + .ns-ir-option-face{
  color:var(--foreground);
  border-color:var(--foreground);
  background:color-mix(in oklab, var(--foreground) 5%, transparent);
}
.ns-ir-input:focus-visible + .ns-ir-option-face{
  outline:2px solid var(--ns-accent);
  outline-offset:2px;
}
.ns-ir-option-term .ns-ir-option-face{ min-height:32px; padding:5px 12px; font-size:0.75rem; }

.ns-ir-price{
  display:flex;
  align-items:baseline;
  gap:8px;
  margin:0;
}
.ns-ir-price-plan{
  font-size:0.75rem;
  color:var(--ns-muted);
}
.ns-ir-price-word{
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:1.05rem;
  font-weight:500;
  color:var(--foreground);
  letter-spacing:0.04em;
}
.ns-ir-price-meta{
  font-size:0.75rem;
  color:var(--ns-muted);
}

.ns-ir-actions{
  display:flex;
  align-items:center;
  gap:14px;
}
.ns-ir-cta{
  min-height:40px;
  padding:0 18px;
  border:1px solid var(--ns-accent);
  border-radius:6px;
  background:var(--ns-accent);
  color:var(--background);
  font-size:0.8125rem;
  font-weight:500;
  cursor:pointer;
}
.ns-ir-cta:focus-visible{
  outline:2px solid var(--ns-accent);
  outline-offset:2px;
}
.ns-ir-link{
  border:none;
  background:none;
  padding:0;
  font-size:0.8125rem;
  color:var(--ns-muted);
  text-decoration:underline;
  text-underline-offset:3px;
  cursor:pointer;
}
.ns-ir-link:focus-visible{
  outline:2px solid var(--ns-accent);
  outline-offset:2px;
}

@media (prefers-reduced-motion: reduce){
  .ns-ir-tablet{ transition:none !important; }
}
`;
Build spec

Build a plan-selector/price-reveal control (`IndicatorRack`, props: `label` default "Plan", `plans: {value,label}[]` default three placeholder plans Plan A/B/C, `value`/`defaultValue`/`onValueChange` for the controlled/uncontrolled plan, `term`/`defaultTerm`("monthly"|"annual", default "monthly")/`onTermChange`, `name`, `className`) modeled on the indicator-tablet rack of a pre-electronic NCR cash register. Render an `aria-hidden` decorative rack of FOUR columns (hundreds/tens/units/term, labelled HUND/TENS/UNITS/TERM underneath in 8px mono uppercase, with a static `$` plate to their left so the rack reads as a price at a glance) each holding TEN absolutely-positioned tablet divs (digits 0-9), all ten always mounted for every column — nothing is created or destroyed on a change, only repositioned via `translateY`. Column geometry derives from the container's SMALLER dimension via ResizeObserver: `lift = clamp(0.19 * min(w,h), 44, 96)`px (reference figures assume a 62px lift), `columnWidth = clamp(lift * 0.62, 28, 64)`px. A tablet's rest (down-stacked, unselected) position is `translateY(lift + digit*3)`px — a fixed 3px pitch between the ten edges, producing a foreshortened 30px band of plate-edge slivers at the foot of the column when the window (`height: lift + 30px`, `overflow:hidden`) clips each tablet's own `lift`-tall body; the selected tablet's raised position is `translateY(0)` — the top of the window, where its face is visible; a raised position of `-lift` puts the whole plate above the window and the readout renders empty, filling the window. On a plan or term change, diff old vs new digit per column (plan digits ARE the plan's figure, read out the way a register reads one (leading zeros included): `{a:[0,1,9], b:[0,4,2], c:[1,2,8]}` for hundreds/tens/units, i.e. the placeholder figures 19 / 42 / 128, chosen so every column changes on at least one transition; term digit is monthly=0/annual=1, but the term column's plates are printed M and A rather than 0 and 1 (a bare digit there reads as another price digit)) and, for every changed column, run two independent Web Animations API animations on that column's outgoing and incoming tablet elements: RAISE (incoming tablet, `delay:0`, `duration:340`) keyframes translateY [restY -> restY (hold to 40ms, the latch-release wait) -> -4px at 250ms with a `cubic-bezier(0.34,1.56,0.64,1)` overshoot easing -> 0 at 340ms with `ease-out` settle]; DROP (outgoing tablet, `delay:25`, `duration:248`) keyframes translateY [0 -> restY+2px at 178ms with `cubic-bezier(0.55,0,1,0.45)` gravity easing -> restY at 248ms `ease-out` bounce-damp]. The 25ms delay on the drop relative to the raise's `delay:0` is what makes the two plates visibly cross mid-column — the whole point of the mechanism; do not tween a single value between old and new instead. A re-selection that lands before the previous change has settled must cancel the in-flight `Animation` on the affected tablet(s) AFTER reading its LIVE `translateY` via `getComputedStyle`'s transform matrix (so the interrupted plate keeps moving from wherever it visually is, never snapping), and re-target a fresh animation from that value — the rack must never gate interaction, and the real DOM price text updates immediately regardless of animation state. Idle, unconditional, always-running CSS `animation:infinite` loops (the 'alive at rest' requirement): a 40px shop-light reflection band sweeps the window left to right, 26px/s, 6.00s linear period (`mix-blend-mode:overlay`, opacity ~0.35, `+` in dark theme / `-` in light theme via a `.dark` selector swap of the gradient's mixed color, capped so it never competes with the real price text outside the window); a 2px-wide flag tick above the window rotates `rotate(1.2deg)` to `rotate(-1.2deg)` and back, `ease-in-out`, 3.4s, never damping; the whole rack window nudges `translateX(0.5px)` and back on a 3.2s two-tick cycle standing in for a 1.6s drive-shaft pawl index. `prefers-reduced-motion: reduce` (via `matchMedia` + change listener) cancels all in-flight tablet Animations and freezes the DECORATIVE rack only on one composed still frame, independent of real interaction state but a pure function of the currently selected plan/term (no PRNG, byte-stable over time): the reflection fixed at `translateX(9px)`, the flag fixed at `rotate(1.2deg)`, the rack-tick animation removed, and per column the CURRENTLY selected tablet held at `translateY((21/62)*lift)` (still rising, short of seated at 0) while its cyclic predecessor digit `(selected + 9) % 10` is held at `translateY(restY(predecessor) - (22/62)*lift)` (still falling, short of its rest) — both inside the window, so the freeze frame shows the crossing rather than an empty rack — a permanently mid-crossing pair that still legibly differs per plan, while every other tablet sits at its normal rest translateY. This freeze is decorative only: the real radios, the real DOM price paragraph, and the aria-live region remain fully live and functional under reduced motion, they simply don't animate. Accessibility: two real `role="radiogroup"` sets of native `<input type="radio">` (visually clipped to 1x1px, not display:none, kept in the tab order) — one for plan, one for billing term — get arrow-key roving and Space re-affirmation for free from the browser; add a manual `onKeyDown` handler per radio for Home/End (not native on radio groups) that focuses and checks the first/last option in that group. Focus order in DOM: plan radios, then term radios, then the primary CTA button (`--ns-accent` fill, the ONLY use of `--ns-accent` in the component — never in the reflection band), then a secondary text-button. The real, always-visible price line is plain DOM text (`{planLabel} ${amount} per month, billed monthly|annually`, where amount is the same placeholder figure the rack is reading out — the literal word PRICE is not a price and fails the two-second test) sitting beside the aria-hidden rack. A single `role="status" aria-live="polite" aria-atomic="true"` region announces on commit only, restarted on every plan/term change and firing after a 350ms debounce so arrow-key traversal through several plans produces one announcement: `"{Plan label}, {amount} dollars per month, billed monthly|annually."`. Tokens: tablets are `--background` plates with `--foreground` digits and `--ns-muted`-mixed hairline top/bottom borders; column separators and the rack's own border are `--border`/`--ns-muted` mixes, never a bare `--border` fill (it is a ~1.1:1 separator, invisible as a fill); `--ns-accent` appears only on the CTA fill and on `:focus-visible` outlines. Pure DOM/CSS + Web Animations API, no canvas, no SVG, zero color literals.

Props

PropTypeDefaultDescription
label?string"Plan"accessible name for the plan radiogroup. @default "Plan"
plans?IndicatorRackPlan[]DEFAULT_PLANSthe plan options. @default three placeholder plans, Plan A/B/C
value?stringcontrolled selected plan value
defaultValue?stringinitial plan when uncontrolled. @default plans[0]?.value
onValueChange?(value: string) => voidcalled with the new plan value when a different plan is selected
term?IndicatorRackTermcontrolled billing term
defaultTerm?IndicatorRackTerm"monthly"initial billing term when uncontrolled. @default "monthly"
onTermChange?(term: IndicatorRackTerm) => voidcalled with the new term when it changes
name?stringshared `name` for the plan radios. @default a generated id
className?stringextra classes merged onto the rendered root element