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
# No packages beyond React.
2. Files
"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>
);
/* 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);
}
/*
* 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
import { Presence } from "./Presence";
import "./Presence.css";
export default function Demo() {
return <Presence />;
}