Skip to main content

ns-ui

Storey Pole

Indoor level switcher drawn as a true architectural cross-section: floors are horizontal slabs at their real relative heights (a double-height lobby is visibly taller than a mezzanine), a spring-loaded datum line points at the selected slab's elevation, below-grade floors carry a 45-degree hatch fill, and every label prints its elevation in metres.

Install

npx shadcn add https://design.helpmarq.com/r/storey-pole.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/storey-pole/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// StoreyPole — an indoor level switcher drawn as a true architectural
// cross-section rather than an abstract vertical list. Floors are horizontal
// slab bars stacked by their REAL relative heights: slab pixel height =
// storeyHeight(metres) x one shared px-per-metre scale, the same scale for
// every slab in the drawing. A double-height lobby is visibly taller than a
// mezzanine; nothing here ever renders two different storeys as identical
// bars, because that would make the section a fake.
//
// GOVERNING SCALAR: `e`, the selected level's datum elevation in metres
// (its finished-floor elevation, the bottom edge of its slab). Four things
// derive from `e`: the datum line's y position, which slab is visually
// highlighted, the plan viewport's crossfade pairing, and the readout text.
//
// MECHANISM: slabs never move — a section is a fixed drawing. Only the
// datum line moves, via an rAF mass-spring (k constant, zeta derived from
// how many floors were crossed: 1 floor is near-critically-damped with
// almost no overshoot, farther jumps progressively underdamp so the
// overshoot visibly scales with distance traveled — a fixed overshoot would
// make a 1-floor and an 8-floor jump feel identical). Pointer interaction on
// the cross-section drags the line continuously: it magnetizes to a slab's
// datum with a deadband 30% of THAT slab's own height, snapping there when
// close; outside every deadband it free-follows the pointer. The nearest
// slab is tracked as a live PREVIEW (drives the highlight, the plan
// crossfade and the readout) without touching the committed value or
// firing onValueChange until pointer-up.
//
// Below-grade slabs (elevation < 0) carry a 45-degree --border hatch fill
// down their left margin — the standard section convention for earth — so
// "below ground" reads before any label does.
//
// STRUCTURE: real native input type=radio per level (not wrapped in a
// <label>, since pointer commits are handled by the drag/click surface
// covering the whole section — but every native radio behavior arrow-key
// roving, Tab order, form participation, aria-checked comes free from the
// browser). Each radio's accessible name carries the real numbers: label,
// elevation phrase, optional name, and its immediate neighbors above/below.
// Arrow-key/commit changes push a spoken elevation delta ("up 3.6 m") into
// an aria-live region. The datum line and hatch are aria-hidden; reduced
// motion repositions the line instantly instead of springing, and skips the
// plan crossfade's fade in favor of an instant swap.
//
// Pure DOM + SVG + CSS. No canvas. Every ink color is one of --background
// --foreground --ns-muted --border --ns-accent, --ns-accent reserved for
// the keyboard focus ring only.
//
// Distinct from counterpoise-tiers: counterpoise-tiers stacks CONTENT by
// weight (an information hierarchy). StoreyPole stacks REAL BUILDING
// GEOMETRY — the metres behind every slab's proportion are printed in its
// accessible name, and the falsifiable constraint (shared px/metre scale)
// means the drawing is either an honest section or visibly wrong, never a
// stylistic choice.
// ---------------------------------------------------------------------------

export interface StoreyPoleLevel {
  /** Stable identifier, used as the native radio's value. */
  id: string;
  /** Short floor code, e.g. "3", "G", "M", "B2". */
  label: string;
  /** Optional descriptive name, e.g. "Engineering", "Lobby", "Parking". */
  name?: string;
  /** Finished-floor elevation in metres above ground (negative = below grade). */
  elevation: number;
  /** Storey height in metres — the ONLY thing that drives this slab's drawn height. */
  height: number;
}

export interface StoreyPoleProps {
  /** Accessible name for the radiogroup, e.g. "Select floor". */
  label: string;
  levels: StoreyPoleLevel[];
  /** Controlled selected level id. */
  value?: string;
  /** Initial value when uncontrolled. @default the level at elevation 0, else the topmost level */
  defaultValue?: string;
  /** called with the new level id when the committed selection changes */
  onValueChange?: (id: string) => void;
  /** Shared `name` for the native radios. @default a generated id */
  name?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

interface Geom {
  level: StoreyPoleLevel;
  top: number;
  height: number;
  bottom: number;
}

interface SpringState {
  raf: number;
  last: number;
  y: number;
  vy: number;
  target: number;
  zeta: number;
  deadline: number;
}

const PX_PER_METRE = 15;
const BAR_WIDTH = 44;
const SPRING_K = 300; // rad^2/s^2, constant — only zeta varies with distance
const BASE_SETTLE_MS = 280;
const PER_FLOOR_SETTLE_MS = 45;
const MAX_SETTLE_MS = 640;
const MIN_ZETA = 0.28;
const ZETA_STEP = 0.11;
const PLAN_FADE_MS = 220;
const DEADBAND_FRACTION = 0.3;

function useReducedMotion(): boolean {
  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 elevationPhrase(elevation: number): string {
  if (elevation === 0) return "at ground level";
  const abs = Math.abs(elevation).toFixed(1);
  return elevation > 0 ? `${abs} m above ground` : `${abs} m below ground`;
}

function elevationShort(elevation: number): string {
  if (elevation === 0) return "±0.0 m";
  const sign = elevation > 0 ? "+" : "−";
  return `${sign}${Math.abs(elevation).toFixed(1)} m`;
}

function neighborName(g: Geom | undefined): string | undefined {
  if (!g) return undefined;
  return g.level.name ?? `Level ${g.level.label}`;
}

// --- deterministic per-level "plan" glyph -----------------------------------
// A tiny seeded PRNG (mulberry32, seeded from a FNV-1a hash of the level id)
// so each floor gets a stable, visually distinct abstract room layout across
// renders and reloads, with no per-render randomness.
function seedFromString(s: string): number {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}

function mulberry32(seed: number) {
  let a = seed;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function PlanGlyph({
  level,
  className,
}: {
  level: StoreyPoleLevel;
  className?: string;
}) {
  const rng = useMemo(() => mulberry32(seedFromString(level.id)), [level.id]);
  const cuts = useMemo(() => {
    const n = 2 + Math.floor(rng() * 3);
    const lines: { x1: number; y1: number; x2: number; y2: number }[] = [];
    for (let i = 0; i < n; i++) {
      if (rng() > 0.5) {
        const x = 14 + rng() * 68;
        lines.push({ x1: x, y1: 6, x2: x, y2: 66 });
      } else {
        const y = 12 + rng() * 48;
        lines.push({ x1: 6, y1: y, x2: 90, y2: y });
      }
    }
    return lines;
  }, [rng]);
  const core = useMemo(
    () => ({
      x: 10 + rng() * 58,
      y: 10 + rng() * 42,
      w: 10 + rng() * 12,
      h: 8 + rng() * 10,
    }),
    [rng]
  );
  return (
    <svg
      viewBox="0 0 96 72"
      className={className}
      aria-hidden="true"
      focusable="false"
    >
      <rect
        x={2}
        y={2}
        width={92}
        height={68}
        className="text-border"
        fill="none"
        stroke="currentColor"
        strokeWidth={1.5}
      />
      {cuts.map((l, i) => (
        <line
          key={i}
          x1={l.x1}
          y1={l.y1}
          x2={l.x2}
          y2={l.y2}
          className="text-border"
          stroke="currentColor"
          strokeWidth={1}
        />
      ))}
      <rect
        x={core.x}
        y={core.y}
        width={core.w}
        height={core.h}
        className="text-ns-muted"
        fill="currentColor"
        fillOpacity={0.28}
        stroke="currentColor"
        strokeWidth={1}
      />
    </svg>
  );
}

export function StoreyPole({
  label,
  levels,
  value,
  defaultValue,
  onValueChange,
  name,
  className = "",
}: StoreyPoleProps) {
  const generatedName = useId();
  const groupName = name ?? generatedName;
  const reducedMotion = useReducedMotion();
  const reducedMotionRef = useRef(reducedMotion);
  reducedMotionRef.current = reducedMotion;

  const sorted = useMemo(
    () => [...levels].sort((a, b) => b.elevation - a.elevation),
    [levels]
  );

  const geometry = useMemo<Geom[]>(() => {
    let acc = 0;
    return sorted.map((lvl) => {
      const px = Math.max(0, lvl.height) * PX_PER_METRE;
      const top = acc;
      acc += px;
      return { level: lvl, top, height: px, bottom: top + px };
    });
  }, [sorted]);

  const totalHeight = geometry.length
    ? geometry[geometry.length - 1].bottom
    : 0;

  const geomById = useMemo(
    () => new Map(geometry.map((g) => [g.level.id, g] as const)),
    [geometry]
  );
  const indexById = useMemo(
    () => new Map(sorted.map((l, i) => [l.id, i] as const)),
    [sorted]
  );

  const fallbackDefault = useMemo(() => {
    const ground = sorted.find((l) => l.elevation === 0);
    return ground?.id ?? sorted[0]?.id ?? "";
  }, [sorted]);

  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(
    () => defaultValue ?? fallbackDefault
  );
  const committedId = isControlled ? (value as string) : internal;

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

  // live preview id: mirrors committedId at rest, but tracks the
  // pointer-nearest slab during a drag scrub without committing.
  const [previewId, setPreviewId] = useState(committedId);
  const previewIdRef = useRef(previewId);
  const draggingRef = useRef(false);

  useEffect(() => {
    if (draggingRef.current) return;
    previewIdRef.current = committedId;
    setPreviewId(committedId);
  }, [committedId]);

  // --- datum line spring -----------------------------------------------
  const lineRef = useRef<HTMLDivElement | null>(null);
  const springRef = useRef<SpringState | null>(null);
  if (!springRef.current) {
    const y = geomById.get(committedId)?.bottom ?? 0;
    springRef.current = { raf: 0, last: 0, y, vy: 0, target: y, zeta: 1, deadline: 0 };
  }

  const applyTransform = useCallback((y: number) => {
    const el = lineRef.current;
    if (el) el.style.transform = `translateY(${y}px)`;
  }, []);

  const setLineImmediate = useCallback(
    (px: number) => {
      const s = springRef.current!;
      if (s.raf) cancelAnimationFrame(s.raf);
      s.raf = 0;
      s.y = px;
      s.vy = 0;
      s.target = px;
      applyTransform(px);
    },
    [applyTransform]
  );

  const loop = useCallback(
    (now: number) => {
      const s = springRef.current!;
      s.raf = 0;
      const dt = s.last ? Math.min(0.033, (now - s.last) / 1000) : 1 / 60;
      s.last = now;
      const c = 2 * s.zeta * Math.sqrt(SPRING_K);
      s.vy += (-SPRING_K * (s.y - s.target) - c * s.vy) * dt;
      s.y += s.vy * dt;
      const settled =
        now >= s.deadline || (Math.abs(s.y - s.target) < 0.3 && Math.abs(s.vy) < 4);
      if (settled) {
        s.y = s.target;
        s.vy = 0;
        applyTransform(s.y);
        return;
      }
      applyTransform(s.y);
      s.raf = requestAnimationFrame(loop);
    },
    [applyTransform]
  );

  const springTo = useCallback(
    (px: number, dist: number) => {
      if (reducedMotionRef.current) {
        setLineImmediate(px);
        return;
      }
      const s = springRef.current!;
      s.target = px;
      s.zeta = Math.max(MIN_ZETA, 1 - (dist - 1) * ZETA_STEP);
      s.deadline =
        performance.now() +
        Math.min(MAX_SETTLE_MS, BASE_SETTLE_MS + (dist - 1) * PER_FLOOR_SETTLE_MS);
      s.last = 0;
      if (!s.raf) s.raf = requestAnimationFrame(loop);
    },
    [loop, setLineImmediate]
  );

  useEffect(() => {
    return () => {
      const s = springRef.current;
      if (s?.raf) cancelAnimationFrame(s.raf);
    };
  }, []);

  // reposition (spring, or instantly under reduced motion) whenever the
  // COMMITTED value actually changes — via click, keyboard, drag-release,
  // or a controlled `value` prop update. `dist` is the number of floors
  // crossed since the last commit, which is what makes the overshoot scale
  // with distance rather than being a fixed constant.
  const prevIdxRef = useRef<number | null>(null);
  useEffect(() => {
    const idx = indexById.get(committedId);
    const g = geomById.get(committedId);
    if (idx === undefined || !g) return;
    if (prevIdxRef.current === null) {
      setLineImmediate(g.bottom);
      prevIdxRef.current = idx;
      return;
    }
    if (prevIdxRef.current === idx) return;
    const dist = Math.max(1, Math.abs(idx - prevIdxRef.current));
    prevIdxRef.current = idx;
    springTo(g.bottom, dist);
  }, [committedId, indexById, geomById, springTo, setLineImmediate]);

  // --- drag / scrub -------------------------------------------------------
  const sectionRef = useRef<HTMLDivElement | null>(null);

  const nearestGeom = useCallback(
    (px: number): Geom | undefined => {
      let best: Geom | undefined;
      let bestD = Infinity;
      for (const g of geometry) {
        const d = Math.abs(g.bottom - px);
        if (d < bestD) {
          bestD = d;
          best = g;
        }
      }
      return best;
    },
    [geometry]
  );

  const updateFromClientY = useCallback(
    (clientY: number) => {
      const el = sectionRef.current;
      if (!el || geometry.length === 0) return;
      const rect = el.getBoundingClientRect();
      const raw = Math.min(totalHeight, Math.max(0, clientY - rect.top));
      const nearest = nearestGeom(raw);
      if (!nearest) return;
      const deadband = nearest.height * DEADBAND_FRACTION;
      const within = Math.abs(raw - nearest.bottom) < deadband / 2;
      setLineImmediate(within ? nearest.bottom : raw);
      previewIdRef.current = nearest.level.id;
      setPreviewId(nearest.level.id);
    },
    [geometry, totalHeight, nearestGeom, setLineImmediate]
  );

  const onPointerDown = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (geometry.length === 0) return;
      draggingRef.current = true;
      sectionRef.current?.setPointerCapture(e.pointerId);
      updateFromClientY(e.clientY);
    },
    [geometry.length, updateFromClientY]
  );

  const onPointerMove = useCallback(
    (e: React.PointerEvent<HTMLDivElement>) => {
      if (!draggingRef.current) return;
      updateFromClientY(e.clientY);
    },
    [updateFromClientY]
  );

  const endDrag = useCallback(() => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    const id = previewIdRef.current;
    const g = geomById.get(id);
    commit(id);
    if (g) springTo(g.bottom, 1);
  }, [geomById, commit, springTo]);

  // --- announcements --------------------------------------------------
  const [announcement, setAnnouncement] = useState("");
  const prevElevRef = useRef<number | null>(null);
  useEffect(() => {
    const g = geomById.get(committedId);
    if (!g) return;
    const elev = g.level.elevation;
    if (prevElevRef.current === null) {
      prevElevRef.current = elev;
      return;
    }
    const delta = elev - prevElevRef.current;
    prevElevRef.current = elev;
    if (delta === 0) {
      setAnnouncement(`Level ${g.level.label} selected`);
      return;
    }
    const dir = delta > 0 ? "up" : "down";
    setAnnouncement(`${dir} ${Math.abs(delta).toFixed(1)} m to Level ${g.level.label}`);
  }, [committedId, geomById]);

  // --- plan viewport crossfade -----------------------------------------
  const [outgoingLevel, setOutgoingLevel] = useState<StoreyPoleLevel | null>(null);
  const outgoingElRef = useRef<HTMLDivElement | null>(null);
  const lastPreviewIdRef = useRef(previewId);

  useEffect(() => {
    if (previewId === lastPreviewIdRef.current) return;
    const prevId = lastPreviewIdRef.current;
    lastPreviewIdRef.current = previewId;
    if (reducedMotion) {
      setOutgoingLevel(null);
      return;
    }
    setOutgoingLevel(geomById.get(prevId)?.level ?? null);
  }, [previewId, reducedMotion, geomById]);

  useEffect(() => {
    if (!outgoingLevel) return;
    const el = outgoingElRef.current;
    if (!el || typeof el.animate !== "function") {
      setOutgoingLevel(null);
      return;
    }
    const anim = el.animate([{ opacity: 1 }, { opacity: 0 }], {
      duration: PLAN_FADE_MS,
      easing: "ease-out",
      fill: "forwards",
    });
    const done = () =>
      setOutgoingLevel((cur) => (cur === outgoingLevel ? null : cur));
    anim.onfinish = done;
    anim.oncancel = done;
  }, [outgoingLevel]);

  if (geometry.length === 0) {
    return (
      <p className={`font-mono text-xs text-ns-muted ${className}`}>
        No levels to show.
      </p>
    );
  }

  const currentLevel = geomById.get(previewId)?.level ?? sorted[0];

  return (
    <div className={`ns-sp-root flex flex-wrap items-start gap-6 ${className}`}>
      <div
        ref={sectionRef}
        role="radiogroup"
        aria-label={label}
        className="ns-sp-section touch-none"
        style={{ height: totalHeight }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
      >
        {geometry.map((g, i) => {
          const lvl = g.level;
          const checked = lvl.id === committedId;
          const highlighted = lvl.id === previewId;
          const belowGrade = lvl.elevation < 0;

          const belowNeighbor = neighborName(geometry[i + 1]);
          const aboveNeighbor = neighborName(geometry[i - 1]);
          const neighborParts: string[] = [];
          if (belowNeighbor) neighborParts.push(`directly above ${belowNeighbor}`);
          if (aboveNeighbor) neighborParts.push(`below ${aboveNeighbor}`);
          const parts = [`Level ${lvl.label}`, elevationPhrase(lvl.elevation)];
          if (lvl.name) parts.push(lvl.name);
          let accLabel = parts.join(", ");
          if (neighborParts.length) accLabel += `; ${neighborParts.join(", ")}`;

          return (
            <div key={lvl.id} className="ns-sp-row" style={{ height: g.height }}>
              <input
                type="radio"
                className="ns-sp-input"
                name={groupName}
                value={lvl.id}
                checked={checked}
                onChange={() => commit(lvl.id)}
                aria-label={accLabel}
              />
              <span className="ns-sp-visual" data-checked={highlighted || undefined}>
                <span className="ns-sp-bar" data-checked={highlighted || undefined}>
                  {belowGrade && <span aria-hidden="true" className="ns-sp-hatch" />}
                </span>
                <span className="ns-sp-info">
                  <span className="ns-sp-code" data-checked={highlighted || undefined}>
                    {lvl.label}
                  </span>
                  <span className="ns-sp-elev">{elevationShort(lvl.elevation)}</span>
                  {lvl.name && <span className="ns-sp-name">{lvl.name}</span>}
                </span>
              </span>
            </div>
          );
        })}

        <div
          ref={lineRef}
          aria-hidden="true"
          className="ns-sp-datum"
          style={{ transform: `translateY(${springRef.current.y}px)` }}
        />
      </div>

      <div className="ns-sp-side min-w-[180px] max-w-[240px] flex-1">
        <div className="ns-sp-plan-stack">
          <PlanGlyph key={currentLevel.id} level={currentLevel} className="ns-sp-plan-glyph" />
          {outgoingLevel && (
            <div ref={outgoingElRef} className="ns-sp-plan-overlay">
              <PlanGlyph level={outgoingLevel} className="ns-sp-plan-glyph" />
            </div>
          )}
        </div>
        <div className="ns-sp-readout">
          <span className="ns-sp-readout-code">{currentLevel.label}</span>
          <span className="ns-sp-readout-elev">{elevationPhrase(currentLevel.elevation)}</span>
          {currentLevel.name && (
            <span className="ns-sp-readout-name">{currentLevel.name}</span>
          )}
        </div>
      </div>

      <div className="sr-only" role="status" aria-live="polite">
        {announcement}
      </div>

      <style>{`
        .ns-sp-section {
          position: relative;
          width: ${BAR_WIDTH + 8}px;
          min-width: max-content;
        }
        .ns-sp-row {
          position: relative;
        }
        .ns-sp-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-sp-visual {
          display: flex;
          align-items: stretch;
          height: 100%;
          border-radius: 4px;
        }
        .ns-sp-input:focus-visible + .ns-sp-visual {
          outline: 2px solid var(--ns-accent);
          outline-offset: 2px;
        }
        .ns-sp-bar {
          position: relative;
          flex: 0 0 ${BAR_WIDTH}px;
          width: ${BAR_WIDTH}px;
          box-sizing: border-box;
          border: 1px solid var(--border);
          background: var(--background);
          transition: background-color 160ms ease;
          overflow: hidden;
        }
        .ns-sp-bar[data-checked] {
          background: color-mix(in oklab, var(--foreground) 8%, transparent);
        }
        .ns-sp-row:hover .ns-sp-bar:not([data-checked]) {
          background: color-mix(in oklab, var(--foreground) 4%, transparent);
        }
        .ns-sp-hatch {
          position: absolute;
          inset: 0 auto 0 0;
          width: 9px;
          background-image: repeating-linear-gradient(
            45deg,
            var(--border) 0 1.5px,
            transparent 1.5px 5px
          );
        }
        .ns-sp-info {
          display: flex;
          align-items: center;
          gap: 8px;
          padding: 0 10px 0 12px;
          white-space: nowrap;
        }
        .ns-sp-code {
          font-family: var(--font-mono, monospace);
          font-size: 0.8125rem;
          font-weight: 500;
          color: var(--ns-muted);
          min-width: 1.5em;
          transition: color 160ms ease;
        }
        .ns-sp-code[data-checked] {
          color: var(--foreground);
          font-weight: 700;
        }
        .ns-sp-elev {
          font-family: var(--font-mono, monospace);
          font-size: 0.6875rem;
          color: var(--ns-muted);
          font-variant-numeric: tabular-nums;
        }
        .ns-sp-name {
          font-size: 0.75rem;
          color: var(--ns-muted);
        }
        .ns-sp-datum {
          position: absolute;
          left: 0;
          right: 0;
          top: -1px;
          height: 2px;
          background: var(--foreground);
          pointer-events: none;
          will-change: transform;
        }

        .ns-sp-side {
          display: flex;
          flex-direction: column;
          gap: 10px;
        }
        .ns-sp-plan-stack {
          position: relative;
          aspect-ratio: 4 / 3;
          width: 100%;
          border-radius: 8px;
          background: var(--background);
        }
        .ns-sp-plan-overlay {
          position: absolute;
          inset: 0;
        }
        .ns-sp-plan-glyph {
          position: absolute;
          inset: 0;
          width: 100%;
          height: 100%;
        }
        .ns-sp-readout {
          display: flex;
          flex-wrap: wrap;
          align-items: baseline;
          gap: 6px 8px;
        }
        .ns-sp-readout-code {
          font-family: var(--font-mono, monospace);
          font-size: 1.05rem;
          font-weight: 700;
          color: var(--foreground);
        }
        .ns-sp-readout-elev {
          font-family: var(--font-mono, monospace);
          font-size: 0.75rem;
          color: var(--ns-muted);
        }
        .ns-sp-readout-name {
          font-size: 0.8125rem;
          color: var(--ns-muted);
        }

        @media (prefers-reduced-motion: reduce) {
          .ns-sp-bar {
            transition: none;
          }
          .ns-sp-code {
            transition: none;
          }
        }
      `}</style>
    </div>
  );
}
Build spec

A floor/level switcher for indoor maps, office floorplan viewers, mall/airport/hospital wayfinders, or parking apps — anywhere a bare vertical list of B2/G/M/3 hides the fact that floors are not evenly spaced. STRUCTURE: real native input type=radio per level, one shared radiogroup, arrow-key roving and Tab order native to the browser; each radio's accessible name is generated, not authored: 'Level 3, 12.4 m above ground, Engineering; directly above Parking, below Food Court' — label, elevation phrase, optional name, and both immediate neighbors. GEOMETRY: each level supplies elevation (metres above ground, negative below grade) and height (storey height in metres). Slab pixel height = height x one shared px-per-metre scale used for every level in the drawing — the falsifiable constraint that forbids equal-height list rows: a double-height lobby renders roughly twice as tall as a normal floor, a short mezzanine renders shorter, because the drawing is a real section, not a styled list. Slabs are stacked contiguously in elevation order and never move; only the datum line moves. Below-grade slabs (elevation less than 0) carry a 45-degree --border hatch fill down their left margin, the standard section-drawing convention for earth, so 'below ground' reads before any label does. MECHANISM: one governing scalar, the selected level's elevation in metres, drives four things together: the datum line's y position, which slab is highlighted, the plan-viewport crossfade pairing, and the readout text. Selecting a level (click, keyboard, or a controlled value change) moves ONLY the datum line via an rAF mass-spring: stiffness is constant, damping ratio is derived from how many floors were crossed — one floor settles near-critically-damped in about 280ms with almost no overshoot, an eight-floor jump underdamps further and visibly overshoots before settling, because a fixed overshoot would make a 1-floor and an 8-floor jump feel identical. DRAG: pointer-down anywhere in the cross-section starts a continuous scrub — the line magnetizes to the nearest slab's datum with a deadband 30% of THAT slab's own height (snapping when close, free-following the pointer outside every deadband), and the nearest slab drives a live PREVIEW of the highlight, the plan glyph and the readout without calling onValueChange or moving the committed radio selection until pointer-up, when the nearest slab is committed and the line makes a small critically-damped settle correction. PLAN VIEWPORT: a small deterministic abstract floor-plan glyph (seeded per level id, so it's stable across renders, not random each time) crossfades to the previewed level's glyph on every change — outgoing glyph fades over 220ms via the Web Animations API while the incoming one is already in place underneath. ANNOUNCEMENTS: every committed change (never a drag preview) pushes a spoken elevation delta into an aria-live=polite region, e.g. 'up 3.6 m to Level 3'. REDUCED MOTION: the datum line repositions instantly with no spring, and the plan viewport swaps instantly with no crossfade; both remain fully functional. STYLING: the highlighted slab and its floor code step up from --ns-muted to --foreground; --ns-accent appears only as the keyboard focus-visible ring, never as a resting-state color. Pure DOM + SVG + CSS, no canvas — every color is one of --background --foreground --ns-muted --border --ns-accent, read as CSS custom properties already in scope so both themes restyle for free. Distinct from counterpoise-tiers: counterpoise-tiers stacks CONTENT by weight as an information hierarchy; StoreyPole stacks REAL BUILDING GEOMETRY, and the metre values behind every slab's proportion live in each radio's accessible name, not just in the pixels.

Props

PropTypeDefaultDescription
labelstringAccessible name for the radiogroup, e.g. "Select floor".
levelsStoreyPoleLevel[]
value?stringControlled selected level id.
defaultValue?stringInitial value when uncontrolled. @default the level at elevation 0, else the topmost level
onValueChange?(id: string) => voidcalled with the new level id when the committed selection changes
name?stringShared `name` for the native radios. @default a generated id
className?stringextra classes merged onto the rendered root element