Avatar Stack Flock

Avatar

Avatar stack that mills as a live boids flock and resolves into the classic overlapping row on hover, with the +N badge appearing only once the formation settles.

Install
npx shadcn add https://design.helpmarq.com/r/avatar-stack-flock.json
Source
registry/core/avatar-stack-flock/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// FlockStack — team avatar stack that never sits still: at rest the avatars
// mill as a bounded boids flock (separation / alignment / cohesion + soft
// walls); hovering or keyboard-focusing the region ramps a seek force toward
// each avatar's slot in the classic overlapping row, SUMMED into the same
// three rules, so the tidy "group photo" is itself a settled flocking state.
// Pure DOM — per-frame transforms from a refs-only vector sim, no canvas, no
// React state on the hot path. Every color is a CSS token class (nothing is
// read via getComputedStyle, so there is nothing to re-derive on theme
// change — ring-background / border-border / bg-surface self-adapt). The +N
// badge fades in only once the formation resolves; once resolved AND settled
// the rAF loop genuinely sleeps until hover exit. Static resolved row under
// prefers-reduced-motion.
// ---------------------------------------------------------------------------

export interface FlockMember {
  name: string;
  /** 1–2 chars; derived from name when omitted */
  initials?: string;
  /** optional avatar image url; initials shown otherwise */
  src?: string;
}

const DEFAULT_MEMBERS: FlockMember[] = [
  { name: "Mara Chen" },
  { name: "Jonas Weber" },
  { name: "Aiko Tanaka" },
  { name: "Sam Okafor" },
  { name: "Lena Fischer" },
  { name: "Ravi Patel" },
  { name: "Nora Lindqvist" },
];

function initialsOf(m: FlockMember) {
  if (m.initials) return m.initials.slice(0, 2).toUpperCase();
  return m.name
    .split(/\s+/)
    .map((w) => w[0] ?? "")
    .join("")
    .slice(0, 2)
    .toUpperCase();
}

// deterministic PRNG for the initial scatter (stable across strict-mode runs)
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// sim constants — px, seconds
const SEP_R = 34;
const SEP_W = 1.4;
const ALI_R = 60;
const ALI_W = 0.6;
const COH_R = 90;
const COH_W = 0.5;
const MAX_SPEED = 60; // px/s while milling — keeps the ambient drift calm
const SEEK_MAX_SPEED = 480; // px/s ceiling while seeking, ramped in with seekT
const MAX_FORCE = 260; // steering clamp, px/s^2
const WALL_M = 24; // soft-wall ramp distance
const WALL_F = 300; // px/s^2 at the edge
const SEEK_W = 2.2; // full seek weight after the ramp
const SEEK_RAMP_S = 0.4; // seconds, both directions
const ARRIVE_R = 40; // arrival slowdown radius

export function FlockStack({
  members = DEFAULT_MEMBERS,
  overflow = 3,
  avatarSize = 28,
  className = "h-[120px]",
  "aria-label": ariaLabel,
}: {
  /** flocking avatars (<=12 recommended; sim is O(n^2)) */
  members?: FlockMember[];
  /** count in the "+N" badge shown once the flock resolves */
  overflow?: number;
  /** avatar diameter in px */
  avatarSize?: number;
  className?: string;
  "aria-label"?: string;
}) {
  const regionRef = useRef<HTMLDivElement>(null);
  const badgeRef = useRef<HTMLDivElement>(null);
  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);

  useEffect(() => {
    const region = regionRef.current;
    if (!region) return;
    const badge = badgeRef.current; // null when overflow <= 0
    const items = itemRefs.current
      .slice(0, members.length)
      .filter((el): el is HTMLDivElement => el !== null);
    const n = items.length;
    if (n === 0) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    const half = avatarSize / 2;

    // hot-path state — plain locals/arrays, never React state
    const px: number[] = new Array(n).fill(0);
    const py: number[] = new Array(n).fill(0);
    const vx: number[] = new Array(n).fill(0);
    const vy: number[] = new Array(n).fill(0);
    const wanderA: number[] = new Array(n).fill(0);
    const slotX: number[] = new Array(n).fill(0);
    let slotY = 0;
    let W = 0;
    let H = 0;
    let sized = false;

    const measure = () => {
      const r = region.getBoundingClientRect();
      W = r.width;
      H = r.height;
      // zero/tiny-size guard: no slots, no sim until we have real bounds
      sized = W >= avatarSize * 2 && H >= avatarSize + 8;
      if (!sized) return;
      const badgeCX = W - 16 - half;
      // classic -8 px overlap, compressed if the region is narrow
      const step = Math.min(
        avatarSize - 8,
        Math.max(6, (badgeCX - half - 12) / n)
      );
      slotY = H / 2;
      for (let i = 0; i < n; i++) slotX[i] = badgeCX - (n - i) * step;
      if (badge) {
        badge.style.transform = `translate3d(${badgeCX - half}px, ${slotY - half}px, 0)`;
      }
      // keep agents inside fresh bounds after a resize
      for (let i = 0; i < n; i++) {
        px[i] = Math.min(Math.max(px[i] ?? 0, half), W - half);
        py[i] = Math.min(Math.max(py[i] ?? 0, half), H - half);
      }
    };
    measure();

    // per-avatar hover: lift + tooltip, only once the formation is resolved
    let resolvedNow = reduced;
    const detachHover: Array<() => void> = [];
    items.forEach((el) => {
      const inner = el.querySelector<HTMLElement>("[data-avatar]");
      const tip = el.querySelector<HTMLElement>("[data-tip]");
      const baseZ = el.style.zIndex;
      const enter = () => {
        if (!resolvedNow) return;
        el.style.zIndex = "40";
        if (inner) inner.style.transform = "translateY(-3px)";
        if (tip) tip.style.opacity = "1";
      };
      const leave = () => {
        el.style.zIndex = baseZ;
        if (inner) inner.style.transform = "";
        if (tip) tip.style.opacity = "0";
      };
      el.addEventListener("pointerenter", enter);
      el.addEventListener("pointerleave", leave);
      detachHover.push(() => {
        el.removeEventListener("pointerenter", enter);
        el.removeEventListener("pointerleave", leave);
      });
    });

    // ------------------------------------------------------------------
    // reduced motion: static two-state — resolved row, badge always on,
    // no milling ever; tooltips/lift still work (CSS transitions only)
    // ------------------------------------------------------------------
    if (reduced) {
      const place = () => {
        measure();
        if (!sized) return;
        items.forEach((el, i) => {
          el.style.transform = `translate3d(${(slotX[i] ?? 0) - half}px, ${slotY - half}px, 0)`;
          el.style.opacity = "1";
        });
        if (badge) badge.style.opacity = "1";
      };
      region.dataset.resolved = "true";
      place();
      const ro = new ResizeObserver(place);
      ro.observe(region);
      return () => {
        ro.disconnect();
        detachHover.forEach((f) => f());
      };
    }

    // ------------------------------------------------------------------
    // animated path
    // ------------------------------------------------------------------
    const rand = mulberry32(0xf10c5 + n);
    let seeded = false;
    const seed = () => {
      if (seeded || !sized) return;
      for (let i = 0; i < n; i++) {
        px[i] = half + 8 + rand() * Math.max(1, W - avatarSize - 16);
        py[i] = half + 8 + rand() * Math.max(1, H - avatarSize - 16);
        const a = rand() * Math.PI * 2;
        vx[i] = Math.cos(a) * MAX_SPEED * 0.5;
        vy[i] = Math.sin(a) * MAX_SPEED * 0.5;
        wanderA[i] = a;
      }
      items.forEach((el) => {
        el.style.opacity = "1";
      });
      seeded = true;
    };
    seed();

    let raf = 0;
    let last = 0;
    let ioVisible = true;
    let docVisible = !document.hidden;
    let hovered = false;
    let focused = false;
    let seekT = 0; // 0..1, ramps over SEEK_RAMP_S both ways
    let sleeping = false;

    const setResolved = (v: boolean) => {
      if (resolvedNow === v) return;
      resolvedNow = v;
      region.dataset.resolved = v ? "true" : "false";
      if (badge) badge.style.opacity = v ? "1" : "0";
    };

    const clampScale = (x: number, y: number) => {
      const l = Math.hypot(x, y);
      return l > MAX_FORCE ? MAX_FORCE / l : 1;
    };

    function step(now: number) {
      raf = 0;
      if (!seeded) seed();
      if (!sized || !seeded) return; // ResizeObserver wakes us with real bounds
      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
      last = now;

      const wantSeek = hovered || focused;
      seekT = Math.min(
        1,
        Math.max(0, seekT + ((wantSeek ? 1 : -1) * dt) / SEEK_RAMP_S)
      );
      const seekW = SEEK_W * seekT;
      const mill = 1 - seekT; // milling drive fades as the seek ramps in
      // the three rules stay summed, attenuated so arrival can actually settle —
      // must reach exactly 0 at full seek (mirrors `mill`) or a residual
      // separation force fights the seek force forever at tight slot spacing
      const flockW = Math.max(0, 1 - seekT);
      // milling's MAX_SPEED (60 px/s) is deliberately slow for a calm resting
      // drift, but that same cap made a from-scatter seek arrival take
      // 5-6+ seconds — far past the 400ms seek-ramp. Ramp a higher travel
      // ceiling in with seekT so milling speed is untouched at rest and the
      // seek phase alone gets a real sprint toward the slot.
      const travelCap = MAX_SPEED + (SEEK_MAX_SPEED - MAX_SPEED) * seekT;

      const damp = Math.pow(0.98, dt * 60);
      let allNear = true;
      let allStill = true;
      // position-only — deliberately NOT the same test as `allNear` (which
      // also gates on speed): a wake-from-sleep velocity kick can trip a
      // speed threshold for a frame without the avatar visibly leaving its
      // slot, and clearing the badge on that alone is exactly the flicker.
      let anyDeparted = false;

      for (let i = 0; i < n; i++) {
        const xi = px[i] ?? 0;
        const yi = py[i] ?? 0;
        let vxi = vx[i] ?? 0;
        let vyi = vy[i] ?? 0;
        let ax = 0;
        let ay = 0;

        // neighbor accumulation (n <= 12 → O(n^2) is nothing)
        let sx = 0;
        let sy = 0;
        let sc = 0;
        let alx = 0;
        let aly = 0;
        let ac = 0;
        let cxs = 0;
        let cys = 0;
        let cc = 0;
        for (let j = 0; j < n; j++) {
          if (j === i) continue;
          let dx = xi - (px[j] ?? 0);
          let dy = yi - (py[j] ?? 0);
          let d = Math.hypot(dx, dy);
          if (d < 1e-4) {
            // coincident agents: deterministic nudge, never a zero-length vector
            dx = i > j ? 0.01 : -0.01;
            dy = 0;
            d = 0.01;
          }
          if (d < SEP_R) {
            const f = (1 - d / SEP_R) / d;
            sx += dx * f;
            sy += dy * f;
            sc++;
          }
          if (d < ALI_R) {
            alx += vx[j] ?? 0;
            aly += vy[j] ?? 0;
            ac++;
          }
          if (d < COH_R) {
            cxs += px[j] ?? 0;
            cys += py[j] ?? 0;
            cc++;
          }
        }

        // Reynolds steering: desired velocity − current, clamped to MAX_FORCE
        if (sc > 0) {
          const m = Math.hypot(sx, sy);
          if (m > 1e-5) {
            const fx = (sx / m) * MAX_SPEED - vxi;
            const fy = (sy / m) * MAX_SPEED - vyi;
            const s = clampScale(fx, fy);
            ax += SEP_W * flockW * fx * s;
            ay += SEP_W * flockW * fy * s;
          }
        }
        if (ac > 0) {
          const mvx = alx / ac;
          const mvy = aly / ac;
          const m = Math.hypot(mvx, mvy);
          if (m > 1e-5) {
            const fx = (mvx / m) * MAX_SPEED - vxi;
            const fy = (mvy / m) * MAX_SPEED - vyi;
            const s = clampScale(fx, fy);
            ax += ALI_W * flockW * fx * s;
            ay += ALI_W * flockW * fy * s;
          }
        }
        if (cc > 0) {
          const tx = cxs / cc - xi;
          const ty = cys / cc - yi;
          const m = Math.hypot(tx, ty);
          if (m > 1e-5) {
            const fx = (tx / m) * MAX_SPEED - vxi;
            const fy = (ty / m) * MAX_SPEED - vyi;
            const s = clampScale(fx, fy);
            ax += COH_W * flockW * fx * s;
            ay += COH_W * flockW * fy * s;
          }
        }

        // milling drive: wander random-walk + cruise thrust, fades with seek
        if (mill > 0.001) {
          wanderA[i] = (wanderA[i] ?? 0) + (Math.random() * 2 - 1) * 4 * dt;
          const wa = wanderA[i] ?? 0;
          ax += Math.cos(wa) * 28 * mill;
          ay += Math.sin(wa) * 28 * mill;
          const sp = Math.hypot(vxi, vyi);
          if (sp > 1e-3) {
            const thrust = (MAX_SPEED * 0.65 - sp) * 1.6 * mill;
            ax += (vxi / sp) * thrust;
            ay += (vyi / sp) * thrust;
          } else {
            ax += Math.cos(wa) * 60 * mill;
            ay += Math.sin(wa) * 60 * mill;
          }
        }

        // soft walls: force ramps to WALL_F inside WALL_M of the region edge
        const dl = xi - half;
        if (dl < WALL_M) ax += WALL_F * (1 - Math.max(0, dl) / WALL_M);
        const dr = W - half - xi;
        if (dr < WALL_M) ax -= WALL_F * (1 - Math.max(0, dr) / WALL_M);
        const dtp = yi - half;
        if (dtp < WALL_M) ay += WALL_F * (1 - Math.max(0, dtp) / WALL_M);
        const db = H - half - yi;
        if (db < WALL_M) ay -= WALL_F * (1 - Math.max(0, db) / WALL_M);

        // seek to slot: summed into the same field, arrival slowdown < 40 px
        const sxT = slotX[i] ?? 0;
        if (seekW > 0) {
          const dx = sxT - xi;
          const dy = slotY - yi;
          const d = Math.hypot(dx, dy);
          if (d > 1e-4) {
            const spd = d < ARRIVE_R ? travelCap * (d / ARRIVE_R) : travelCap;
            const fx = (dx / d) * spd - vxi;
            const fy = (dy / d) * spd - vyi;
            const s = clampScale(fx, fy);
            ax += seekW * fx * s;
            ay += seekW * fy * s;
          }
        }

        // integrate: damping 0.98/frame normalized, speed cap ramps with seekT
        vxi = (vxi + ax * dt) * damp;
        vyi = (vyi + ay * dt) * damp;
        const sp2 = Math.hypot(vxi, vyi);
        if (sp2 > travelCap) {
          const s = travelCap / sp2;
          vxi *= s;
          vyi *= s;
        }
        let nx = xi + vxi * dt;
        let ny = yi + vyi * dt;

        // capture assist at full ramp: physics carries the approach, this
        // closes the last few px so the 2 px / 1 px/s gate is reachable
        if (seekT > 0.95) {
          const ddx = sxT - nx;
          const ddy = slotY - ny;
          if (Math.hypot(ddx, ddy) < 8) {
            const k = Math.min(1, 10 * dt);
            nx += ddx * k;
            ny += ddy * k;
            const kd = Math.pow(0.8, dt * 60);
            vxi *= kd;
            vyi *= kd;
          }
        }

        // hard containment safety net (soft walls do the real work)
        if (nx < half) {
          nx = half;
          vxi = Math.abs(vxi);
        } else if (nx > W - half) {
          nx = W - half;
          vxi = -Math.abs(vxi);
        }
        if (ny < half) {
          ny = half;
          vyi = Math.abs(vyi);
        } else if (ny > H - half) {
          ny = H - half;
          vyi = -Math.abs(vyi);
        }

        px[i] = nx;
        py[i] = ny;
        vx[i] = vxi;
        vy[i] = vyi;

        const speed = Math.hypot(vxi, vyi);
        const slotDist = Math.hypot(sxT - nx, slotY - ny);
        if (slotDist > 2 || speed >= 1) allNear = false;
        if (speed >= 0.5) allStill = false;
        if (slotDist > 2) anyDeparted = true;

        const el = items[i];
        if (el) {
          el.style.transform = `translate3d(${nx - half}px, ${ny - half}px, 0)`;
        }
      }

      // badge clears only once an avatar has actually left its slot (position,
      // not the speed-inclusive `allNear`) — not on every momentary
      // pointer-out, so a hover blip that never visibly moves the row can't
      // flicker the badge
      if (resolvedNow && anyDeparted) setResolved(false);

      if (wantSeek && seekT >= 1) {
        if (allNear) setResolved(true);
        if (allNear && allStill) {
          // snap exactly to slots, zero out, and genuinely sleep
          for (let i = 0; i < n; i++) {
            const sxT = slotX[i] ?? 0;
            px[i] = sxT;
            py[i] = slotY;
            vx[i] = 0;
            vy[i] = 0;
            const el = items[i];
            if (el) {
              el.style.transform = `translate3d(${sxT - half}px, ${slotY - half}px, 0)`;
            }
          }
          sleeping = true;
          last = 0;
          return;
        }
      }

      if (ioVisible && docVisible && !sleeping) {
        raf = requestAnimationFrame(step);
      }
    }

    const wake = () => {
      sleeping = false;
      if (raf === 0 && ioVisible && docVisible) {
        last = 0;
        raf = requestAnimationFrame(step);
      }
    };
    const pause = () => {
      if (raf !== 0) {
        cancelAnimationFrame(raf);
        raf = 0;
      }
      last = 0;
    };

    const onEnter = () => {
      hovered = true;
      wake();
    };
    const onLeave = () => {
      hovered = false;
      wake();
    };
    const onFocus = () => {
      // keyboard focus behaves like hover; pointer clicks don't lock the row
      focused = region.matches(":focus-visible");
      if (focused) wake();
    };
    const onBlur = () => {
      if (focused) {
        focused = false;
        wake();
      }
    };
    const onVis = () => {
      docVisible = !document.hidden;
      if (!docVisible) pause();
      else wake();
    };
    region.addEventListener("pointerenter", onEnter);
    region.addEventListener("pointerleave", onLeave);
    region.addEventListener("focus", onFocus);
    region.addEventListener("blur", onBlur);
    document.addEventListener("visibilitychange", onVis);

    // milling is the ambient default, so "sleep" also means: pause offscreen
    const io = new IntersectionObserver((entries) => {
      ioVisible = entries[0]?.isIntersecting ?? true;
      if (!ioVisible) pause();
      else wake();
    });
    io.observe(region);

    const ro = new ResizeObserver(() => {
      measure();
      seed();
      wake();
    });
    ro.observe(region);

    raf = requestAnimationFrame(step);

    return () => {
      if (raf !== 0) cancelAnimationFrame(raf);
      io.disconnect();
      ro.disconnect();
      region.removeEventListener("pointerenter", onEnter);
      region.removeEventListener("pointerleave", onLeave);
      region.removeEventListener("focus", onFocus);
      region.removeEventListener("blur", onBlur);
      document.removeEventListener("visibilitychange", onVis);
      detachHover.forEach((f) => f());
    };
  }, [members, avatarSize]);

  const label =
    ariaLabel ??
    `Team: ${members.map((m) => m.name).join(", ")}${
      overflow > 0 ? ` and ${overflow} more` : ""
    }`;

  return (
    <div
      ref={regionRef}
      tabIndex={0}
      role="group"
      aria-label={label}
      data-resolved="false"
      className={`relative w-full rounded-sm border border-border/60 bg-background/40 transition-colors hover:border-foreground/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${className}`}
    >
      {members.map((m, i) => (
        <div
          key={`${m.name}-${i}`}
          ref={(el) => {
            itemRefs.current[i] = el;
          }}
          className="absolute left-0 top-0 transition-opacity duration-300 will-change-transform"
          style={{ width: avatarSize, height: avatarSize, zIndex: i + 1, opacity: 0 }}
        >
          <div
            data-avatar
            className="flex h-full w-full select-none items-center justify-center rounded-full border border-border bg-surface font-mono text-[9px] font-medium text-muted ring-2 ring-background transition-transform duration-200"
          >
            {m.src ? (
              <img
                src={m.src}
                alt=""
                className="h-full w-full rounded-full object-cover"
              />
            ) : (
              initialsOf(m)
            )}
          </div>
          <div
            data-tip
            aria-hidden
            className="pointer-events-none absolute bottom-full left-1/2 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-sm border border-border bg-surface px-1.5 py-0.5 font-mono text-[10px] text-foreground opacity-0 shadow-sm transition-opacity duration-150"
          >
            {m.name}
          </div>
        </div>
      ))}
      {overflow > 0 && (
        <div
          ref={badgeRef}
          className="absolute left-0 top-0 flex select-none items-center justify-center rounded-full border border-border bg-surface font-mono text-[10px] text-muted ring-2 ring-background transition-opacity duration-200"
          style={{
            width: avatarSize,
            height: avatarSize,
            zIndex: members.length + 1,
            opacity: 0,
          }}
        >
          +{overflow}
        </div>
      )}
    </div>
  );
}
Build spec

A team avatar stack that never sits still: 7 DOM avatar circles (28 px, initials, ring-2 ring-background) mill inside a bounded card region as a real boids flock driven by a canvas-free vector sim (separation r34/w1.4, alignment r60/w0.6, cohesion r90/w0.5, max speed 60 px/s, soft-wall 300 px/s^2 ramping inside 24 px of the edge, damping pow(0.98, dt*60)). Hovering or keyboard-focusing the container ramps a per-avatar seek force toward its slot in the classic -8 px overlap right-aligned row from 0 to weight 2.2 over 400 ms, summed into the same three rules with arrival slowdown inside 40 px, so the tidy group photo is itself a settled flocking state. The 60 px/s milling speed cap is deliberately slow for a calm resting drift, so the seek phase ramps in its own, much higher travel-speed ceiling with the same 400 ms curve — idle milling is untouched but a from-scatter hover resolves the row in about a second instead of several. When every agent is within 2 px of its slot at under 1 px/s the +N badge fades in over 200 ms; release ramps the seek off over 400 ms, and the badge only fades out once the row has actually started to leave its slots (not on a momentary pointer-out) as the flock disperses back to milling. Transforms are written per-frame on a refs-only direct-DOM rAF loop that pauses offscreen (IntersectionObserver) and on document.hidden, and genuinely sleeps once resolved and settled below 0.5 px/s until hover exit; ResizeObserver re-derives bounds and slots with zero-size and zero-length-vector guards. Hovering an individual avatar once resolved lifts it 3 px and shows a token-styled name tooltip. All colors are CSS token classes (bg-surface, border-border, ring-background) so both themes self-adapt. prefers-reduced-motion renders the static resolved row with the badge always visible.

Tags
avatarboidsphysicshoverteammicro-interaction