Skip to main content

ns-ui

Pin Register

A map layer panel drawn as registered acetate separations — each layer a translucent sheet in a shallow isometric stack that fans on hover, and turning one off parks it visibly off the pins instead of hiding it.

Use when a map/GIS layer panel (base, labels, traffic, boundaries, heat) where layer ORDER is part of the job, not just visibility — z-order is a draggable physical fact, and a disabled layer parks visibly (24px off the registration pins, dimmed) rather than vanishing, so the panel never loses the read on what's currently hidden from the map. Pick light-table instead for comparing the visual content of stacked film with no z-order or on/off semantics at all — that component is about looking, not compositing.

Install

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

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";

// ---------------------------------------------------------------------------
// PinRegister — a map layer panel drawn as registered acetate separations
// instead of a checkbox list. Every sheet's transform derives from ONE
// governing scalar x (0 = flat pile, 1 = fully fanned), driven by hover/focus
// anywhere in the panel: translateY = index * 14px * x, inside a single
// shared 2-axis skew on the stack wrapper (the isometric tilt) — no
// per-sheet rotation, one skew and one scalar is the whole motion budget.
// Two pin dots are drawn once, fixed in the panel, never moved by x or by
// toggling — that fixed screen position IS the registration: every ACTIVE
// sheet's translateX stays 0 at any explode value, literally locked to the
// pins. Toggling a layer off does not fade it out (an invisible overlay is a
// checkbox, not a separation) — it translates the sheet 24px right, off the
// pin line, to a parked slot at 35% opacity, still fully legible and still
// occupying its place in the fan. One `order` array is both the visual stack
// order and the draw order of the composite preview beneath it, so the two
// can never disagree. Reordering — drag on the sheet itself, or Alt+Arrow —
// reshuffles that one array; every sheet (dragged or not) is a continuous
// spring toward its live target, so a drag-in-progress previews the reflow
// before it commits. Reduced motion pins x at 0 (sheets sit flush, no fan)
// and parked sheets simply offset with no animation — the isometric read is
// pure presentation, never load-bearing. DOM + SVG + CSS only, no canvas.
// Distinct from light-table: that component stacks film to visually compare
// content; this one treats z-order as a draggable physical fact and keeps a
// disabled layer parked-but-visible rather than gone, so you never lose the
// read on what you're missing.
// ---------------------------------------------------------------------------

export interface PinRegisterLayer {
  id: string;
  label: string;
  /** initial visibility; defaults true */
  active?: boolean;
  /** small line-work swatch for this layer — stroke="currentColor", viewBox 0 0 120 80 */
  swatch: ReactNode;
}

export interface PinRegisterProps {
  layers: PinRegisterLayer[];
  /** fires with the active layer ids, bottom-to-top draw order, on any toggle or reorder */
  onChange?: (activeIds: string[]) => void;
  className?: string;
  "aria-label"?: string;
}

const STEP_Y = 14; // px per stack index, scaled by explode x
const PARK_X = 24; // px a parked sheet sits off the pin line
const PARK_OPACITY = 0.35;
const DRAG_THRESHOLD = 4; // px of travel before a pointerdown becomes a live drag

const X_K = 190;
const X_C = 2 * 0.92 * Math.sqrt(X_K); // near-critical — explode settles without overshoot
const Y_K = 240;
const Y_C = 2 * 0.86 * Math.sqrt(Y_K); // slight underdamp — the "200ms spring" reorder feel
const LERP_RATE = 11; // 1/s, for parkX + opacity — simple exponential ease, not a spring
const REST_EPS = 0.05;
const REST_VEPS = 0.4;

type Phys = { y: number; vy: number; parkX: number; opacity: number };

type DragState = {
  id: string;
  pointerId: number;
  startClientY: number;
  startY: number;
  followY: number;
  active: boolean;
  baseOrder: string[];
  insertionIndex: number;
};

function PinIcon() {
  return (
    <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true" focusable="false">
      <circle cx="5" cy="5" r="3.4" fill="var(--background)" stroke="var(--ns-muted)" strokeWidth="1.4" />
      <circle cx="5" cy="5" r="1.1" fill="var(--ns-muted)" />
    </svg>
  );
}

function GripGlyph() {
  return (
    <svg width="8" height="14" viewBox="0 0 8 14" aria-hidden="true" focusable="false" className="shrink-0 opacity-60">
      <circle cx="1.5" cy="2" r="1" fill="currentColor" />
      <circle cx="6.5" cy="2" r="1" fill="currentColor" />
      <circle cx="1.5" cy="7" r="1" fill="currentColor" />
      <circle cx="6.5" cy="7" r="1" fill="currentColor" />
      <circle cx="1.5" cy="12" r="1" fill="currentColor" />
      <circle cx="6.5" cy="12" r="1" fill="currentColor" />
    </svg>
  );
}

export function PinRegister({
  layers,
  onChange,
  className = "",
  "aria-label": ariaLabel = "Map layers",
}: PinRegisterProps) {
  const layersById = useMemo(() => {
    const m = new Map<string, PinRegisterLayer>();
    for (const l of layers) m.set(l.id, l);
    return m;
  }, [layers]);

  const [order, setOrder] = useState<string[]>(() => layers.map((l) => l.id));
  const [active, setActive] = useState<Record<string, boolean>>(() =>
    Object.fromEntries(layers.map((l) => [l.id, l.active !== false]))
  );
  const [hovered, setHovered] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);
  const [reduced, setReduced] = useState(false);
  const [draggingId, setDraggingId] = useState<string | null>(null);
  const [liveMsg, setLiveMsg] = useState("");

  const orderRef = useRef(order);
  orderRef.current = order;
  const activeRef = useRef(active);
  activeRef.current = active;
  const reducedRef = useRef(false);
  reducedRef.current = reduced;
  const hoverOrFocusRef = useRef(false);
  hoverOrFocusRef.current = hovered || focusWithin;
  const firstChange = useRef(true);

  const panelRef = useRef<HTMLDivElement>(null);
  const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const physRef = useRef<Map<string, Phys>>(new Map());
  const xRef = useRef({ v: 0, dv: 0 });
  const dragRef = useRef<DragState | null>(null);
  const suppressClickRef = useRef(false);
  const rafRef = useRef(0);
  const lastFrameRef = useRef(0);

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

  useEffect(() => {
    if (firstChange.current) {
      firstChange.current = false;
      return;
    }
    onChange?.(order.filter((id) => active[id]));
  }, [order, active, onChange]);

  const writeRow = useCallback((id: string, idx: number, p: Phys) => {
    const el = rowRefs.current.get(id);
    if (!el) return;
    el.style.transform = `translate3d(${p.parkX.toFixed(2)}px, ${p.y.toFixed(2)}px, 0)`;
    el.style.opacity = p.opacity.toFixed(3);
    el.style.zIndex = String(100 - idx);
  }, []);

  const ensureLoop = useCallback(() => {
    if (rafRef.current) return;
    lastFrameRef.current = 0;
    rafRef.current = requestAnimationFrame(loop);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- loop is a stable module-scoped function below
  }, []);

  // eslint-disable-next-line react-hooks/exhaustive-deps -- loop reads refs, deps stable across renders
  function loop(now: number) {
    rafRef.current = 0;
    const dt = lastFrameRef.current ? Math.min(0.032, (now - lastFrameRef.current) / 1000) : 1 / 60;
    lastFrameRef.current = now;

    const phys = physRef.current;
    let settled = true;

    // one governing scalar: the panel's explode factor
    const xTarget = reducedRef.current ? 0 : hoverOrFocusRef.current ? 1 : 0;
    const xs = xRef.current;
    if (reducedRef.current) {
      xs.v = 0;
      xs.dv = 0;
    } else {
      const ax = -X_K * (xs.v - xTarget) - X_C * xs.dv;
      xs.dv += ax * dt;
      xs.v += xs.dv * dt;
      if (Math.abs(xs.v - xTarget) > 0.0015 || Math.abs(xs.dv) > 0.01) settled = false;
      else {
        xs.v = xTarget;
        xs.dv = 0;
      }
    }
    const x = xs.v;

    const drag = dragRef.current;
    const liveOrder =
      drag && drag.active
        ? (() => {
            const rest = drag.baseOrder.filter((id) => id !== drag.id);
            const clamped = Math.min(rest.length, Math.max(0, drag.insertionIndex));
            rest.splice(clamped, 0, drag.id);
            return rest;
          })()
        : orderRef.current;

    liveOrder.forEach((id, idx) => {
      let p = phys.get(id);
      if (!p) {
        p = { y: idx * STEP_Y, vy: 0, parkX: activeRef.current[id] ? 0 : PARK_X, opacity: activeRef.current[id] ? 1 : PARK_OPACITY };
        phys.set(id, p);
      }

      const isDragged = !!drag && drag.active && drag.id === id;
      const targetParkX = activeRef.current[id] ? 0 : PARK_X;
      const targetOpacity = activeRef.current[id] ? 1 : PARK_OPACITY;

      if (reducedRef.current) {
        p.y = isDragged ? drag!.followY : idx * STEP_Y * x; // x is 0 under reduced motion
        p.parkX = targetParkX;
        p.opacity = targetOpacity;
        p.vy = 0;
      } else {
        if (isDragged) {
          p.y = drag!.followY;
          p.vy = 0;
        } else {
          const targetY = idx * STEP_Y * x;
          const ay = -Y_K * (p.y - targetY) - Y_C * p.vy;
          p.vy += ay * dt;
          p.y += p.vy * dt;
          if (Math.abs(p.y - targetY) > REST_EPS || Math.abs(p.vy) > REST_VEPS) settled = false;
        }
        const lerp = 1 - Math.exp(-LERP_RATE * dt);
        p.parkX += (targetParkX - p.parkX) * lerp;
        p.opacity += (targetOpacity - p.opacity) * lerp;
        if (Math.abs(p.parkX - targetParkX) > 0.05 || Math.abs(p.opacity - targetOpacity) > 0.002) settled = false;
      }

      writeRow(id, idx, p);
    });

    if (!settled || (drag && drag.active)) {
      rafRef.current = requestAnimationFrame(loop);
    }
  }

  // seed + write synchronously before paint so rows don't sit at (0,0) for a frame
  useLayoutEffect(() => {
    const phys = physRef.current;
    order.forEach((id, idx) => {
      if (!phys.has(id)) {
        phys.set(id, { y: idx * STEP_Y, vy: 0, parkX: active[id] ? 0 : PARK_X, opacity: active[id] ? 1 : PARK_OPACITY });
      }
      writeRow(id, idx, phys.get(id)!);
    });
    for (const id of Array.from(phys.keys())) {
      if (!layersById.has(id)) phys.delete(id);
    }
    ensureLoop();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [order, active, layersById, writeRow, ensureLoop]);

  useEffect(() => {
    ensureLoop();
  }, [hovered, focusWithin, reduced, ensureLoop]);

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

  const toggle = useCallback(
    (id: string) => {
      const label = layersById.get(id)?.label ?? "";
      setActive((prev) => {
        const next = { ...prev, [id]: !prev[id] };
        setLiveMsg(next[id] ? `${label} layer on.` : `${label} layer off, parked.`);
        return next;
      });
      ensureLoop();
    },
    [layersById, ensureLoop]
  );

  const reorder = useCallback(
    (id: string, dir: -1 | 1) => {
      const cur = orderRef.current;
      const idx = cur.indexOf(id);
      const swapWith = idx + dir;
      if (swapWith < 0 || swapWith >= cur.length) return;
      const next = cur.slice();
      next[idx] = cur[swapWith];
      next[swapWith] = cur[idx];
      const neighborLabel = layersById.get(cur[swapWith])?.label ?? "";
      const label = layersById.get(id)?.label ?? "";
      setOrder(next);
      setLiveMsg(
        `${label} moved ${dir < 0 ? "above" : "below"} ${neighborLabel}, position ${swapWith + 1} of ${cur.length}.`
      );
      ensureLoop();
    },
    [layersById, ensureLoop]
  );

  const beginDrag = useCallback((id: string, e: ReactPointerEvent<HTMLButtonElement>) => {
    const startY = physRef.current.get(id)?.y ?? 0;
    dragRef.current = {
      id,
      pointerId: e.pointerId,
      startClientY: e.clientY,
      startY,
      followY: startY,
      active: false,
      baseOrder: orderRef.current.slice(),
      insertionIndex: orderRef.current.indexOf(id),
    };
  }, []);

  const moveDrag = useCallback(
    (id: string, e: ReactPointerEvent<HTMLButtonElement>) => {
      const d = dragRef.current;
      if (!d || d.id !== id || d.pointerId !== e.pointerId) return;
      const deltaY = e.clientY - d.startClientY;

      if (!d.active && Math.abs(deltaY) > DRAG_THRESHOLD) {
        d.active = true;
        suppressClickRef.current = true;
        setDraggingId(id);
        e.currentTarget.setPointerCapture(e.pointerId);
        ensureLoop();
      }
      if (!d.active) return;

      d.followY = d.startY + deltaY;

      // rect-based hit test: how many other rows currently sit above the pointer
      const others = d.baseOrder.filter((oid) => oid !== id);
      let insertionIndex = others.length;
      for (let i = 0; i < others.length; i++) {
        const el = rowRefs.current.get(others[i]);
        if (!el) continue;
        const rect = el.getBoundingClientRect();
        if (e.clientY < rect.top + rect.height / 2) {
          insertionIndex = i;
          break;
        }
      }
      d.insertionIndex = insertionIndex;
      ensureLoop();
    },
    [ensureLoop]
  );

  const endDrag = useCallback(
    (id: string, e: ReactPointerEvent<HTMLButtonElement>) => {
      const d = dragRef.current;
      if (!d || d.id !== id || d.pointerId !== e.pointerId) return;
      if (e.currentTarget.hasPointerCapture(e.pointerId)) {
        e.currentTarget.releasePointerCapture(e.pointerId);
      }
      dragRef.current = null;
      setDraggingId(null);
      if (!d.active) return;

      const rest = d.baseOrder.filter((oid) => oid !== id);
      const clamped = Math.min(rest.length, Math.max(0, d.insertionIndex));
      rest.splice(clamped, 0, id);
      setOrder(rest);
      const idx = rest.indexOf(id);
      const label = layersById.get(id)?.label ?? "";
      setLiveMsg(`${label}, now at position ${idx + 1} of ${rest.length}.`);
      ensureLoop();
    },
    [ensureLoop, layersById]
  );

  const onRowKeyDown = useCallback(
    (id: string, e: ReactKeyboardEvent<HTMLButtonElement>) => {
      if (!e.altKey) return;
      if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
      e.preventDefault();
      reorder(id, e.key === "ArrowUp" ? -1 : 1);
    },
    [reorder]
  );

  const onClick = useCallback(
    (id: string) => {
      if (suppressClickRef.current) {
        suppressClickRef.current = false;
        return;
      }
      toggle(id);
    },
    [toggle]
  );

  const panelHeight = Math.max(48, layers.length * STEP_Y + 44);

  return (
    <div
      className={`flex flex-col gap-4 sm:flex-row sm:items-start ${className}`}
      onPointerEnter={() => setHovered(true)}
      onPointerLeave={() => setHovered(false)}
      onFocus={() => setFocusWithin(true)}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
      }}
    >
      <div aria-live="polite" role="status" className="sr-only">
        {liveMsg}
      </div>

      <div
        ref={panelRef}
        data-pr-panel
        role="list"
        aria-label={ariaLabel}
        className="relative flex-1 select-none rounded-md border border-border bg-background p-4"
        style={{ height: panelHeight }}
      >
        <div
          className="relative h-full"
          style={{ transform: "skewX(-6deg) skewY(2deg)", transformOrigin: "8px 8px" }}
        >
          <div className="pointer-events-none absolute left-[15px] top-[7px] z-[200] flex gap-6">
            <PinIcon />
            <PinIcon />
          </div>

          {order.map((id, idx) => {
            const layer = layersById.get(id);
            if (!layer) return null;
            const isActive = !!active[id];
            const isDragging = draggingId === id;
            const pos = order.indexOf(id) + 1;
            return (
              <div
                key={id}
                ref={(el) => {
                  if (el) rowRefs.current.set(id, el);
                  else rowRefs.current.delete(id);
                }}
                role="listitem"
                className="absolute left-6 right-2 top-1 will-change-transform"
                style={{ height: 40 }}
              >
                <button
                  type="button"
                  data-pr-sheet
                  data-pr-row={idx}
                  aria-pressed={isActive}
                  aria-label={`${layer.label} layer, ${isActive ? "visible" : "off, parked"}. Position ${pos} of ${order.length}.`}
                  onPointerDown={(e) => beginDrag(id, e)}
                  onPointerMove={(e) => moveDrag(id, e)}
                  onPointerUp={(e) => endDrag(id, e)}
                  onPointerCancel={(e) => endDrag(id, e)}
                  onClick={() => onClick(id)}
                  onKeyDown={(e) => onRowKeyDown(id, e)}
                  className={`flex h-full w-full cursor-grab touch-none items-center gap-2 rounded-md border bg-surface/90 px-2.5 text-left shadow-[0_3px_10px_rgba(0,0,0,0.16)] transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent active:cursor-grabbing ${
                    isDragging ? "border-ns-accent" : "border-border hover:border-foreground/30"
                  }`}
                >
                  <GripGlyph />
                  <span className={isActive ? "text-ns-muted" : "text-ns-muted/70"}>{layer.swatch}</span>
                  <span
                    className={`min-w-0 flex-1 truncate text-xs font-medium ${
                      isActive ? "text-foreground" : "text-ns-muted"
                    }`}
                  >
                    {layer.label}
                  </span>
                  {!isActive ? (
                    <span className="shrink-0 rounded-full border border-border bg-background px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-ns-muted">
                      parked
                    </span>
                  ) : null}
                  <span className="shrink-0 rounded-full border border-border bg-background px-1.5 py-0.5 font-mono text-[9px] text-ns-muted">
                    {pos}
                  </span>
                </button>
              </div>
            );
          })}
        </div>
      </div>

      <div
        className="flex shrink-0 flex-col gap-2 sm:w-32"
        aria-hidden="true"
      >
        <p className="font-mono text-[10px] uppercase tracking-wide text-ns-muted">Composite</p>
        <div className="relative aspect-square w-full overflow-hidden rounded-md border border-border bg-background">
          {order.map((id, idx) => {
            const layer = layersById.get(id);
            if (!layer) return null;
            const isActive = !!active[id];
            // index 0 is the sheet nearest the viewer in the physical stack
            // (translateY pinned at 0) — it draws LAST here too, on top of
            // the composite, so the two orderings never disagree.
            return (
              <div
                key={id}
                className="absolute inset-0 flex items-center justify-center text-ns-muted transition-opacity duration-200"
                style={{ opacity: isActive ? 0.85 : 0, zIndex: order.length - idx }}
              >
                {layer.swatch}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}
Build spec

A map layer panel rendered as registered acetate separations instead of a checkbox list — the job a plain checkbox list hides is what layer ORDER even means. Mechanism: one governing scalar x, the panel's explode factor (0 = flat pile, 1 = fully fanned), driven by hover or focus landing anywhere in the panel (a damped spring, k=190 zeta=0.92, settles smoothly toward the target) — every sheet's transform derives from that single scalar: translateY = index * 14px * x. The whole stack sits inside one shared 2-axis CSS skew (skewX -6deg skewY 2deg) applied once to the stack wrapper for the isometric tilt; there is no per-sheet rotation, one skew plus one scalar is the entire motion budget. Two small pin-dot markers are drawn once, fixed in the panel (never repositioned by x, by toggling, or by reordering) — that fixed screen position IS the registration constraint: every ACTIVE sheet's translateX stays exactly 0 at any explode value, literally locked to the pins. Toggling a layer off never fades it to invisible (an overlay you can no longer see is a checkbox, not a separation) — it springs the sheet 24px right, off the pin line, into a parked slot at 35% opacity, still fully legible, still occupying its slot in the fan, tagged with a 'parked' badge. RENDERING: absolutely-positioned DOM rows inside one relatively-positioned, skewed stack container (no canvas); every row's transform (translateY, parkX, opacity) is written per-frame on a refs-only rAF loop — y is a real damped spring (k=240, zeta=0.86) so both the explode fan and any reorder read as one continuous physical settle, parkX/opacity are a simple exponential ease toward their target. ONE ARRAY: the same `order` array drives both the visual stack (translateY = index * 14 * x, z-index = higher for lower index — the sheet nearest the viewer occludes the ones behind it) and a small composite preview beside the panel, where only ACTIVE layers draw, in that same order, index 0 last (on top) — so the physical stack and the composited map can never disagree about what's showing or in what order. REORDER: drag directly on a sheet (4px threshold distinguishes a drag from a click-to-toggle) — the dragged sheet follows the pointer 1:1 on Y while every other row's target index is recomputed each move from a live rect-based hit test (comparing the pointer's clientY against each other row's current on-screen midpoint), so the reflow previews before the drop commits; releasing commits the order and every row springs the rest of the way. Alt+ArrowUp/ArrowDown on a focused sheet swaps it with its neighbor directly, no arm/drop step. A11Y: every sheet is one real `<button>` (`role=listitem` wrapper, panel is `role=list`) — `aria-pressed` carries visibility state, `aria-label` names the layer, its state, and its position ('Traffic layer, off, parked. Position 3 of 5.'); Space or a plain click toggles; a completed toggle or reorder announces via an `aria-live=polite` status region ('Traffic layer off, parked.', 'Labels moved above Boundaries, position 2 of 5.'). REDUCED MOTION: x is pinned at 0 (active sheets sit flush at the pins with no fan — the isometric read is pure presentation, never load-bearing) and parked sheets simply offset with no animation; dragging and Alt+Arrow reordering still work, they just commit their new position instantly instead of springing into it. Colors are --background/--foreground/--ns-muted/--border/--ns-accent only (--ns-accent appears only on focus-visible and on the sheet currently being dragged); DOM + SVG + CSS only, no canvas. Distinct from light-table: that component stacks film purely for visual comparison of content, with no z-order or on/off semantics; this one treats stack order as a draggable physical fact tied to real map draw order, and keeps a disabled layer parked-but-visible instead of gone.

Props

PropTypeDefaultDescription
layersPinRegisterLayer[]
onChange?(activeIds: string[]) => voidfires with the active layer ids, bottom-to-top draw order, on any toggle or reorder
className?string
aria-label?string"Map layers"