Radio Group Pin

Radio group

Vertical radio group whose single choice is embodied by exactly one indicator: a dot resting on the checked option's notch that, on re-selection, elongates into a thin traveling line along a hairline rail (ticking each notch it passes) and contracts back down into a dot once it arrives — no spring, no overshoot, and never a line at rest.

Install
npx shadcn add https://design.helpmarq.com/r/radio-group-pin.json
Source
registry/core/radio-group-pin/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// PinTumbler — a vertical radio group whose single choice is embodied by
// exactly one indicator: a --foreground DOT sitting on the checked row's
// notch. It only ever looks like a line while it is actually in transit —
// re-selecting elongates it into a thin traveling pill that stretches
// toward the new row, then contracts back down to a dot once it arrives.
// At rest it is never a line: rest state and travel state are visually
// distinct on purpose, so a glance at a settled control shows a dot, never
// mid-motion residue. Distance traveled (both the trip duration and how
// far it stretches) reads as how far apart the options are in the list —
// reorder the options and the trip's duration changes with them.
//
// MECHANISM: the indicator is one absolutely-positioned element whose
// `top`, `height` AND `width` (not a scale transform) are driven together
// via the Web Animations API against measured row centers (a
// ResizeObserver keeps those centers correct across re-layout): in the
// stretch phase, its leading edge eases straight to the new row's center
// while its trailing edge stays put and its width narrows from the dot's
// diameter down to the thin traveling thickness, so it visibly elongates
// into a line across whatever sits between old and new; then, in the
// settle phase, the trailing edge eases up to meet the leading one while
// the width widens back out, contracting the line back into a dot exactly
// where it stopped. Horizontal centering never needs its own keyframe —
// the element sits at the rail column's horizontal center with a permanent
// `translateX(-50%)`, so as WAAPI mutates its inline width every frame the
// browser recenters it for free. Both phases use a single no-overshoot
// ease-out curve (control points never exceed the endpoint) — the edges
// and the width move monotonically toward their targets and stop, they
// never overshoot past the destination and bounce back. Passing an
// intermediate notch schedules a brief opacity/scale "tick" on that notch,
// timed proportionally to where the line's leading edge is along the
// travel phase.
//
// STRUCTURE: real native `<input type="radio">` per row, visually hidden
// but present and focusable — roving arrow keys, form participation, and
// checked-state announcements all come free from the browser, nothing is
// reimplemented. aria-checked is inherent to `<input type=radio>` and
// flips the instant the browser commits the change; the dot/line animation
// is a trailing, aria-hidden visual layer on top of that, never a gate on
// it. Each row is a full-width `<label>` (min 44px tall) so the hit area
// covers the whole row, not just the hidden input.
//
// Reduced motion: the indicator's position, height and width update with
// no animation — straight teleport to the new row as a dot, still fully
// legible and functional.
//
// Pure DOM/CSS, no canvas. Ink is token-relative only: --foreground for
// the dot/line, --border for notches and the rail, --muted for resting
// label text, --accent solely for the keyboard focus ring.
//
// Distinct from segmented-control-fling: segmented-control-fling is a horizontal segmented
// control with a draggable, flingable pill and release-velocity physics.
// PinTumbler is a stacked vertical radio *list* — no dragging, selection
// only ever changes via click or native radio keyboard roving — whose
// indicator visibly commutes between the old and new row along a fixed
// rail, so the trip itself communicates list distance.
// ---------------------------------------------------------------------------

// Single no-overshoot ease-out: both control points' y stays at the 1.0
// endpoint, so the curve decelerates hard into its target and never swings
// past it — no spring, no bounce, on any of the indicator's position,
// length or width.
const TRAVEL_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const PER_ROW_MS = 65;
const MIN_TRAVEL_MS = 90;
const SETTLE_MS = 160;
const TICK_MS = 220;

// Rest shape: a DOT_SIZE x DOT_SIZE circle (border-radius: full), sized to
// match the notch's own 10px footprint so the checked row reads as "its
// notch, filled in" rather than a second, differently-shaped glyph. While
// traveling the indicator narrows to LINE_W — a thin pill — for the
// duration of the trip only; DOT_SIZE is also what the bridge span's cap
// is measured in (see bridgeTop/bridgeHeight below), so the line's start
// and end land exactly on the outer edges of the dot boxes it grew out of.
const DOT_SIZE = 10;
const LINE_W = 3;
const RAIL_COL = 28;

export interface PinTumblerOption {
  /** Stable identifier, used as the native radio's value. */
  value: string;
  label: string;
  /** Optional secondary line, e.g. price or latency. */
  description?: string;
}

export interface PinTumblerProps {
  /** Accessible name for the radiogroup, e.g. "Shipping speed". */
  label: string;
  options: PinTumblerOption[];
  /** Controlled selected value. */
  value?: string;
  /** Initial value when uncontrolled. @default options[0]?.value */
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Shared `name` for the native radios. @default a generated id */
  name?: string;
  className?: string;
}

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

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

export function PinTumbler({
  label,
  options,
  value,
  defaultValue,
  onValueChange,
  name,
  className = "",
}: PinTumblerProps) {
  const generatedName = useId();
  const groupName = name ?? generatedName;

  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(
    () => defaultValue ?? options[0]?.value ?? ""
  );
  const committed = isControlled ? (value as string) : internal;
  const committedIndex = Math.max(
    0,
    options.findIndex((o) => o.value === committed)
  );

  const reducedMotion = useReducedMotion();

  const groupRef = useRef<HTMLDivElement | null>(null);
  const rowRefs = useRef<(HTMLLabelElement | null)[]>([]);
  const pinRef = useRef<HTMLDivElement | null>(null);
  const [rowOffsets, setRowOffsets] = useState<number[]>([]);

  const measure = useCallback(() => {
    const group = groupRef.current;
    if (!group) return;
    const groupTop = group.getBoundingClientRect().top;
    const offsets = rowRefs.current.map((el) => {
      if (!el) return 0;
      const r = el.getBoundingClientRect();
      return r.top - groupTop + r.height / 2;
    });
    setRowOffsets(offsets);
  }, []);

  useLayoutEffect(() => {
    measure();
    // options.length affects row count; re-measure when it changes.
  }, [measure, options.length]);

  useEffect(() => {
    const group = groupRef.current;
    if (!group || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => measure());
    ro.observe(group);
    return () => ro.disconnect();
  }, [measure]);

  const commit = useCallback(
    (v: string) => {
      if (!isControlled) setInternal(v);
      if (v !== committed) onValueChange?.(v);
    },
    [isControlled, committed, onValueChange]
  );

  // --- pin animation ---------------------------------------------------
  // `restIndex` is the single source of truth for "where the pin is
  // actually rendered right now" — it only ever advances in `onfinish`,
  // i.e. once a travel has genuinely completed. Deriving `prev` from it
  // (rather than a separately-tracked ref updated eagerly at effect start)
  // matters the moment a row is re-selected before the in-flight travel
  // finishes: cancelling the live Animation reverts the element to its
  // underlying inline style, which is exactly `rowOffsets[restIndex]` —
  // so the next leg's start keyframe is guaranteed to match what's on
  // screen at that instant. An eagerly-updated ref would already point at
  // the (unfinished) target, so the new leg would start its first
  // keyframe from a row the pin never actually reached, and the browser
  // renders that as an instant teleport before reversing back toward the
  // real new target — visible as a jarring step mid-glide on fast
  // re-selection (rapid clicks or held arrow-key roving).
  const [restIndex, setRestIndex] = useState(committedIndex);
  const animRef = useRef<Animation | null>(null);
  const timeoutsRef = useRef<number[]>([]);
  const [tickSet, setTickSet] = useState<Set<number>>(() => new Set());

  useLayoutEffect(() => {
    const prev = restIndex;
    if (committedIndex === prev) return;

    const pin = pinRef.current;
    const fromCenterY = rowOffsets[prev];
    const toCenterY = rowOffsets[committedIndex];

    // Start the new leg from the indicator's LIVE on-screen box — read its
    // computed `top`/`height`/`width` *before* cancelling the in-flight
    // animation. cancel() reverts the element to its inline style
    // (rowOffsets[restIndex] at the resting DOT_SIZE box), but restIndex
    // still points at the row this travel started from (it only advances in
    // onfinish), so seeding the new leg from rowOffsets[prev]'s resting dot
    // on every re-selection would snap the indicator back to a fully-formed
    // dot even mid-stretch — a visible jump. Reading getComputedStyle first
    // lets the interrupt leg begin from whatever box it actually is right
    // now — a dot, a mid-morph pill, or anything between.
    let fromTop =
      fromCenterY !== undefined ? fromCenterY - DOT_SIZE / 2 : undefined;
    let fromHeight = DOT_SIZE;
    let fromWidth = DOT_SIZE;
    if (pin && animRef.current) {
      const cs = getComputedStyle(pin);
      const liveTop = parseFloat(cs.top);
      const liveHeight = parseFloat(cs.height);
      const liveWidth = parseFloat(cs.width);
      if (
        !Number.isNaN(liveTop) &&
        !Number.isNaN(liveHeight) &&
        !Number.isNaN(liveWidth)
      ) {
        fromTop = liveTop;
        fromHeight = liveHeight;
        fromWidth = liveWidth;
      }
    }

    timeoutsRef.current.forEach((id) => window.clearTimeout(id));
    timeoutsRef.current = [];
    animRef.current?.cancel();
    animRef.current = null;
    setTickSet(new Set());

    if (
      reducedMotion ||
      !pin ||
      typeof pin.animate !== "function" ||
      fromTop === undefined ||
      fromCenterY === undefined ||
      toCenterY === undefined
    ) {
      setRestIndex(committedIndex);
      return;
    }

    const toTop = toCenterY - DOT_SIZE / 2;
    // The line's leading edge reaches the destination first; its trailing
    // edge stays anchored at the far end of [fromCenterY, toCenterY] until
    // the settle phase catches it up. That single box — spanning exactly the
    // two row centers plus the *dot's* own half-caps, i.e. the union of the
    // two rest-dot boxes at fromCenterY and toCenterY — is what "the line
    // stretches toward where it's going" means geometrically: it never
    // reaches further than where the dot's own edge would already sit at
    // either end. No keyframe here ever positions an edge past its own
    // destination, so there is nothing to bounce back from.
    const bridgeTop = Math.min(fromCenterY, toCenterY) - DOT_SIZE / 2;
    const bridgeHeight = Math.abs(toCenterY - fromCenterY) + DOT_SIZE;

    const dist = Math.abs(committedIndex - prev);
    const travelMs = Math.max(MIN_TRAVEL_MS, dist * PER_ROW_MS);
    const total = travelMs + SETTLE_MS;
    const arriveAt = travelMs / total;

    const anim = pin.animate(
      [
        {
          top: `${fromTop}px`,
          height: `${fromHeight}px`,
          width: `${fromWidth}px`,
          offset: 0,
          easing: TRAVEL_EASE,
        },
        {
          top: `${bridgeTop}px`,
          height: `${bridgeHeight}px`,
          width: `${LINE_W}px`,
          offset: arriveAt,
          easing: TRAVEL_EASE,
        },
        {
          top: `${toTop}px`,
          height: `${DOT_SIZE}px`,
          width: `${DOT_SIZE}px`,
          offset: 1,
        },
      ],
      { duration: total, fill: "forwards" }
    );
    animRef.current = anim;
    anim.onfinish = () => {
      // Deliberately not cancelling here: the animation's `fill: "forwards"`
      // holds the exact final keyframe (the resting dot box at
      // `toTop`/`DOT_SIZE`/`DOT_SIZE`), which is the same value `restIndex`
      // now resolves to via the resting inline style. Cancelling immediately
      // would strip the animation's held effect before this `setRestIndex`
      // has actually re-rendered, snapping the element back to the
      // *previous* underlying style for one frame — a visible flash/step
      // right at the end of every single travel. Leaving the finished
      // animation in place keeps the on-screen
      // value continuous; it's reclaimed for free by the
      // `animRef.current?.cancel()` at the top of this effect the next time
      // a travel actually starts.
      setRestIndex(committedIndex);
    };

    const dir = committedIndex > prev ? 1 : -1;
    for (let idx = prev + dir; idx !== committedIndex; idx += dir) {
      const frac = Math.abs(idx - prev) / dist;
      const t = frac * travelMs;
      const onId = window.setTimeout(() => {
        setTickSet((s) => new Set(s).add(idx));
      }, t);
      const offId = window.setTimeout(() => {
        setTickSet((s) => {
          if (!s.has(idx)) return s;
          const next = new Set(s);
          next.delete(idx);
          return next;
        });
      }, t + TICK_MS);
      timeoutsRef.current.push(onId, offId);
    }
    // `restIndex` is read (as `prev`) above; adding it here is safe — once
    // `onfinish` advances it to `committedIndex`, this effect reruns, sees
    // `committedIndex === prev`, and bails on the very first line.
  }, [committedIndex, reducedMotion, rowOffsets, restIndex]);

  useEffect(() => {
    return () => {
      timeoutsRef.current.forEach((id) => window.clearTimeout(id));
      animRef.current?.cancel();
    };
  }, []);

  const measured = rowOffsets.length === options.length && options.length > 0;
  const railTop = measured ? rowOffsets[0] : 0;
  const railHeight = measured ? rowOffsets[rowOffsets.length - 1] - railTop : 0;
  const pinTop = (rowOffsets[restIndex] ?? 0) - DOT_SIZE / 2;

  return (
    <div className={`ns-pt-wrap ${className}`}>
      <div
        ref={groupRef}
        role="radiogroup"
        aria-label={label}
        data-reduced={reducedMotion || undefined}
        className="ns-pt-group"
        style={
          {
            "--pt-rail-col": `${RAIL_COL}px`,
          } as Vars
        }
      >
        {options.length > 1 && (
          <div
            aria-hidden="true"
            className="ns-pt-rail"
            style={{
              top: railTop,
              height: Math.max(0, railHeight),
              opacity: measured ? 1 : 0,
            }}
          />
        )}

        {options.map((opt, i) => (
          <label
            key={opt.value}
            ref={(el) => {
              rowRefs.current[i] = el;
            }}
            className="ns-pt-row"
          >
            <input
              type="radio"
              className="ns-pt-input"
              name={groupName}
              value={opt.value}
              checked={i === committedIndex}
              onChange={() => commit(opt.value)}
            />
            <span className="ns-pt-visual">
              <span className="ns-pt-rail-col">
                <span
                  className="ns-pt-notch"
                  data-ticking={tickSet.has(i) || undefined}
                />
              </span>
              <span className="ns-pt-text">
                <span className="ns-pt-label-text" data-checked={i === committedIndex || undefined}>
                  {opt.label}
                </span>
                {opt.description && (
                  <span className="ns-pt-desc">{opt.description}</span>
                )}
              </span>
            </span>
          </label>
        ))}

        <div
          ref={pinRef}
          aria-hidden="true"
          className="ns-pt-pin"
          style={{
            top: pinTop,
            height: DOT_SIZE,
            width: DOT_SIZE,
            opacity: measured ? 1 : 0,
          }}
        />
      </div>

      <style>{`
        .ns-pt-wrap {
          display: block;
        }
        .ns-pt-group {
          position: relative;
          display: flex;
          flex-direction: column;
        }
        .ns-pt-rail {
          position: absolute;
          left: calc(var(--pt-rail-col) / 2 - 0.5px);
          width: 1px;
          background: var(--border);
          pointer-events: none;
        }
        .ns-pt-row {
          position: relative;
          display: block;
          min-height: 44px;
          cursor: pointer;
          -webkit-tap-highlight-color: transparent;
        }
        .ns-pt-input {
          position: absolute;
          width: 1px;
          height: 1px;
          padding: 0;
          margin: -1px;
          overflow: hidden;
          clip: rect(0, 0, 0, 0);
          white-space: nowrap;
          border: 0;
          outline: none;
        }
        .ns-pt-visual {
          display: flex;
          align-items: center;
          gap: 4px;
          min-height: 44px;
          padding: 6px 12px 6px 0;
          border-radius: 8px;
          transition: background-color 140ms ease;
        }
        .ns-pt-row:hover .ns-pt-visual {
          background: color-mix(in oklab, var(--foreground) 4%, transparent);
        }
        .ns-pt-input:focus-visible + .ns-pt-visual {
          outline: 2px solid var(--accent);
          outline-offset: 2px;
        }
        .ns-pt-rail-col {
          flex: 0 0 var(--pt-rail-col);
          display: flex;
          align-items: center;
          justify-content: center;
        }
        .ns-pt-notch {
          width: 10px;
          height: 10px;
          border-radius: 3px;
          border: 1px solid var(--border);
          box-sizing: border-box;
          transform: scale(1);
          transition: transform 160ms ease, border-color 160ms ease;
        }
        .ns-pt-notch[data-ticking] {
          border-color: var(--foreground);
          transform: scale(1.35);
        }
        .ns-pt-text {
          display: flex;
          flex-direction: column;
          gap: 1px;
          min-width: 0;
        }
        .ns-pt-label-text {
          font-size: 0.875rem;
          color: var(--muted);
          transition: color 160ms ease;
        }
        .ns-pt-label-text[data-checked] {
          color: var(--foreground);
          font-weight: 500;
        }
        .ns-pt-desc {
          font-size: 0.75rem;
          color: var(--muted);
        }
        .ns-pt-pin {
          /* left sits at the rail column's center and stays there via a
             permanent translateX(-50%) — width is animated (dot <-> thin
             traveling pill) straight through JS/WAAPI inline styles, so
             centering can't be a fixed calc() against a constant width. */
          position: absolute;
          left: calc(var(--pt-rail-col) / 2);
          transform: translateX(-50%);
          border-radius: 9999px;
          background: var(--foreground);
          pointer-events: none;
        }

        .ns-pt-group[data-reduced] .ns-pt-notch,
        .ns-pt-group[data-reduced] .ns-pt-label-text {
          transition: none;
        }

        @media (prefers-reduced-motion: reduce) {
          .ns-pt-notch,
          .ns-pt-label-text,
          .ns-pt-visual {
            transition: none;
          }
        }
      `}</style>
    </div>
  );
}
Build spec

A stacked vertical radio list for plans, shipping speeds, environments, or any exclusive choice among ordered options — anywhere a dot swap next to the label would feel weightless and the distance between options is itself worth reading. STRUCTURE: real native input type=radio per row, visually hidden (clipped to 1x1px, not display:none, so it stays in the tab order and keeps native focus/keyboard behavior) but genuinely present — roving arrow keys between rows, form participation, and the browser's own checked-state handling all come free, none of it is reimplemented. Each row is a full-width label (min-height 44px) so the hit area covers the whole row, not just the hidden input; aria-checked is inherent to the native radio and flips the instant the browser commits a change, never waiting on the visual layer. MECHANISM: the indicator is a single absolutely-positioned --foreground dot, animated via the Web Animations API by tweening its own top/height/width against row centers measured with getBoundingClientRect (kept correct across re-layout by a ResizeObserver watching the group) in two phases: stretch (its leading edge eases straight to the new row's center while its trailing edge holds at the old row's center and its width narrows from the dot's diameter down to a thin traveling thickness, so it visibly elongates into a line across whatever sits between old and new — duration scales with how many rows are crossed, ~65ms per row crossed, floor 90ms), settle (the trailing edge eases up to meet the leading edge while the width widens back out, contracting the line back into a dot exactly at the new row, ~160ms). At rest — before the first stretch and after every settle — it is always a dot, never a line; the line only ever exists mid-travel. Both phases use one no-overshoot ease-out curve — the indicator's edges and width move straight to their targets and stop; nothing here overshoots the destination or springs back. Every intermediate row the line passes during the stretch phase gets a brief notch tick — border color and scale pulse, timed proportionally to where the line's leading edge actually is via scheduled timeouts, not a fixed per-row delay — so a five-row trip visibly ticks through rows two, three and four on its way. Notches are static 10x10 --border-outlined marks per row, sized to match the resting dot so the checked row reads as its own notch filled in; the rail connecting them is a 1px --border vertical rule spanning from the first row's notch to the last. Re-selecting the CURRENTLY checked row, or a value that resolves to the same option, is a no-op: no stretch, no settle. REDUCED MOTION: the dot's position updates with no animation at all, an instant teleport to the new row's coordinates as a dot, remaining fully legible. STYLING: unchecked row labels sit in --muted, the checked row's label text steps up to --foreground at medium weight — a typographic cue, not a second indicator glyph; the dot alone carries the selection state visually. Hovering a row tints its background with a faint --foreground wash; the keyboard focus ring (--accent, focus-visible only, never paired with an unconditional outline-none on the same element) renders on the row's visible wrapper, not the clipped input itself. Pure DOM/CSS, no canvas — every color is one of --background --foreground --muted --border --accent, read as CSS custom properties already in scope so both themes restyle for free. Distinct from segmented-control-fling: segmented-control-fling is a horizontal segmented control whose pill is directly grabbable and flingable with release-velocity physics and rubber-banding; PinTumbler never accepts a drag at all, selection changes only via click or native radio keyboard roving, and its indicator is a vertical-list dot whose in-transit trip length communicates how far apart two options sit in the ordering, not a physically-thrown object.

Tags
radio-groupformlistpickerphysicsmicro-interaction