Skip to main content

ns-ui

Pole Shy

Live cursor name-labels that repel each other like matching magnetic poles, so the person actually typing makes bystander labels visibly yield and drift back.

Usage

registry/core/pole-shy/demo.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { PoleShy, type PoleShyUser } from "./component";

// Four collaborators crowd one line (row 0) — close enough that their name
// labels would overlap without the repulsion physics. Two more sit far apart
// on a second line (row 1) as a quiet baseline: nothing to repel, so nothing
// moves. "You" is driven by real keydown events on the textarea below; the
// other row-0 peers run their own irregular burst/pause typing loops so more
// than one label can be dominant at once, never a fixed choreography. Kai
// leaves and Mo arrives partway through, exercising the live region's
// arrival/departure announcements.
const INITIAL_USERS: PoleShyUser[] = [
  { id: "you", name: "You", x: 0.4, row: 0, typing: false, section: "section 2" },
  { id: "priya", name: "Priya Nair", x: 0.46, row: 0, typing: false, section: "section 2" },
  { id: "theo", name: "Theo Voss", x: 0.52, row: 0, typing: false, section: "section 2" },
  { id: "sam", name: "Sam Ochieng", x: 0.58, row: 0, typing: false, section: "section 2" },
  { id: "wren", name: "Wren Delacroix", x: 0.15, row: 1, typing: false, section: "intro" },
  { id: "kai", name: "Kai Fujimori", x: 0.82, row: 1, typing: false, section: "intro" },
];

export default function PoleShyDemo() {
  const [users, setUsers] = useState<PoleShyUser[]>(INITIAL_USERS);
  const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const setTyping = (id: string, typing: boolean) => {
    setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, typing } : u)));
  };

  // "You": real keystrokes drive real typing state, with a short idle
  // timeout back to false — the component's own field-strength decay layers
  // on top of this, so precedence fades smoothly rather than snapping off.
  const handleKeyDown = () => {
    setTyping("you", true);
    if (idleTimer.current) clearTimeout(idleTimer.current);
    idleTimer.current = setTimeout(() => setTyping("you", false), 900);
  };

  useEffect(() => {
    const timers: Array<ReturnType<typeof setTimeout>> = [];
    let cancelled = false;

    function runPeer(id: string) {
      function burst() {
        if (cancelled) return;
        setTyping(id, true);
        const burstMs = 500 + Math.random() * 1100;
        timers.push(
          setTimeout(() => {
            if (cancelled) return;
            setTyping(id, false);
            timers.push(setTimeout(burst, 1200 + Math.random() * 2600));
          }, burstMs)
        );
      }
      timers.push(setTimeout(burst, 300 + Math.random() * 1200));
    }

    runPeer("priya");
    runPeer("theo");
    runPeer("sam");

    timers.push(
      setTimeout(() => {
        if (!cancelled) setUsers((prev) => prev.filter((u) => u.id !== "kai"));
      }, 7000)
    );
    timers.push(
      setTimeout(() => {
        if (!cancelled) {
          setUsers((prev) => [
            ...prev,
            { id: "mo", name: "Mo Abara", x: 0.5, row: 1, typing: false, section: "intro" },
          ]);
        }
      }, 9500)
    );

    return () => {
      cancelled = true;
      timers.forEach(clearTimeout);
      if (idleTimer.current) clearTimeout(idleTimer.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-16">
      <div className="w-full max-w-lg rounded-md border border-border bg-background p-5 shadow-sm">
        <h2 className="text-sm font-medium text-foreground">Section 2 — release notes</h2>
        <p className="mt-1 text-xs text-ns-muted">
          Four cursors crowd one line below. Type in the box — your label carries a stronger field
          while you&apos;re actively typing, and the others yield to make room.
        </p>

        <div className="mt-4">
          <PoleShy users={users} />
        </div>

        <textarea
          rows={2}
          placeholder="Type here — your caret label pushes the others aside"
          aria-label="Document body"
          onKeyDown={handleKeyDown}
          className="mt-4 w-full resize-none rounded-sm border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
        />
      </div>

      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / pole-shy — repulsion demonstrates precedence, decoration never does
      </p>
    </div>
  );
}

Component

registry/core/pole-shy/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// PoleShy — live cursor name-labels that behave as like-pole magnets. Every
// label carries the same polarity, so labels crowding one line shove each
// other apart along the baseline with inverse-square falloff, and whoever is
// actively typing carries a stronger field, so bystander labels yield and
// drift back. Dominance is expressed by who moves out of whose way, never by
// size, glow or color — the currently-typing label is styled identically to
// every other label.
//
// MECHANISM: users are grouped into "bands" by their integer `row` — only
// labels sharing a row ever interact, matching how carets on different lines
// of a real document never fight for the same space. Within a crowded band
// (>=2 members), a 30fps-throttled loop computes each user's field strength
// F = BASE * (1 + 2 * exp(-idleMs / DECAY_MS)) — 3x base the instant `typing`
// is true, decaying back to 1x base over ~1.4s of not typing, so dominance
// fades rather than snapping off. Every pair in the band repels with
// Fi*Fj/d^2 along the horizontal axis only; that repulsion is summed as an
// external force into a critically-damped spring pulling each label back
// toward its true anchor (offset 0), clamped to +/-48px, and written directly
// to a ref'd DOM node's transform each tick — refs-only, no per-frame
// setState. A hairline SVG leader always connects the label's current
// (possibly displaced) position to its true caret position, so the anchor is
// never ambiguous even when a label has moved.
//
// prefers-reduced-motion drops the spring entirely: crowded-band members
// render with zero horizontal displacement and instead stack vertically,
// each still leadered straight down to its own true caret. No physics loop
// runs at all in that mode.
// ---------------------------------------------------------------------------

export interface PoleShyUser {
  id: string;
  name: string;
  /** normalized 0..1 horizontal position of this user's true caret within the band */
  x: number;
  /** integer line/row index — the caret's true vertical band. Only users sharing a row repel each other. */
  row: number;
  /** true while this user is actively producing keystrokes right now */
  typing: boolean;
  /** optional human label of where they are, used only in the "is typing in ___" announcement */
  section?: string;
}

export interface PoleShyProps {
  users: PoleShyUser[];
  className?: string;
}

const ROW_HEIGHT = 64;
const LABEL_TOP_IN_ROW = 4;
const LABEL_H = 26;
const CARET_BOTTOM_GAP = 14;
const STACK_GAP = 32;

const MAX_OFFSET = 72;
const DECAY_MS = 1400;
const BASE_FIELD = 1;
const TYPING_MULT = 3;
// Hovering a label is this component's own way of asking "let me read that
// one" — it feeds the same field-strength mechanism typing does (so the
// separation is real physics, not a tooltip layer), decaying back on the
// same curve once the pointer leaves so it settles rather than snapping.
const HOVER_MULT = 5;
const SPRING_K = 46; // s^-2
const SPRING_ZETA = 0.85;
// Chosen so two idle (F=1) labels ~50px apart settle a few px off anchor,
// and a typing/hovered (F=3x) neighbour pushes that toward the clamp: at
// equilibrium offset ~= REPULSE_K * Fi*Fj / (SPRING_K * d^2). Raised
// alongside MAX_OFFSET so a crowded band of 4 long names can actually clear
// each other at full field strength instead of clamping short of it.
const REPULSE_K = 2_000_000;
const MIN_D = 24; // px, floor on pairwise distance so near-overlap can't slam the clamp in one tick
const MAX_VEL = 900; // px/s
const TICK_MS = 1000 / 30; // 30fps throttle

interface LabelPhysics {
  offset: number;
  vel: number;
}

interface BandInfo {
  size: number;
  stackIndex: number;
}

function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return "?";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

export function PoleShy({ users, className = "" }: PoleShyProps) {
  const [reducedMotion, setReducedMotion] = useState(false);
  const [size, setSize] = useState({ width: 0, height: 0 });
  const [announcement, setAnnouncement] = useState("");
  // Which label is currently under the pointer — the only thing this drives
  // is field strength (below) and paint order (so the revealed label draws
  // over its neighbours instead of losing a coin-flip on DOM order).
  const [hoveredId, setHoveredId] = useState<string | null>(null);

  const containerRef = useRef<HTMLDivElement | null>(null);
  const widthRef = useRef(0);
  const labelRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  const leaderRefs = useRef<Map<string, SVGLineElement>>(new Map());
  const physicsRef = useRef<Map<string, LabelPhysics>>(new Map());
  const lastTypingAtRef = useRef<Map<string, number>>(new Map());
  const lastHoverAtRef = useRef<Map<string, number>>(new Map());
  const hoveredIdRef = useRef<string | null>(null);
  hoveredIdRef.current = hoveredId;
  const usersRef = useRef<PoleShyUser[]>(users);
  usersRef.current = users;

  const prevAnnounceRef = useRef<Map<string, { name: string; typing: boolean }> | null>(null);

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

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      const box = entries[0]?.contentRect;
      if (!box) return;
      widthRef.current = box.width;
      setSize((prev) =>
        prev.width === box.width && prev.height === box.height
          ? prev
          : { width: box.width, height: box.height }
      );
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const maxRow = useMemo(() => users.reduce((m, u) => Math.max(m, u.row), 0), [users]);

  // Per-user band membership: how many users share its row, and (for the
  // reduced-motion stacked layout only) its order within that band.
  const bandInfo = useMemo(() => {
    const byRow = new Map<number, PoleShyUser[]>();
    for (const u of users) {
      const arr = byRow.get(u.row) ?? [];
      arr.push(u);
      byRow.set(u.row, arr);
    }
    const info = new Map<string, BandInfo>();
    for (const members of byRow.values()) {
      const sorted = [...members].sort((a, b) => a.x - b.x);
      sorted.forEach((u, i) => info.set(u.id, { size: members.length, stackIndex: i }));
    }
    return info;
  }, [users]);

  // Row geometry. Under reduced motion a crowded row grows tall enough to
  // hold its vertical stack instead of the labels ever moving horizontally;
  // every other row (and every row when motion is allowed) stays a fixed
  // ROW_HEIGHT since the physics displacement never leaves that band.
  const rowLayout = useMemo(() => {
    const bandSizeByRow = new Map<number, number>();
    for (const u of users) bandSizeByRow.set(u.row, (bandSizeByRow.get(u.row) ?? 0) + 1);
    const rowCount = maxRow + 1;
    const tops: number[] = [];
    const heights: number[] = [];
    let acc = 0;
    for (let r = 0; r < rowCount; r++) {
      const bandSize = bandSizeByRow.get(r) ?? 0;
      const h =
        reducedMotion && bandSize >= 2
          ? Math.max(ROW_HEIGHT, LABEL_TOP_IN_ROW + bandSize * STACK_GAP + LABEL_H)
          : ROW_HEIGHT;
      tops.push(acc);
      heights.push(h);
      acc += h;
    }
    return { tops, heights, total: acc || ROW_HEIGHT };
  }, [users, maxRow, reducedMotion]);

  function rowTop(row: number) {
    return rowLayout.tops[row] ?? 0;
  }
  function rowCaretY(row: number) {
    return rowTop(row) + (rowLayout.heights[row] ?? ROW_HEIGHT) - CARET_BOTTOM_GAP;
  }

  // Prune stale physics/decay state for users who have left, so a long-lived
  // session with churn doesn't grow these maps forever.
  useEffect(() => {
    const ids = new Set(users.map((u) => u.id));
    for (const id of physicsRef.current.keys()) {
      if (!ids.has(id)) physicsRef.current.delete(id);
    }
    for (const id of lastTypingAtRef.current.keys()) {
      if (!ids.has(id)) lastTypingAtRef.current.delete(id);
    }
    for (const id of lastHoverAtRef.current.keys()) {
      if (!ids.has(id)) lastHoverAtRef.current.delete(id);
    }
    setHoveredId((h) => (h && !ids.has(h) ? null : h));
  }, [users]);

  // ---- physics loop (skipped entirely under reduced motion) --------------
  useEffect(() => {
    if (reducedMotion) return;
    let raf = 0;
    let last = 0;
    let running = true;

    const cAnchor = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);

    function integrate(st: LabelPhysics, extraAccel: number, dt: number) {
      const accel = -SPRING_K * st.offset - cAnchor * st.vel + extraAccel;
      st.vel += accel * dt;
      if (st.vel > MAX_VEL) st.vel = MAX_VEL;
      else if (st.vel < -MAX_VEL) st.vel = -MAX_VEL;
      st.offset += st.vel * dt;
      if (st.offset > MAX_OFFSET) {
        st.offset = MAX_OFFSET;
        if (st.vel > 0) st.vel = 0;
      } else if (st.offset < -MAX_OFFSET) {
        st.offset = -MAX_OFFSET;
        if (st.vel < 0) st.vel = 0;
      }
    }

    function apply(id: string, offset: number, trueX: number) {
      const label = labelRefs.current.get(id);
      if (label) label.style.transform = `translateX(calc(-50% + ${offset.toFixed(2)}px))`;
      const leader = leaderRefs.current.get(id);
      if (leader) leader.setAttribute("x1", (trueX + offset).toFixed(2));
    }

    function step(now: number, dt: number) {
      const width = widthRef.current;
      if (!width) return;

      const byRow = new Map<number, PoleShyUser[]>();
      for (const u of usersRef.current) {
        const arr = byRow.get(u.row) ?? [];
        arr.push(u);
        byRow.set(u.row, arr);
      }

      for (const members of byRow.values()) {
        if (members.length < 2) {
          // Not (or no longer) crowded — relax any residual offset back to
          // its true anchor, spring only, no repulsion.
          for (const u of members) {
            const st = physicsRef.current.get(u.id);
            if (!st) continue;
            if (st.offset === 0 && st.vel === 0) continue;
            integrate(st, 0, dt);
            apply(u.id, st.offset, u.x * width);
          }
          continue;
        }

        // Field strength per member: 3x base the instant `typing` is true,
        // decaying back toward 1x base over DECAY_MS of not typing. Hovering
        // a label drives the same field through its own independent decay,
        // so "let me read that one" uses the identical repulsion mechanism
        // as "I'm typing here" rather than a second, unrelated affordance.
        const field = new Map<string, number>();
        const hovered = hoveredIdRef.current;
        for (const u of members) {
          const lastTyping = lastTypingAtRef.current.get(u.id) ?? -Infinity;
          if (u.typing) lastTypingAtRef.current.set(u.id, now);
          const typingIdle = u.typing ? 0 : now - lastTyping;
          const typingMult = 1 + (TYPING_MULT - 1) * Math.exp(-typingIdle / DECAY_MS);

          const isHovered = u.id === hovered;
          const lastHover = lastHoverAtRef.current.get(u.id) ?? -Infinity;
          if (isHovered) lastHoverAtRef.current.set(u.id, now);
          const hoverIdle = isHovered ? 0 : now - lastHover;
          const hoverMult = 1 + (HOVER_MULT - 1) * Math.exp(-hoverIdle / DECAY_MS);

          field.set(u.id, BASE_FIELD * Math.max(typingMult, hoverMult));
        }

        for (const u of members) {
          let st = physicsRef.current.get(u.id);
          if (!st) {
            st = { offset: 0, vel: 0 };
            physicsRef.current.set(u.id, st);
          }
          const xi = u.x * width + st.offset;
          let repel = 0;
          for (const v of members) {
            if (v.id === u.id) continue;
            const vst = physicsRef.current.get(v.id) ?? { offset: 0, vel: 0 };
            const xj = v.x * width + vst.offset;
            const raw = xi - xj;
            const d = Math.max(Math.abs(raw), MIN_D);
            const dir = raw === 0 ? (u.id < v.id ? 1 : -1) : Math.sign(raw);
            repel += (dir * REPULSE_K * field.get(u.id)! * field.get(v.id)!) / (d * d);
          }
          integrate(st, repel, dt);
          apply(u.id, st.offset, u.x * width);
        }
      }
    }

    function frame(now: number) {
      if (!running) return;
      raf = requestAnimationFrame(frame);
      if (document.hidden) return;
      if (last === 0) {
        last = now;
        return;
      }
      if (now - last < TICK_MS) return;
      const dt = Math.min((now - last) / 1000, 0.1);
      last = now;
      step(now, dt);
    }

    raf = requestAnimationFrame(frame);
    return () => {
      running = false;
      cancelAnimationFrame(raf);
    };
  }, [reducedMotion]);

  // ---- live-region announcements: state changes only, never motion -------
  useEffect(() => {
    const prev = prevAnnounceRef.current;
    const isFirst = prev === null;
    let msg = "";

    if (!isFirst) {
      const currentIds = new Set(users.map((u) => u.id));
      for (const [id, was] of prev) {
        if (!currentIds.has(id)) msg = `${was.name} left`;
      }
      for (const u of users) {
        const before = prev.get(u.id);
        if (!before) {
          msg = `${u.name} joined`;
          continue;
        }
        if (!before.typing && u.typing) {
          msg = u.section ? `${u.name} is typing in ${u.section}` : `${u.name} is typing`;
        }
      }
    }

    prevAnnounceRef.current = new Map(users.map((u) => [u.id, { name: u.name, typing: u.typing }]));
    if (!isFirst && msg) setAnnouncement(msg);
  }, [users]);

  return (
    <div
      ref={containerRef}
      className={["relative w-full overflow-hidden", className].join(" ")}
      style={{ height: rowLayout.total }}
    >
      <svg
        className="pointer-events-none absolute inset-0 h-full w-full"
        viewBox={`0 0 ${Math.max(size.width, 1)} ${Math.max(size.height, 1)}`}
        preserveAspectRatio="none"
        aria-hidden="true"
      >
        {users.map((u) => {
          const trueX = u.x * size.width;
          const caretY = rowCaretY(u.row);
          const info = bandInfo.get(u.id);
          const stacked = reducedMotion && info && info.size >= 2;
          const labelTopY = stacked
            ? rowTop(u.row) + LABEL_TOP_IN_ROW + info!.stackIndex * STACK_GAP
            : rowTop(u.row) + LABEL_TOP_IN_ROW;
          const labelBottomY = labelTopY + LABEL_H;
          return (
            <g key={u.id}>
              <line
                ref={(el) => {
                  if (el) leaderRefs.current.set(u.id, el);
                  else leaderRefs.current.delete(u.id);
                }}
                x1={trueX}
                y1={labelBottomY}
                x2={trueX}
                y2={caretY}
                stroke="var(--border)"
                strokeWidth={1}
                vectorEffect="non-scaling-stroke"
              />
              <circle cx={trueX} cy={caretY} r={3} fill="var(--foreground)" opacity={0.55} />
            </g>
          );
        })}
      </svg>

      {users.map((u) => {
        const info = bandInfo.get(u.id);
        const stacked = reducedMotion && info && info.size >= 2;
        const top = stacked
          ? rowTop(u.row) + LABEL_TOP_IN_ROW + info!.stackIndex * STACK_GAP
          : rowTop(u.row) + LABEL_TOP_IN_ROW;
        const isHovered = u.id === hoveredId;
        return (
          <div
            key={u.id}
            ref={(el) => {
              if (el) labelRefs.current.set(u.id, el);
              else labelRefs.current.delete(u.id);
            }}
            className="absolute flex items-center gap-1.5 rounded-full border border-border bg-background px-2 py-1 shadow-sm"
            onMouseEnter={() => setHoveredId(u.id)}
            onMouseLeave={() => setHoveredId((h) => (h === u.id ? null : h))}
            style={{
              left: `${u.x * 100}%`,
              top,
              transform: "translateX(-50%)",
              whiteSpace: "nowrap",
              // Hovering is this component's own reveal mechanism (stronger
              // field -> neighbours yield further, see the physics loop
              // above) — pulling the hovered chip's paint order above its
              // crowded neighbours is the other half: without it the label
              // could still lose an arbitrary DOM-order coin-flip against
              // an un-hovered sibling sitting closer to its own anchor.
              zIndex: isHovered ? 10 : 1,
            }}
          >
            <span className="rounded-full border border-border px-1 font-mono text-[9px] leading-[14px] text-ns-muted">
              {initials(u.name)}
            </span>
            <span className="text-xs text-foreground">{u.name}</span>
          </div>
        );
      })}

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

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

Use when multi-user text editing where several carets land close together on the same line and their name labels would otherwise overlap illegibly — repulsion keeps every label readable and, because the currently-typing person's field is 3x stronger and decays over ~1.4s of idle, WHICH collaborator is actively typing reads from who yields to whom rather than from any color or size change. Distinct from avatar-stack-flock, which flocks idle header avatars as a decorative boids sim with no connection to real document position; pole-shy's displacement is a caret-collision fix at the text itself, always leadered back to the true position, and never runs for a lone caret with nothing to repel.

Install

npx shadcn add https://design.helpmarq.com/r/pole-shy.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Live cursor name-labels that behave as like-pole magnets: every label carries the same polarity, so labels crowding one line shove each other apart along the baseline with inverse-square falloff, and the person currently typing carries a stronger field, so bystander labels yield and drift back — dominance is expressed by who moves out of whose way, never by size, glow or color; the currently-typing label is styled identically to every other label at all times. Props: `users` (required) is an array of {id, name, x (0..1 horizontal position of that user's TRUE caret within the container), row (integer line index — only users sharing a row ever interact, exactly like carets on different lines of a document never fighting for the same space), typing (boolean, true while that user is actively producing keystrokes), section? (optional human label used only in the typing announcement)}. MECHANISM: within a band of 2+ same-row users, a rAF loop throttled to 30fps computes each member's field strength F = 1 (base) times 3 while `typing` is true, or times 5 while the pointer is hovering that label (hover is meant to guarantee legibility on demand, so it's given a stronger pull than the ambient typing signal), each decaying independently back toward 1 over roughly 1.4s of measured idle/unhover time via the same exponential curve (1 + (mult-1)*e^(-idleMs/1400)) — hovering a crowded label is read by the physics exactly like that person typing: it, too, is a repulsion field, not a tooltip, it's just tuned stronger. Every pair in the band repels along the horizontal axis only with Fi*Fj/d^2 (d floored at 14px so near-overlap can't spike to infinity); that repulsion is summed as an external force into a critically-damped spring (k=46 s^-2, zeta=0.85) pulling each label back toward its true anchor at offset 0, clamped to +/-72px, integrated with semi-implicit Euler and written straight to a ref'd DOM node's `transform: translateX()` every tick — refs-only, no per-frame React state, so idle bands (fewer than two members sharing a row) cost nothing beyond a size check. The hovered label's own DOM node is also raised in paint order (z-index) above its band, so it draws over crowded neighbours rather than losing an arbitrary DOM-order tie — text is always in the accessibility tree regardless of paint order, so this is a purely visual legibility fix, not a semantics change. A hairline SVG line (stroke var(--border)) plus a small dot (fill var(--foreground)) always run from the label's current, possibly displaced position down to its true caret coordinate, so the anchor is never ambiguous even mid-displacement — a label can never be drawn somewhere its leader doesn't reconcile. Labels are bordered chips (border var(--border), background var(--background)) holding a small Geist Mono initials badge plus the collaborator's name as real, always-visible text — identity is carried by that text, not by color, and --ns-accent never touches a label. Under prefers-reduced-motion the spring is dropped entirely: crowded-row members render with zero horizontal displacement and instead stack vertically within a row that grows tall enough to hold them, each still leadered straight down to its own true caret — legible instantly, no motion required, and no physics loop runs at all in that mode. An adjacent role=status aria-live=polite region (visually sr-only) announces arrivals, departures, and '<name> is typing' (or '<name> is typing in <section>' when `section` is given) strictly on state change — never once per physics tick, since all repulsion is presentational and the leader guarantees the announced identity's visual position always matches its true one. Zero dependencies, DOM + SVG + CSS only, no canvas. Differs from avatar-stack-flock, which flocks a header's idle avatar row as decorative boids with no tie to document position: pole-shy only ever displaces to resolve a real collision between real caret positions, sleeps whenever a row has fewer than two members, and expresses 'who is typing' through motion deference rather than any static visual difference.

Props

PropTypeDefaultDescription
usersPoleShyUser[]——
className?string——