Teseria
All elements

Presence

Teammates' cursors on a shared canvas. Positions come in through `peers` (as coarse and jittery as a realtime channel delivers them) and each cursor springs to its latest position, so it reads as a hand, not a teleport.

Code

1. Install

Terminal
# No packages beyond React.

2. Files

Presence.tsx223 lines
"use client";
// Presence: teammates' cursors on a shared canvas. Positions come in through `peers` (as coarse and jittery as a realtime
// channel delivers them) and each cursor springs to its latest position, so it reads as a hand, not a teleport. The spring
// loop runs only while a cursor is still travelling and the canvas is on screen; your own cursor joins with a tag.
// With no `peers` it simulates a realtime channel with three teammates who wander, dart and go away.
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";

export type Peer = {
  name: string;
  hue: number;
  /** Position inside the canvas, 0..1 on each axis. Feed these from your realtime channel; the cursor springs to them. */
  x?: number;
  y?: number;
  /** Idle/away: the cursor blurs out, and floats back in from the bottom edge when it returns. */
  away?: boolean;
};

export type PresenceProps = {
  /** Who is here. Leave out to run the built-in simulation of three teammates. */
  peers?: Peer[];
  /** Label on your own cursor. */
  you?: string;
  /** Canvas width in px. */
  width?: number;
  /** Canvas height in px. */
  height?: number;
  /** Corner radius in px. */
  corner?: number;
  className?: string;
  style?: CSSProperties;
};

const SEED: Peer[] = [
  { name: "Ravi", hue: 330, x: 0.22, y: 0.36 },
  { name: "Hugo", hue: 30, x: 0.58, y: 0.58 },
  { name: "Isla", hue: 150, x: 0.76, y: 0.3 },
];
const SPOTS = [[0.22, 0.36], [0.58, 0.58], [0.76, 0.3], [0.36, 0.72], [0.86, 0.7]];

type Sim = { x: number; y: number; vx: number; vy: number; away: boolean };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const still = () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;

/** True while `ref` is near the viewport and the tab is visible. */
function useOnScreen(ref: React.RefObject<Element | null>) {
  const [near, setNear] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") return setNear(true);
    let inView = false;
    const sync = () => setNear(inView && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        inView = entry.isIntersecting;
        sync();
      },
      { rootMargin: "160px" },
    );
    observer.observe(el);
    document.addEventListener("visibilitychange", sync);
    return () => {
      observer.disconnect();
      document.removeEventListener("visibilitychange", sync);
    };
  }, [ref]);
  return near;
}

/** Stands in for a realtime channel: each teammate sends a new position every 0.5–2.3s, sometimes a long dart, and now and then goes away and comes back. */
function useSimulatedPeers(ref: React.RefObject<Element | null>, enabled: boolean) {
  const on = useOnScreen(ref);
  const [peers, setPeers] = useState(SEED);
  useEffect(() => {
    if (!enabled || !on) return;
    const timers: number[] = [];
    const update = (i: number, change: (peer: Peer) => Peer) => setPeers((ps) => ps.map((peer, k) => (k === i ? change(peer) : peer)));
    SEED.forEach((_, i) => {
      const move = () => {
        const far = Math.random() < 0.3;
        const jump = (spread: number) => (Math.random() - 0.5) * spread;
        update(i, (peer) => ({
          ...peer,
          x: clamp((peer.x ?? 0.5) + jump(far ? 0.85 : 0.24), 0.06, 0.9),
          y: clamp((peer.y ?? 0.5) + jump(far ? 0.85 : 0.26), 0.08, 0.86),
        }));
        timers.push(window.setTimeout(move, 500 + Math.random() * 1800));
      };
      const away = (gone: boolean) => {
        update(i, (peer) => ({ ...peer, away: gone }));
        timers.push(window.setTimeout(() => away(!gone), gone ? 1800 + Math.random() * 1500 : 5000 + Math.random() * 6000));
      };
      timers.push(window.setTimeout(move, 300 + i * 350));
      timers.push(window.setTimeout(() => away(true), 4000 + i * 3000));
    });
    return () => timers.forEach(clearTimeout);
  }, [enabled, on]);
  return peers;
}

export function Presence({ peers: given, you = "You", width = 300, height = 190, corner = 24, className, style }: PresenceProps) {
  const root = useRef<HTMLDivElement>(null);
  const mine = useRef<HTMLDivElement>(null);
  const els = useRef<(HTMLDivElement | null)[]>([]);
  const sims = useRef(new Map<string, Sim>());
  const simulated = useSimulatedPeers(root, !given);
  const peers = given ?? simulated;
  const peersRef = useRef(peers);
  peersRef.current = peers;
  const raf = useRef(0);
  const on = useOnScreen(root);

  const goal = (peer: Peer, i: number) => ({
    // keep the whole name tag inside the canvas: it hangs ~60px right of and ~34px below the arrow tip
    x: clamp((peer.x ?? SPOTS[i % SPOTS.length][0]) * width, 12, width - 64),
    y: clamp((peer.y ?? SPOTS[i % SPOTS.length][1]) * height, 12, height - 38),
  });
  const paint = (i: number, cursor: Sim) => {
    const el = els.current[i];
    if (el) el.style.transform = `translate3d(${cursor.x.toFixed(1)}px,${cursor.y.toFixed(1)}px,0)`;
  };

  // place every cursor before first paint (no spring on mount)
  useLayoutEffect(() => {
    peersRef.current.forEach((peer, i) => {
      const target = goal(peer, i);
      const cursor = { ...target, vx: 0, vy: 0, away: !!peer.away };
      sims.current.set(peer.name, cursor);
      paint(i, cursor);
    });
    root.current?.setAttribute("data-ready", "");
  }, []);

  const park = () => {
    cancelAnimationFrame(raf.current);
    raf.current = 0;
  };
  const kick = () => {
    if (raf.current) return;
    let last = 0;
    const frame = (t: number) => {
      const dt = last ? clamp((t - last) / 16.67, 0, 2.5) : 1;
      last = t;
      let moving = false;
      peersRef.current.forEach((peer, i) => {
        const target = goal(peer, i);
        let cursor = sims.current.get(peer.name);
        if (!cursor) {
          cursor = { ...target, vx: 0, vy: 0, away: !!peer.away };
          sims.current.set(peer.name, cursor);
        }
        if (cursor.away && !peer.away) Object.assign(cursor, { x: target.x, y: height + 10, vx: 0, vy: 0 }); // back from away: float in from the bottom
        cursor.away = !!peer.away;
        cursor.vx = (cursor.vx + (target.x - cursor.x) * 0.045 * dt) * 0.78 ** dt;
        cursor.vy = (cursor.vy + (target.y - cursor.y) * 0.045 * dt) * 0.78 ** dt;
        cursor.x += cursor.vx * dt;
        cursor.y += cursor.vy * dt;
        if (Math.abs(target.x - cursor.x) + Math.abs(target.y - cursor.y) > 0.2 || Math.abs(cursor.vx) + Math.abs(cursor.vy) > 0.02) moving = true;
        else Object.assign(cursor, { x: target.x, y: target.y, vx: 0, vy: 0 });
        paint(i, cursor);
      });
      raf.current = moving ? requestAnimationFrame(frame) : 0;
    };
    raf.current = requestAnimationFrame(frame);
  };
  useEffect(() => {
    if (!on) return park();
    if (still()) {
      // reduced motion: cursors jump to their positions
      peers.forEach((peer, i) => {
        const target = goal(peer, i);
        const cursor = { ...target, vx: 0, vy: 0, away: !!peer.away };
        sims.current.set(peer.name, cursor);
        paint(i, cursor);
      });
      return;
    }
    kick();
  }, [on, peers, width, height]);
  useEffect(() => park, []);

  return (
    <div
      ref={root}
      className={className ? `tpr-root ${className}` : "tpr-root"}
      style={{ ...style, width, height, borderRadius: corner }}
      role="img"
      aria-label={`${peers.filter((peer) => !peer.away).length} people here`}
      onPointerMove={(e) => {
        const b = e.currentTarget.getBoundingClientRect();
        const el = mine.current;
        if (!el) return;
        el.style.transform = `translate3d(${(e.clientX - b.left).toFixed(1)}px,${(e.clientY - b.top).toFixed(1)}px,0)`;
        el.removeAttribute("data-away");
      }}
      onPointerLeave={() => mine.current?.setAttribute("data-away", "")}
    >
      <div className="tpr-grid" aria-hidden />
      {peers.map((peer, i) => (
        <div key={peer.name} ref={(el) => void (els.current[i] = el)} className="tpr-cur" data-peer data-away={peer.away || undefined} style={{ "--tpr-c": `oklch(0.62 0.17 ${peer.hue})` } as CSSProperties} aria-hidden>
          <Arrow />
          <span className="tpr-tag">{peer.name}</span>
        </div>
      ))}
      <div ref={mine} className="tpr-cur tpr-me" data-away style={{ "--tpr-c": "var(--ts-fg)" } as CSSProperties} aria-hidden>
        <Arrow />
        <span className="tpr-tag">{you}</span>
      </div>
      <div className="tpr-stack" aria-hidden>
        {peers.filter((peer) => !peer.away).map((peer) => (
          <i key={peer.name} style={{ background: `oklch(0.62 0.17 ${peer.hue})` }} />
        ))}
      </div>
    </div>
  );
}

const Arrow = () => (
  <svg className="tpr-arrow" viewBox="0 0 16 16" width="16" height="16">
    <path d="M2.5 1.5 L13.5 7.2 L8.6 8.8 L6.4 14 Z" />
  </svg>
);
Presence.css96 lines
/* basics: the host app may have none */
:where(.tpr-root),
:where(.tpr-root) *,
:where(.tpr-root) *::before,
:where(.tpr-root) *::after {
  box-sizing: border-box;
}
.tpr-root {
  font-family: var(--ts-font, system-ui, sans-serif);
}
:where(.tpr-root button) {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
}
:where(.tpr-root input) {
  font: inherit;
  color: inherit;
}
:where(.tpr-root) :focus-visible {
  outline: 2px solid var(--ts-focus, #3446eb);
  outline-offset: 2px;
}

.tpr-root {
  position: relative;
  overflow: hidden;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  cursor: none;
  user-select: none;
  touch-action: none;
}
.tpr-grid {
  position: absolute;
  inset: 0;
  background-image: radial-gradient(rgba(var(--ts-fg-rgb), 0.12) 1px, transparent 1.2px);
  background-size: 16px 16px;
  pointer-events: none;
}
.tpr-cur {
  --tpr-c: var(--ts-fg);
  position: absolute;
  left: 0;
  top: 0;
  display: flex;
  align-items: flex-start;
  gap: 2px;
  pointer-events: none;
  transition: opacity 0.22s cubic-bezier(.22, 1, .36, 1), filter 0.22s cubic-bezier(.22, 1, .36, 1), scale 0.42s cubic-bezier(.24, 1.34, .38, 1);
}
.tpr-root:not([data-ready]) .tpr-cur[data-peer],
.tpr-cur[data-away] {
  opacity: 0;
  filter: blur(4px);
  scale: 0.7;
}
.tpr-arrow {
  flex: none;
  fill: var(--tpr-c);
  stroke: var(--ts-raised);
  stroke-width: 1.2;
  stroke-linejoin: round;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
.tpr-tag {
  margin: 10px 0 0 -2px;
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--tpr-c);
  color: #fff;
  font-size: 12px;
  font-weight: 500;
  white-space: nowrap;
  line-height: 1.5;
}
.tpr-me .tpr-tag {
  color: var(--ts-raised);
}
.tpr-stack {
  position: absolute;
  right: 10px;
  top: 10px;
  display: flex;
}
.tpr-stack i {
  width: 14px;
  height: 14px;
  border-radius: 999px;
  margin-left: -4px;
  box-shadow: 0 0 0 2px var(--ts-raised);
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Presence } from "./Presence";
import "./Presence.css";

export default function Demo() {
  return <Presence />;
}