# Cohort — Cursor Effect for React

> Scripted multiplayer cursors over any content: named pointers glide on curved, slightly overshooting springs to percent or [data-cohort] targets, then click with ripples, drag selections, type comment bubbles and float emoji reactions — concurrently and on loop. Pauses politely when a real pointer enters, sleeps offscreen, and parks at first targets under reduced motion.

- Page: https://crucibleui.com/components/cursors/cohort
- Category: [cursors](https://crucibleui.com/components/cursors)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/cohort
```

Registry JSON: https://crucibleui.com/r/cohort.json

Import from `@/components/crucible/cohort`.

## Props (`CohortProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `React.ReactNode` | — | The content the cursors perform over. Tag targets with `data-cohort="name"`. |
| `cursors?` | `CohortCursor[]` | `three cursors on a percent-based script` | The scripted collaborators (2–4 reads best). |
| `loop?` | `boolean` | `true` | Restart each script when it ends. |
| `speedScale?` | `number` | `1` | Playback rate of every script. |
| `pauseOnPointer?` | `boolean` | `true` | When the visitor's pointer enters, cursors finish their step then idle; they resume 1.5s after it leaves. |
| `paused?` | `boolean` | `false` | Freeze the scripts where they are. |
| `ref?` | `React.Ref<CohortHandle>` | — | Imperative handle — `restart()`. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
import { Cohort, type CohortCursor } from "@/components/crucible/cohort";

const CURSORS: CohortCursor[] = [
  {
    name: "Mara",
    color: "#a5b4fc",
    script: [
      { type: "move", to: "title", anchor: [0.3, 0.5] },
      { type: "wait", duration: 300 },
      { type: "select", target: "title" },
      { type: "type", text: "Can we make this punchier?" },
      { type: "move", to: "note-a", anchor: [0.4, 0.6] },
      { type: "click" },
      { type: "react", emoji: "👍" },
      { type: "wait", duration: 1200 },
    ],
  },
  {
    name: "Jun",
    color: "#f9a8d4",
    script: [
      { type: "wait", duration: 500 },
      { type: "move", to: "image", anchor: [0.55, 0.45] },
      { type: "click" },
      { type: "wait", duration: 400 },
      { type: "type", text: "Swap for the wider crop" },
      { type: "react", emoji: "✨" },
      { type: "move", to: "line-3", anchor: [0.7, 0.5] },
      { type: "wait", duration: 1100 },
    ],
  },
  {
    name: "Ola",
    color: "#6ee7b7",
    script: [
      { type: "wait", duration: 1100 },
      { type: "move", to: "line-2", anchor: [0.2, 0.5] },
      { type: "select", target: "line-2" },
      { type: "wait", duration: 700 },
      { type: "move", to: "note-b", anchor: [0.5, 0.5] },
      { type: "react", emoji: "🎉" },
      { type: "wait", duration: 1400 },
    ],
  },
];

export default function CohortDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex w-full flex-col items-center gap-4 px-4 py-8">
        <Cohort cursors={CURSORS} className="w-full max-w-xl">
          <div className="rounded-2xl border border-white/10 bg-neutral-900/70 p-6 shadow-2xl">
            <div className="mb-5 flex items-center gap-1.5">
              <span className="size-2 rounded-full bg-white/15" />
              <span className="size-2 rounded-full bg-white/15" />
              <span className="size-2 rounded-full bg-white/15" />
              <span className="ml-3 h-2 w-24 rounded-full bg-white/10" />
            </div>
            <h3
              data-cohort="title"
              className="w-fit text-xl font-semibold tracking-tight text-white sm:text-2xl"
            >
              Quarterly planning notes
            </h3>
            <div className="mt-4 grid grid-cols-1 gap-5 sm:grid-cols-[1fr_170px]">
              <div className="flex flex-col gap-2.5">
                <div data-cohort="line-1" className="h-2.5 w-full rounded-full bg-white/15" />
                <div data-cohort="line-2" className="h-2.5 w-[88%] rounded-full bg-white/15" />
                <div data-cohort="line-3" className="h-2.5 w-[64%] rounded-full bg-white/15" />
                <div className="mt-3 flex gap-3">
                  <div
                    data-cohort="note-a"
                    className="h-16 flex-1 -rotate-2 rounded-md bg-amber-200/85 p-2 text-[11px] font-medium text-amber-950"
                  >
                    Ship by Friday?
                  </div>
                  <div
                    data-cohort="note-b"
                    className="h-16 flex-1 rotate-1 rounded-md bg-sky-200/85 p-2 text-[11px] font-medium text-sky-950"
                  >
                    Needs a second pass
                  </div>
                </div>
              </div>
              <div
                data-cohort="image"
                className="relative h-32 overflow-hidden rounded-lg bg-gradient-to-br from-slate-600 via-slate-700 to-slate-900 sm:h-auto"
              >
                <div className="absolute left-5 top-5 size-7 rounded-full bg-white/25" />
                <div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-slate-950/80 to-transparent" />
                <div className="absolute -bottom-6 left-8 size-24 rotate-45 bg-slate-500/50" />
                <div className="absolute -bottom-10 right-2 size-28 rotate-45 bg-slate-400/30" />
              </div>
            </div>
          </div>
        </Cohort>
        <p className="text-xs text-neutral-500">Move your cursor in to pause them.</p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`cohort.tsx`)

```tsx
"use client";

import * as React from "react";
import { motion, motionValue, useSpring, type MotionValue } from "motion/react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

/** A point in percent of the container: `[x%, y%]`. */
export type CohortPoint = readonly [x: number, y: number];

/** A rectangle in percent of the container: `[x%, y%, width%, height%]`. */
export type CohortRect = readonly [x: number, y: number, width: number, height: number];

/**
 * Where a step points. Either percent coordinates, or a string: a bare name
 * (`"title"`) resolves to `[data-cohort="title"]` inside the children; anything
 * else is used as a CSS selector scoped to the children. Unresolvable targets
 * fall back to the container centre.
 */
export type CohortTarget = string | CohortPoint | CohortRect;

/** One step of a cursor's script. Durations are in ms (before `speedScale`). */
export type CohortStep =
  | {
      /** Glide to a target along a curved, slightly overshooting path. */
      type: "move";
      to: CohortTarget;
      /** Point within an element/rect target, as fractions `[0..1, 0..1]`. @default [0.5, 0.5] */
      anchor?: CohortPoint;
      /** Travel time. @default derived from distance (~500–1500ms) */
      duration?: number;
    }
  | {
      /** Press: the pointer dips and a ripple rings out in the cursor's color. */
      type: "click";
    }
  | {
      /** Travel to the target's top-left, then drag a translucent selection over it. */
      type: "select";
      target: string | CohortRect;
      /** Drag time. @default 700 */
      duration?: number;
    }
  | {
      /** A comment bubble beside the name pill types out `text`. */
      type: "type";
      text: string;
      /** Typing time. @default 55ms per character */
      duration?: number;
      /** How long the finished comment stays before the next step. @default 900 */
      hold?: number;
    }
  | {
      /** An emoji floats up from the pointer and fades. */
      type: "react";
      emoji: string;
    }
  | {
      /** Idle in place (with a subtle drift). */
      type: "wait";
      /** @default 900 */
      duration?: number;
    };

/** One scripted collaborator. */
export interface CohortCursor {
  /** Label shown in the name pill. */
  name: string;
  /** Pointer, pill, selection and ripple color (any CSS color). */
  color: string;
  /** Steps acted out in order; runs concurrently with the other cursors. */
  script: CohortStep[];
}

/** Imperative controls exposed via `ref`. */
export interface CohortHandle {
  /** Clear every selection/comment and replay all scripts from the intro. */
  restart: () => void;
}

export interface CohortProps
  extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /** The content the cursors perform over. Tag targets with `data-cohort="name"`. */
  children?: React.ReactNode;
  /** The scripted collaborators (2–4 reads best). @default three cursors on a percent-based script */
  cursors?: CohortCursor[];
  /** Restart each script when it ends. @default true */
  loop?: boolean;
  /** Playback rate of every script. @default 1 */
  speedScale?: number;
  /** When the visitor's pointer enters, cursors finish their step then idle; they resume 1.5s after it leaves. @default true */
  pauseOnPointer?: boolean;
  /** Freeze the scripts where they are. @default false */
  paused?: boolean;
  /** Imperative handle — `restart()`. */
  ref?: React.Ref<CohortHandle>;
}

const DEFAULT_CURSORS: CohortCursor[] = [
  {
    name: "Mara",
    color: "#a5b4fc",
    script: [
      { type: "move", to: [30, 28] },
      { type: "wait", duration: 400 },
      { type: "select", target: [16, 20, 42, 14] },
      { type: "type", text: "Tighten this intro?" },
      { type: "move", to: [66, 64] },
      { type: "click" },
      { type: "react", emoji: "👍" },
      { type: "wait", duration: 1100 },
    ],
  },
  {
    name: "Jun",
    color: "#f9a8d4",
    script: [
      { type: "wait", duration: 600 },
      { type: "move", to: [72, 30] },
      { type: "click" },
      { type: "wait", duration: 500 },
      { type: "move", to: [52, 72] },
      { type: "type", text: "Love this part" },
      { type: "react", emoji: "✨" },
      { type: "move", to: [38, 82] },
      { type: "wait", duration: 900 },
    ],
  },
  {
    name: "Ola",
    color: "#6ee7b7",
    script: [
      { type: "wait", duration: 1200 },
      { type: "move", to: [36, 62] },
      { type: "react", emoji: "🎉" },
      { type: "wait", duration: 700 },
      { type: "select", target: [56, 42, 30, 16] },
      { type: "wait", duration: 900 },
      { type: "move", to: [78, 78] },
      { type: "click" },
      { type: "wait", duration: 1200 },
    ],
  },
];

const INTRO_STAGGER = 0.35; // s between cursors fading in
const INTRO_FADE = 0.45; // s
const RESUME_DELAY = 1.5; // s after the real pointer leaves
const CLICK_MS = 320;
const PILL_SPRING = { stiffness: 170, damping: 22, mass: 0.9 };

// Underdamped spring step response (ζ≈0.68 → ~5% overshoot), normalized to s∈[0,1].
const ZETA = 0.68;
const OMEGA = 10;
const OMEGA_D = OMEGA * Math.sqrt(1 - ZETA * ZETA);
function springEase(s: number) {
  if (s >= 1) return 1;
  const e = Math.exp(-ZETA * OMEGA * s);
  return 1 - e * (Math.cos(OMEGA_D * s) + ((ZETA * OMEGA) / OMEGA_D) * Math.sin(OMEGA_D * s));
}
const easeInOut = (s: number) => (s < 0.5 ? 2 * s * s : 1 - Math.pow(-2 * s + 2, 2) / 2);
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/** Pill text color: white on dark hex fills, near-black otherwise (non-hex colors keep the dark default). */
function inkOn(color: string) {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
  if (!m) return "#0a0a0a";
  const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
  const [r, g, b] = [0, 2, 4].map((i) => {
    const c = parseInt(h.slice(i, i + 2), 16) / 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
  // Contrast vs white (1.05/(L+0.05)) beats contrast vs near-black ((L+0.05)/0.05) below L≈0.18.
  return lum < 0.18 ? "#ffffff" : "#0a0a0a";
}

function hashString(str: string) {
  let h = 2166136261;
  for (let i = 0; i < str.length; i++) {
    h ^= str.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;
  };
}

type Vec = { x: number; y: number };
type Box = { x: number; y: number; w: number; h: number };

// Internal ops: `select` expands to a move to the top-left + a drag.
type Op =
  | { k: "move"; to: CohortTarget; anchor: CohortPoint; dur?: number }
  | { k: "drag"; target: string | CohortRect; dur: number }
  | { k: "click" }
  | { k: "type"; text: string; dur: number; hold: number }
  | { k: "react"; emoji: string }
  | { k: "wait"; dur: number };

function compile(script: CohortStep[]): Op[] {
  const ops: Op[] = [];
  for (const s of script) {
    switch (s.type) {
      case "move":
        ops.push({ k: "move", to: s.to, anchor: s.anchor ?? [0.5, 0.5], dur: s.duration });
        break;
      case "select":
        ops.push({ k: "move", to: s.target, anchor: [0, 0] });
        ops.push({ k: "drag", target: s.target, dur: s.duration ?? 700 });
        break;
      case "click":
        ops.push({ k: "click" });
        break;
      case "type":
        ops.push({ k: "type", text: s.text, dur: s.duration ?? s.text.length * 55, hold: s.hold ?? 900 });
        break;
      case "react":
        ops.push({ k: "react", emoji: s.emoji });
        break;
      case "wait":
        ops.push({ k: "wait", dur: s.duration ?? 900 });
        break;
    }
  }
  return ops;
}

interface Runtime {
  x: MotionValue<number>;
  y: MotionValue<number>;
  scale: MotionValue<number>;
  opacity: MotionValue<number>;
  selX: MotionValue<number>;
  selY: MotionValue<number>;
  selW: MotionValue<number>;
  selH: MotionValue<number>;
  selO: MotionValue<number>;
  bubbleO: MotionValue<number>;
  text: MotionValue<string>;
  pillX?: MotionValue<number>;
  pillY?: MotionValue<number>;
  ops: Op[];
  seed: number;
  rng: () => number;
  phase: "intro" | "run" | "done";
  i: number;
  /** Current step has finished; waiting for permission to start the next. */
  pending: boolean;
  stepStart: number;
  base: Vec;
  from: Vec;
  to: Vec;
  ctrl: Vec;
  dur: number;
  selTarget: number;
  bubbleTarget: number;
  drift: number;
  driftPhase: number;
  gen: number;
  /** Last resolved destination spec, re-resolved on resize. */
  rest: { to: CohortTarget; anchor: CohortPoint } | null;
  selSpec: string | CohortRect | null;
}

interface Fx {
  id: number;
  kind: "ripple" | "react";
  x: number;
  y: number;
  color: string;
  emoji?: string;
}

function isRect(t: CohortTarget): t is CohortRect {
  return typeof t !== "string" && t.length === 4;
}

/**
 * Cohort — fake live collaboration for marketing heroes. A handful of named
 * pointers (arrow + name pill in their color) act out looping scripts over
 * the children: gliding along curved, slightly overshooting paths, clicking
 * with ripples, dragging translucent selections, typing comment bubbles and
 * floating emoji reactions. Scripts run concurrently; pointer positions are
 * driven by motion values (no per-frame React renders) and the name pill
 * trails on a softer spring. Targets resolve from percent coordinates or
 * `[data-cohort]` elements inside the children, measured relative to the
 * container and re-measured on resize. When the visitor's pointer enters,
 * cursors finish their current step and idle with a subtle drift, resuming
 * 1.5s after it leaves. The overlay is `aria-hidden` and pointer-transparent;
 * playback pauses offscreen and on hidden tabs. Under reduced motion the
 * cursors park at their first targets with their name pills — no motion.
 */
export function Cohort({
  children,
  className,
  cursors = DEFAULT_CURSORS,
  loop = true,
  speedScale = 1,
  pauseOnPointer = true,
  paused = false,
  ref,
  onPointerEnter,
  onPointerLeave,
  ...props
}: CohortProps) {
  const reducedMotion = useReducedMotion();
  const [fx, setFx] = React.useState<Fx[]>([]);
  const fxId = React.useRef(0);

  // Scripts are plain data: key on their content so inline literals don't reset playback.
  const cursorsKey = JSON.stringify(cursors);
  const scriptHash = hashString(cursorsKey);
  const runtimes = React.useMemo<Runtime[]>(
    () =>
      cursors.map((c, idx) => {
        const seed = hashString(`${c.name}:${idx}`);
        const rng = mulberry32(seed);
        return {
          x: motionValue(0),
          y: motionValue(0),
          scale: motionValue(1),
          opacity: motionValue(0),
          selX: motionValue(0),
          selY: motionValue(0),
          selW: motionValue(0),
          selH: motionValue(0),
          selO: motionValue(0),
          bubbleO: motionValue(0),
          text: motionValue(""),
          ops: compile(c.script),
          seed,
          rng,
          phase: "intro",
          i: 0,
          pending: true,
          stepStart: 0,
          base: { x: 0, y: 0 },
          from: { x: 0, y: 0 },
          to: { x: 0, y: 0 },
          ctrl: { x: 0, y: 0 },
          dur: 0,
          selTarget: 0,
          bubbleTarget: 0,
          drift: 1,
          driftPhase: rng() * Math.PI * 2,
          gen: 0,
          rest: null,
          selSpec: null,
        };
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [cursorsKey]
  );

  const live = React.useRef({
    clock: 0,
    real: 0,
    last: -1,
    inside: false,
    leftAt: -Infinity,
    size: { w: 0, h: 0 },
    gen: 0,
  });
  const opts = React.useRef({ loop, speedScale, pauseOnPointer, cursors, reducedMotion });
  opts.current = { loop, speedScale, pauseOnPointer, cursors, reducedMotion };

  const containerRef = useVisibilityPause<HTMLDivElement>((elapsed) => tick(elapsed), {
    paused: reducedMotion || paused,
  });

  // --- target resolution (container-relative px) -----------------------------
  const resolveBox = React.useCallback((t: CohortTarget): Box => {
    const { w, h } = live.current.size;
    if (typeof t !== "string") {
      if (isRect(t)) return { x: (t[0] / 100) * w, y: (t[1] / 100) * h, w: (t[2] / 100) * w, h: (t[3] / 100) * h };
      return { x: (t[0] / 100) * w, y: (t[1] / 100) * h, w: 0, h: 0 };
    }
    const root = containerRef.current;
    let el: Element | null = null;
    if (root) {
      const sel = /^[\w-]+$/.test(t) ? `[data-cohort="${t}"]` : t;
      try {
        el = root.querySelector(sel);
      } catch {
        el = null;
      }
    }
    if (!root || !el) return { x: w / 2, y: h / 2, w: 0, h: 0 };
    const rr = root.getBoundingClientRect();
    const er = el.getBoundingClientRect();
    return { x: er.left - rr.left, y: er.top - rr.top, w: er.width, h: er.height };
  }, [containerRef]);

  const resolvePoint = React.useCallback(
    (t: CohortTarget, anchor: CohortPoint): Vec => {
      const b = resolveBox(t);
      return { x: b.x + b.w * anchor[0], y: b.y + b.h * anchor[1] };
    },
    [resolveBox]
  );

  const spawn = React.useCallback((f: Omit<Fx, "id">) => {
    const id = ++fxId.current;
    setFx((prev) => [...prev.slice(-11), { ...f, id }]);
  }, []);

  const teleport = (r: Runtime, p: Vec) => {
    r.base = { ...p };
    r.x.jump(p.x);
    r.y.jump(p.y);
    r.pillX?.jump(p.x);
    r.pillY?.jump(p.y);
  };

  const clearMarks = (r: Runtime, hard: boolean) => {
    r.selTarget = 0;
    r.bubbleTarget = 0;
    if (hard) {
      r.selO.set(0);
      r.bubbleO.set(0);
      r.text.set("");
    }
  };

  const firstTarget = (r: Runtime) => {
    const first = r.ops.find((o) => o.k === "move") as Extract<Op, { k: "move" }> | undefined;
    return first ? { to: first.to, anchor: first.anchor } : null;
  };

  const measure = () => {
    const el = containerRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    live.current.size = { w: rect.width, h: rect.height };
  };

  /** Park every cursor at its first target, fully visible (reduced motion / paused-from-start frame). */
  const placeStatic = () => {
    measure();
    for (const r of runtimes) {
      const t = firstTarget(r);
      const p = t ? resolvePoint(t.to, t.anchor) : { x: live.current.size.w / 2, y: live.current.size.h / 2 };
      teleport(r, p);
      r.rest = t;
      r.opacity.set(1);
      r.scale.set(1);
      clearMarks(r, true);
      r.phase = "run";
      r.i = 0;
      r.pending = true;
      r.drift = 1;
    }
  };

  /** Fresh start: cursors wait offstage-adjacent, then fade in staggered and run. */
  const reset = () => {
    measure();
    const L = live.current;
    L.clock = 0;
    L.last = -1;
    for (const r of runtimes) {
      r.rng = mulberry32(r.seed);
      const t = firstTarget(r);
      const p = t ? resolvePoint(t.to, t.anchor) : { x: L.size.w / 2, y: L.size.h / 2 };
      const ang = r.rng() * Math.PI * 2;
      const dist = 60 + r.rng() * 70;
      teleport(r, {
        x: clamp(p.x + Math.cos(ang) * dist, 12, Math.max(12, L.size.w - 60)),
        y: clamp(p.y + Math.sin(ang) * dist, 12, Math.max(12, L.size.h - 40)),
      });
      r.rest = null;
      r.selSpec = null;
      r.opacity.set(0);
      r.scale.set(1);
      clearMarks(r, true);
      r.phase = "intro";
      r.i = 0;
      r.pending = true;
      r.drift = 1;
      r.gen = L.gen;
    }
    setFx([]);
  };

  const beginStep = (r: Runtime, now: number) => {
    const o = opts.current;
    if (r.i >= r.ops.length) {
      if (!o.loop || r.ops.length === 0) {
        r.phase = "done";
        return;
      }
      r.i = 0;
      clearMarks(r, false);
    }
    const op = r.ops[r.i];
    r.pending = false;
    r.stepStart = now;
    const color = o.cursors[runtimes.indexOf(r)]?.color ?? "#fff";
    switch (op.k) {
      case "move": {
        clearMarks(r, false);
        r.from = { ...r.base };
        r.to = resolvePoint(op.to, op.anchor);
        r.rest = { to: op.to, anchor: op.anchor };
        const dx = r.to.x - r.from.x;
        const dy = r.to.y - r.from.y;
        const dist = Math.hypot(dx, dy);
        const bend = dist * (0.12 + r.rng() * 0.16) * (r.rng() < 0.5 ? -1 : 1);
        const nx = dist > 0 ? -dy / dist : 0;
        const ny = dist > 0 ? dx / dist : 0;
        r.ctrl = { x: (r.from.x + r.to.x) / 2 + nx * bend, y: (r.from.y + r.to.y) / 2 + ny * bend };
        r.dur = op.dur ?? clamp(520 + dist * 0.9, 500, 1500);
        if (dist < 3) r.dur = Math.min(r.dur, 200);
        break;
      }
      case "drag": {
        const b = resolveBox(op.target);
        r.from = { x: b.x, y: b.y };
        r.to = { x: b.x + b.w, y: b.y + b.h };
        r.selSpec = op.target;
        r.rest = { to: op.target, anchor: [1, 1] };
        r.dur = op.dur;
        r.selX.set(b.x);
        r.selY.set(b.y);
        r.selW.set(0);
        r.selH.set(0);
        r.selO.set(0);
        r.selTarget = 1;
        break;
      }
      case "click":
        r.dur = CLICK_MS;
        spawn({ kind: "ripple", x: r.base.x, y: r.base.y, color });
        break;
      case "type":
        r.text.set("");
        r.bubbleTarget = 1;
        r.dur = op.dur + op.hold;
        break;
      case "react":
        r.dur = 520;
        spawn({ kind: "react", x: r.base.x, y: r.base.y, color, emoji: op.emoji });
        break;
      case "wait":
        r.dur = op.dur;
        break;
    }
  };

  function tick(elapsed: number) {
    const L = live.current;
    const o = opts.current;
    const dt = L.last < 0 ? 0 : clamp(elapsed - L.last, 0, 0.1);
    L.last = elapsed;
    L.real += dt;
    L.clock += dt * Math.max(0, o.speedScale);
    const now = L.clock;
    const smooth = 1 - Math.exp(-dt * 9);
    const canAdvance = !o.pauseOnPointer || (!L.inside && L.real - L.leftAt >= RESUME_DELAY);

    runtimes.forEach((r, idx) => {
      // Re-measure after a resize: snap parked cursors / retarget the live move.
      if (r.gen !== L.gen) {
        r.gen = L.gen;
        const op = r.ops[r.i];
        if (!r.pending && op?.k === "move") {
          r.to = resolvePoint(op.to, op.anchor);
        } else if (r.rest && !(op?.k === "drag" && !r.pending)) {
          r.base = resolvePoint(r.rest.to, r.rest.anchor);
        }
        if (r.selSpec && r.selTarget > 0 && !(op?.k === "drag" && !r.pending)) {
          const b = resolveBox(r.selSpec);
          r.selX.set(b.x);
          r.selY.set(b.y);
          r.selW.set(b.w);
          r.selH.set(b.h);
        }
      }

      if (r.phase === "intro") {
        const t = now - idx * INTRO_STAGGER;
        r.opacity.set(clamp(t / INTRO_FADE, 0, 1));
        if (t >= INTRO_FADE) r.phase = "run";
      }

      let moving = false;
      if (r.phase === "run") {
        if (r.pending && canAdvance) beginStep(r, now);
        const op = r.ops[r.i];
        if (r.phase === "run" && !r.pending && op) {
          const ms = (now - r.stepStart) * 1000;
          const s = r.dur > 0 ? ms / r.dur : 1;
          switch (op.k) {
            case "move": {
              moving = true;
              const p = springEase(Math.min(s, 1));
              const q = 1 - p;
              r.base =
                s >= 1
                  ? { ...r.to }
                  : {
                      x: q * q * r.from.x + 2 * q * p * r.ctrl.x + p * p * r.to.x,
                      y: q * q * r.from.y + 2 * q * p * r.ctrl.y + p * p * r.to.y,
                    };
              break;
            }
            case "drag": {
              moving = true;
              const e = easeInOut(clamp(s, 0, 1));
              r.base = { x: r.from.x + (r.to.x - r.from.x) * e, y: r.from.y + (r.to.y - r.from.y) * e };
              r.selW.set(Math.max(0, r.base.x - r.from.x));
              r.selH.set(Math.max(0, r.base.y - r.from.y));
              break;
            }
            case "click":
              r.scale.set(1 - 0.18 * Math.sin(Math.PI * clamp(s, 0, 1)));
              break;
            case "type": {
              const n = Math.floor(op.text.length * clamp(op.dur > 0 ? ms / op.dur : 1, 0, 1));
              if (r.text.get().length !== n) r.text.set(op.text.slice(0, n));
              break;
            }
            default:
              break;
          }
          if (s >= 1) {
            if (op.k === "click") r.scale.set(1);
            r.i += 1;
            r.pending = true;
            if (canAdvance) beginStep(r, now);
          }
        }
      }

      // Subtle human drift whenever the pointer isn't travelling.
      r.drift += ((moving ? 0 : 1) - r.drift) * (1 - Math.exp(-dt * 3));
      const ph = r.driftPhase;
      const dx = Math.sin(L.real * 0.9 + ph) * 3.2 * r.drift;
      const dy = Math.sin(L.real * 1.3 + ph * 1.7) * 2.4 * r.drift;
      r.x.set(r.base.x + dx);
      r.y.set(r.base.y + dy);

      r.selO.set(r.selO.get() + (r.selTarget - r.selO.get()) * smooth);
      const bo = r.bubbleO.get() + (r.bubbleTarget - r.bubbleO.get()) * smooth;
      r.bubbleO.set(bo);
      if (r.bubbleTarget === 0 && bo < 0.02 && r.text.get() !== "") r.text.set("");
    });
  }

  // Mount / script change / motion preference: lay out the opening frame.
  React.useLayoutEffect(() => {
    if (reducedMotion) placeStatic();
    else if (paused && live.current.clock === 0) placeStatic();
    else if (live.current.clock === 0) reset();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [runtimes, reducedMotion]);

  React.useEffect(() => {
    if (paused && !reducedMotion && live.current.clock === 0) placeStatic();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [paused]);

  // Container-relative measurement: re-measure on resize; ticks re-resolve targets.
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => {
      measure();
      live.current.gen += 1;
      if (opts.current.reducedMotion) placeStatic();
    });
    ro.observe(el);
    return () => ro.disconnect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [runtimes]);

  React.useImperativeHandle(
    ref,
    () => ({
      restart: () => {
        if (opts.current.reducedMotion) placeStatic();
        else reset();
      },
    }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [runtimes]
  );

  const removeFx = React.useCallback((id: number) => {
    setFx((prev) => prev.filter((f) => f.id !== id));
  }, []);

  return (
    <div
      ref={containerRef}
      data-crucible="cohort"
      className={cn("relative isolate", className)}
      onPointerEnter={(e) => {
        live.current.inside = true;
        onPointerEnter?.(e);
      }}
      onPointerLeave={(e) => {
        live.current.inside = false;
        live.current.leftAt = live.current.real;
        onPointerLeave?.(e);
      }}
      {...props}
    >
      {children}
      <div aria-hidden className="pointer-events-none absolute inset-0 z-30 select-none">
        {runtimes.map((r, i) => {
          const c = cursors[i];
          if (!c) return null;
          return (
            <motion.div
              key={`sel-${i}`}
              className="absolute left-0 top-0 rounded-[3px]"
              style={{
                x: r.selX,
                y: r.selY,
                width: r.selW,
                height: r.selH,
                opacity: r.selO,
                backgroundColor: `color-mix(in srgb, ${c.color} 16%, transparent)`,
                boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${c.color} 55%, transparent)`,
              }}
            />
          );
        })}
        {fx.map((f) =>
          f.kind === "ripple" ? (
            <motion.span
              key={f.id}
              className="absolute block size-9 rounded-full"
              style={{ left: f.x - 18, top: f.y - 18, boxShadow: `0 0 0 1.5px ${f.color}` }}
              initial={{ scale: 0.2, opacity: 0.9 }}
              animate={{ scale: 1.1, opacity: 0 }}
              transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
              onAnimationComplete={() => removeFx(f.id)}
            />
          ) : (
            <motion.span
              key={f.id}
              className="absolute block text-xl leading-none"
              style={{ left: f.x + 6, top: f.y - 22 }}
              initial={{ y: 0, opacity: 0, scale: 0.6 }}
              animate={{ y: -58, opacity: [0, 1, 1, 0], scale: [0.6, 1.15, 1, 0.95] }}
              transition={{ duration: 1.5, ease: "easeOut", times: [0, 0.18, 0.7, 1] }}
              onAnimationComplete={() => removeFx(f.id)}
            >
              {f.emoji}
            </motion.span>
          )
        )}
        {runtimes.map((r, i) => {
          const c = cursors[i];
          if (!c) return null;
          return <CohortPointer key={`ptr-${scriptHash}-${i}`} runtime={r} cursor={c} still={reducedMotion} />;
        })}
      </div>
    </div>
  );
}

function CohortPointer({ runtime: r, cursor, still }: { runtime: Runtime; cursor: CohortCursor; still: boolean }) {
  const springX = useSpring(r.x, PILL_SPRING);
  const springY = useSpring(r.y, PILL_SPRING);
  React.useLayoutEffect(() => {
    r.pillX = springX;
    r.pillY = springY;
    springX.jump(r.x.get());
    springY.jump(r.y.get());
    return () => {
      r.pillX = undefined;
      r.pillY = undefined;
    };
  }, [r, springX, springY]);

  const pillX = still ? r.x : springX;
  const pillY = still ? r.y : springY;

  return (
    <>
      <motion.div
        className="absolute left-0 top-0"
        style={{ x: pillX, y: pillY, opacity: r.opacity }}
      >
        <div className="absolute left-[13px] top-[19px] flex flex-col items-start gap-1">
          <span
            className="whitespace-nowrap rounded-full px-2 py-[3px] text-[11px] font-medium leading-none shadow-[0_2px_8px_rgba(0,0,0,0.35)]"
            style={{ backgroundColor: cursor.color, color: inkOn(cursor.color) }}
          >
            {cursor.name}
          </span>
          <motion.div
            className="w-max max-w-[190px] rounded-xl rounded-tl-sm border border-white/10 bg-neutral-900/95 px-2.5 py-1.5 text-xs leading-snug text-neutral-100 shadow-[0_6px_20px_rgba(0,0,0,0.45)]"
            style={{ opacity: r.bubbleO }}
          >
            <motion.span>{r.text}</motion.span>
            <span
              className="ml-px inline-block h-[0.9em] w-px translate-y-[0.12em] animate-pulse"
              style={{ backgroundColor: cursor.color }}
            />
          </motion.div>
        </div>
      </motion.div>
      <motion.div
        className="absolute left-0 top-0"
        style={{ x: r.x, y: r.y, scale: r.scale, opacity: r.opacity, transformOrigin: "0px 0px" }}
      >
        <svg
          width="20"
          height="22"
          viewBox="0 0 20 22"
          className="-ml-[2px] -mt-[2px] block drop-shadow-[0_2px_4px_rgba(0,0,0,0.45)]"
        >
          <path
            d="M2 2 L2 18.5 L6.4 14.6 L9.3 21 L12.1 19.8 L9.3 13.5 L15.2 13.2 Z"
            fill={cursor.color}
            stroke="rgba(10,10,10,0.85)"
            strokeWidth="1.1"
            strokeLinejoin="round"
          />
        </svg>
      </motion.div>
    </>
  );
}
```
