Studio Pieces & Glyph Artv0.4.0New
Multiplayer Canvas Card
A feature card where named cursors select, type and comment on a canvas.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/multiplayer-canvas-card.jsonManual
Install the dependencies
npm install clsx lucide-react tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/multiplayer-canvas-card/multiplayer-canvas-card.tsx"use client"; import { useEffect, useRef, type ComponentProps } from "react"; import { MousePointer2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { FlowLines } from "@/components/ui/flow-line-chat-card/flow-line-chat-card"; export interface Collaborator { name: string; /** Cursor and avatar color. */ color: string; } export interface MultiplayerCanvasCardProps extends Omit<ComponentProps<"article">, "title"> { /** Feature title under the illustration. @default "Build it together, live" */ title?: string; /** One-line description under the title. */ description?: string; /** Up to three people whose cursors select, type and comment. @default Maya, Jonas and Lena */ collaborators?: Collaborator[]; } const PEOPLE: Collaborator[] = [ { name: "Maya", color: "#3b82f6" }, { name: "Jonas", color: "#f97316" }, { name: "Lena", color: "#22c55e" }, ]; const LOOP = 9; const TYPED = "Ship it on Friday?"; type P = [number, number]; /** Cubic bezier point. */ const bez = (a: P, b: P, c: P, d: P, t: number): P => { const u = 1 - t; return [u * u * u * a[0] + 3 * u * u * t * b[0] + 3 * u * t * t * c[0] + t * t * t * d[0], u * u * u * a[1] + 3 * u * u * t * b[1] + 3 * u * t * t * c[1] + t * t * t * d[1]]; }; const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); const seg = (t: number, start: number, end: number) => Math.min(1, Math.max(0, (t - start) / (end - start))); /** Scripted positions (in % of the canvas) for each cursor over one loop. */ function script(index: number, t: number): P { if (index === 0) { // Maya: to the shapes, drag a selection, then drift away. if (t < 1.2) return bez([8, 90], [10, 60], [14, 30], [16, 18], ease(seg(t, 0, 1.2))); if (t < 2.6) return bez([16, 18], [30, 30], [45, 50], [58, 62], ease(seg(t, 1.2, 2.6))); if (t < 3.6) return [58, 62]; return bez([58, 62], [50, 80], [30, 92], [12, 88], ease(seg(t, 3.6, 5.6))); } if (index === 1) { // Jonas: to the text block, type, then leave. if (t < 1.5) return bez([94, 92], [90, 60], [80, 30], [72, 26], ease(seg(t, 0, 1.5))); if (t < 6) return [72 + seg(t, 1.6, 4.2) * 18, 26]; return bez([90, 26], [96, 40], [98, 70], [94, 92], ease(seg(t, 6, 7.6))); } // Lena: to the comment pin, open it, wait, then back. if (t < 2) return [84, 96]; if (t < 3.5) return bez([84, 96], [70, 92], [40, 86], [29, 74], ease(seg(t, 2, 3.5))); if (t < 7.6) return [29, 74]; return bez([29, 74], [45, 86], [70, 96], [84, 96], ease(seg(t, 7.6, 8.8))); } /** * The companion feature card for a real-time collaboration product: on a small canvas, named cursors * follow scripted paths. One drags a selection box that highlights shapes, another types into a text * block, a third opens a comment thread, while flowing lines carry "data" from each cursor to an * avatar stack. Hovering speeds the scene up and shows the visitor's own cursor labelled "You". * Everything moves through refs in one loop; still under reduced motion. */ export function MultiplayerCanvasCard({ title = "Build it together, live", description = "Cursors, selections and comments sync in real time, so the whole team works on one canvas.", collaborators = PEOPLE, className, ...props }: MultiplayerCanvasCardProps) { const people = collaborators.slice(0, 3); const peopleKey = people.map((p) => p.name).join("|"); const stageRef = useRef<HTMLDivElement>(null); const cursorRefs = useRef<(HTMLDivElement | null)[]>([]); const linkRefs = useRef<(SVGPathElement | null)[]>([]); const selectRef = useRef<SVGRectElement>(null); const shapesRef = useRef<HTMLDivElement>(null); const typedRef = useRef<HTMLSpanElement>(null); const caretRef = useRef<HTMLSpanElement>(null); const threadRef = useRef<HTMLDivElement>(null); const youRef = useRef<HTMLDivElement>(null); const avatarsRef = useRef<HTMLDivElement>(null); useEffect(() => { const stage = stageRef.current; if (!stage) return; const media = window.matchMedia("(prefers-reduced-motion: reduce)"); let raf = 0; let last = 0; let time = 0; let speed = 1; let target = 1; let visible = true; const paint = (t: number) => { const w = stage.clientWidth; const h = stage.clientHeight; // Links live in the outer frame so they can reach the avatar stack above the canvas. const ox = stage.offsetLeft; const oy = stage.offsetTop; const stack = avatarsRef.current; const hub: P = stack ? [stack.offsetLeft + stack.offsetWidth * 0.3, stack.offsetTop + stack.offsetHeight] : [ox + w - 40, oy - 10]; people.forEach((_, i) => { const [px, py] = script(i, t); const x = (px / 100) * w; const y = (py / 100) * h; const el = cursorRefs.current[i]; if (el) el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`; const lx = ox + x; const ly = oy + y; const link = linkRefs.current[i]; if (link) link.setAttribute("d", `M${lx.toFixed(1)} ${ly.toFixed(1)} Q ${((lx + hub[0]) / 2).toFixed(1)} ${(Math.min(ly, hub[1]) - 30).toFixed(1)} ${hub[0].toFixed(1)} ${hub[1].toFixed(1)}`); }); // Selection box from where Maya pressed to where she is. const sel = selectRef.current; if (sel) { const on = t > 1.2 && t < 4.2; const end = script(0, Math.min(t, 2.6)); const x0 = Math.min(16, end[0]); const y0 = Math.min(18, end[1]); sel.style.opacity = on ? (t > 3.6 ? String(1 - seg(t, 3.6, 4.2)) : "1") : "0"; // An SVG rect, so redrawing it every frame never touches the page layout. sel.setAttribute("x", `${x0}%`); sel.setAttribute("y", `${y0}%`); sel.setAttribute("width", `${Math.abs(end[0] - 16)}%`); sel.setAttribute("height", `${Math.abs(end[1] - 18)}%`); shapesRef.current?.toggleAttribute("data-selected", t > 2.4 && t < 4.2); } // Typing. const typed = typedRef.current; if (typed) typed.textContent = TYPED.slice(0, Math.round(seg(t, 1.6, 4.2) * TYPED.length) * (t < 8.6 ? 1 : 0)); caretRef.current?.toggleAttribute("data-on", t > 1.5 && t < 6.2); // Comment thread. threadRef.current?.toggleAttribute("data-open", t > 3.6 && t < 7.6); }; const tick = (now: number) => { raf = 0; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; speed += (target - speed) * Math.min(1, dt * 4); time = (time + dt * speed) % LOOP; paint(time); schedule(); }; const schedule = () => { if (!raf && visible && !document.hidden && !media.matches) raf = requestAnimationFrame(tick); }; const wake = () => { last = 0; if (media.matches) paint(3.9); schedule(); }; // The visitor's own cursor, and a slightly faster scene while hovered. const onMove = (event: PointerEvent) => { const rect = stage.getBoundingClientRect(); const you = youRef.current; if (!you) return; you.style.opacity = "1"; you.style.transform = `translate3d(${event.clientX - rect.left}px, ${event.clientY - rect.top}px, 0)`; target = 1.35; }; const onLeave = () => { if (youRef.current) youRef.current.style.opacity = "0"; target = 1; }; const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true; wake(); }); io.observe(stage); const ro = new ResizeObserver(() => paint(media.matches ? 3.9 : time)); ro.observe(stage); stage.addEventListener("pointermove", onMove); stage.addEventListener("pointerleave", onLeave); document.addEventListener("visibilitychange", wake); media.addEventListener("change", wake); wake(); return () => { cancelAnimationFrame(raf); io.disconnect(); ro.disconnect(); stage.removeEventListener("pointermove", onMove); stage.removeEventListener("pointerleave", onLeave); document.removeEventListener("visibilitychange", wake); media.removeEventListener("change", wake); }; // Re-run only when the set of collaborators changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [peopleKey]); const summary = `${people.map((p) => p.name).join(", ")} editing a canvas together: selecting shapes, typing a note and leaving a comment.`; return ( <article className={cn("@container w-full max-w-md overflow-hidden rounded-[22px] border border-white/10 bg-[#070b14] text-white shadow-xl", className)} {...props}> <div className="relative h-80 overflow-hidden border-b border-white/10 bg-[radial-gradient(120%_80%_at_20%_0%,rgb(37_99_235/0.24),transparent_60%)]"> <FlowLines lines={12} color="#3b82f6" bend={0.4} className="opacity-60" /> {/* Avatar stack. */} <div ref={avatarsRef} className="absolute top-4 right-4 z-20 flex items-center gap-2 rounded-full border border-white/10 bg-[#0d1424]/90 py-1 pr-3 pl-1 text-[12px]"> <span className="flex -space-x-1.5" aria-hidden="true"> {people.map((p) => ( <span key={p.name} className="flex size-6 items-center justify-center rounded-full text-[10px] font-semibold ring-2 ring-[#0d1424]" style={{ background: p.color }}> {p.name[0]} </span> ))} </span> {people.length} editing </div> {/* Data lines from each cursor to the avatar stack. */} <svg aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"> <style>{`@keyframes mp-flow{to{stroke-dashoffset:-40}}`}</style> {people.map((p, i) => ( <path key={p.name} ref={(el) => { linkRefs.current[i] = el; }} fill="none" stroke={p.color} strokeOpacity="0.6" strokeWidth="1.2" strokeDasharray="3 7" // The animation is an inline style, so the reduced-motion override needs !important. className="motion-reduce:[animation:none]!" style={{ animation: "mp-flow 1.2s linear infinite" }} /> ))} </svg> <div ref={stageRef} role="img" aria-label={summary} className="absolute inset-x-5 top-14 bottom-5 cursor-none overflow-hidden rounded-2xl border border-white/10 bg-[#0d1424]/80 [background-image:radial-gradient(rgb(255_255_255/0.07)_1px,transparent_1.5px)] [background-size:16px_16px]"> <div aria-hidden="true" className="absolute inset-0"> {/* Shapes that get selected. */} <div ref={shapesRef} className="group/shapes absolute top-[22%] left-[20%] h-[34%] w-[34%]"> <span className="absolute top-0 left-0 size-[46%] rounded-lg bg-[#2563eb]/80 ring-2 ring-transparent transition-shadow group-data-[selected]/shapes:ring-[#60a5fa]" /> <span className="absolute right-0 bottom-0 size-[44%] rounded-full bg-[#f59e0b]/80 ring-2 ring-transparent transition-shadow group-data-[selected]/shapes:ring-[#60a5fa]" /> <span className="absolute bottom-[8%] left-[8%] h-[30%] w-[36%] rotate-[-6deg] rounded-sm bg-[#fde68a] ring-2 ring-transparent transition-shadow group-data-[selected]/shapes:ring-[#60a5fa]" /> </div> <svg className="pointer-events-none absolute inset-0 size-full overflow-visible"> <rect ref={selectRef} x="16%" y="18%" width="0" height="0" rx={4} className="fill-[#3b82f6]/15 stroke-[#60a5fa] opacity-0" strokeWidth={1} /> </svg> {/* Text block. */} <div className="absolute top-[20%] right-[6%] w-[34%] rounded-lg border border-white/15 bg-white/5 px-2.5 py-2 text-[11.5px] leading-4 text-white/85"> <span className="block text-[10px] tracking-wide text-white/45 uppercase">Note</span> <span ref={typedRef} /> <span ref={caretRef} className="ml-px inline-block h-3 w-px translate-y-0.5 bg-white opacity-0 data-[on]:animate-pulse data-[on]:opacity-100" /> </div> {/* Comment pin and thread. */} <div className="absolute top-[64%] left-[24%]"> <span className="flex size-6 items-center justify-center rounded-full rounded-bl-none bg-[#22c55e] text-[10px] font-semibold text-[#052e16] shadow">2</span> <div ref={threadRef} className="absolute bottom-7 left-0 w-44 origin-bottom-left scale-90 rounded-xl border border-white/10 bg-[#111a2e] p-2 text-[11px] opacity-0 shadow-xl transition-[opacity,transform] duration-300 data-[open]:scale-100 data-[open]:opacity-100 motion-reduce:transition-none" > <p> <span className="font-semibold" style={{ color: people[2]?.color }}> {people[2]?.name ?? "Lena"} </span>{" "} Can the circle sit a bit lower? </p> <p className="mt-1 text-white/70"> <span className="font-semibold" style={{ color: people[0]?.color }}> {people[0]?.name ?? "Maya"} </span>{" "} Done, nudged it 8px. </p> </div> </div> {/* Named cursors. */} {people.map((p, i) => ( <div key={p.name} ref={(el) => { cursorRefs.current[i] = el; }} className="absolute top-0 left-0 z-10" > <MousePointer2 className="size-4 -translate-x-0.5 -translate-y-0.5" style={{ color: p.color, fill: p.color }} /> <span className="ml-3 inline-block rounded-md px-1.5 py-px text-[10.5px] font-medium text-white shadow" style={{ background: p.color }}> {p.name} </span> </div> ))} <div ref={youRef} className="absolute top-0 left-0 z-20 opacity-0 transition-opacity"> <MousePointer2 className="size-4 -translate-x-0.5 -translate-y-0.5 fill-white text-white" /> <span className="ml-3 inline-block rounded-md bg-white px-1.5 py-px text-[10.5px] font-medium text-[#0d1424] shadow">You</span> </div> </div> </div> </div> <div className="space-y-1.5 p-5"> <h3 className="text-[17px] font-semibold tracking-tight">{title}</h3> {description ? <p className="text-[14px] leading-6 text-white/65">{description}</p> : null} </div> </article> ); }
Usage
import { MultiplayerCanvasCard } from "@/components/ui/multiplayer-canvas-card/multiplayer-canvas-card";
<MultiplayerCanvasCard
title="Build it together, live"
description="Cursors, selections and comments sync in real time."
collaborators={[
{ name: "Maya", color: "#3b82f6" },
{ name: "Jonas", color: "#f97316" },
{ name: "Lena", color: "#22c55e" },
]}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | "Build it together, live" | Feature title under the illustration. |
description | string | – | One-line description under the title. |
collaborators | Collaborator[] | Maya, Jonas and Lena | Up to three people whose cursors select, type and comment. |
Also accepts every native <article> attribute, including ref and className.
- One loop moves everything through refs: each cursor follows scripted cubic Bézier paths (in % of the canvas), the first drags a selection box that highlights the shapes, the second types into a note, the third opens a comment thread, and dashed lines flow from every cursor to the avatar stack.
- Hovering speeds the scene up slightly and shows the visitor's own cursor labelled “You”. The background is `FlowLines` from Flow Line Chat Card, which Multiplayer Canvas Card imports as a sibling component.
- Up to three collaborators are shown; pair it with Flow Line Chat Card for a two-card feature section.
Accessibility
- The scene is one image (role="img") with a text summary of who is doing what; its scripted cursors and shapes are hidden from screen readers. The title and description are real headings and text.
- Under reduced motion the scene holds a single moment (selection drawn, note typed, comment open) and nothing moves; it also pauses offscreen and in hidden tabs.