Multiplayer Text
A headline visited by named multiplayer hand cursors that glide to a word, click it, and claim it in that teammate's ink.
Crafted for your next SaaS
On each beat one hand glides to a word and clicks it: the finger presses down, the word squashes, then wobbles back on a playful spring wearing that teammate's color. The ink follows the person, so a word cools back to the headline's own color when its hand moves on. The choreography is seeded, so it is identical on every visit, and it only runs while on screen. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/multiplayer-text.tsx.
'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;const SPRING = { type: 'spring', duration: 0.5, bounce: 0.16 } as const;const DROP = { type: 'spring', duration: 0.55, bounce: 0.28 } as const;const TRAVEL_S = 0.65;const PRESS_MS = 170;function seeded(n: number) { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x);}const HAND_POINT = 'M9 14 L9 4 A2.5 2.5 0 0 1 14 4 L14 12 L16 12 A6 6 0 0 1 22 18 L22 20 A7 7 0 0 1 15 27 L13 27 A8 8 0 0 1 5 19 L5 17.5 A4 4 0 0 1 9 14 Z';const HAND_PRESS = 'M9 14 L9 10.5 A2.5 2.5 0 0 1 14 10.5 L14 12 L16 12 A6 6 0 0 1 22 18 L22 20 A7 7 0 0 1 15 27 L13 27 A8 8 0 0 1 5 19 L5 17.5 A4 4 0 0 1 9 14 Z';function HandCursor({ color, pressed }: { color: string; pressed: boolean }) { return ( <svg width="26" height="30" viewBox="0 0 26 30" fill="none" aria-hidden> <path d={pressed ? HAND_PRESS : HAND_POINT} fill={color} stroke="#141612" strokeWidth="1.75" strokeLinejoin="round" strokeLinecap="round" /> <path d="M12 17.5v4 M15 17.5v4 M18 17.5v4" stroke="#141612" strokeWidth="1.5" strokeLinecap="round" /> </svg> );}export type MultiplayerTextPlayer = { name: string; color: string;};export type MultiplayerTextProps = { text: string; players?: MultiplayerTextPlayer[]; interval?: number; scatter?: boolean; className?: string;};type CursorPose = { x: number; y: number; rot: number } | null;const DEFAULT_PLAYERS: MultiplayerTextPlayer[] = [ { name: 'Mira', color: '#3ecf8e' }, { name: 'Jonas', color: '#f0883e' }, { name: 'Ada', color: '#22d3ee' },];export function MultiplayerText({ text, players = DEFAULT_PLAYERS, interval = 1400, scatter = true, className,}: MultiplayerTextProps) { const reduce = useReducedMotion(); const words = React.useMemo(() => text.split(/\s+/).filter(Boolean), [text]); const containerRef = React.useRef<HTMLDivElement>(null); const wordRefs = React.useRef<(HTMLSpanElement | null)[]>([]); const centersRef = React.useRef<{ x: number; y: number }[]>([]); const sizeRef = React.useRef({ w: 0, h: 0 }); const targetsRef = React.useRef<number[]>([]); const playersRef = React.useRef(players); playersRef.current = players; const [ready, setReady] = React.useState(false); const [inView, setInView] = React.useState(false); const [cursors, setCursors] = React.useState<CursorPose[]>(() => players.map(() => null), ); const [owners, setOwners] = React.useState<(number | null)[]>([]); const [pressed, setPressed] = React.useState<{ player: number; word: number; } | null>(null); const playersKey = players.map((p) => p.name + p.color).join('|'); React.useLayoutEffect(() => { const el = containerRef.current; if (!el) return; const measure = () => { const crect = el.getBoundingClientRect(); sizeRef.current = { w: crect.width, h: crect.height }; centersRef.current = wordRefs.current.slice(0, words.length).map((w) => { if (!w) return { x: crect.width / 2, y: crect.height / 2 }; const r = w.getBoundingClientRect(); return { x: r.left - crect.left + r.width / 2, y: r.top - crect.top + r.height / 2, }; }); setReady(true); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, [words.length]); React.useEffect(() => setOwners([]), [text]); React.useEffect(() => { const el = containerRef.current; if (!el) return; const io = new IntersectionObserver( ([entry]) => setInView(entry.isIntersecting), { threshold: 0.3 }, ); io.observe(el); return () => io.disconnect(); }, []); React.useEffect(() => { if (!ready) return; const ps = playersRef.current; const count = Math.max(words.length, 1); if (reduce) { const claimed: (number | null)[] = words.map(() => null); setCursors( ps.map((_, i) => { const wi = Math.floor(seeded(i * 7.31 + 3) * count) % count; claimed[wi] = i; const c = centersRef.current[wi]; return c ? { x: c.x + 10, y: c.y + 12, rot: (seeded(i + 53) - 0.5) * 20 } : null; }), ); setOwners(claimed); return; } setCursors((prev) => prev.some(Boolean) ? prev : ps.map((_, i) => ({ x: sizeRef.current.w * (0.12 + 0.76 * seeded(i * 9.7 + 5)), y: sizeRef.current.h * (i % 2 === 0 ? 0.1 : 0.85), rot: (seeded(i + 41) - 0.5) * 20, })), ); }, [ready, reduce, words, playersKey]); React.useEffect(() => { if (!ready || !inView || reduce) return; const count = words.length; if (count === 0) return; let cancelled = false; const timers: ReturnType<typeof setTimeout>[] = []; let step = 0; const beat = Math.max(interval, 900); const tick = () => { if (cancelled) return; const p = step % playersRef.current.length; const taken = new Set(targetsRef.current); let wi = Math.floor(seeded(step * 7.31 + 3) * count) % count; for (let tries = 0; tries < count && taken.has(wi); tries += 1) { wi = (wi + 1) % count; } const prevWord = targetsRef.current[p]; targetsRef.current[p] = wi; if (prevWord !== undefined) { setOwners((prev) => { if (prev[prevWord] !== p) return prev; const next = [...prev]; next[prevWord] = null; return next; }); } const s = step; const c = centersRef.current[wi]; if (c) { const dx = (seeded(s * 3.7 + 17) - 0.5) * 26; const dy = (seeded(s * 5.3 + 29) - 0.5) * 12 + 8; const rot = (seeded(s * 2.9 + 53) - 0.5) * 26; setCursors((prev) => { const next = [...prev]; next[p] = { x: c.x + dx, y: c.y + dy, rot }; return next; }); timers.push( setTimeout(() => { if (cancelled) return; setPressed({ player: p, word: wi }); setOwners((prev) => { const next = words.map((_, i) => prev[i] ?? null); next[wi] = p; return next; }); timers.push( setTimeout(() => { if (!cancelled) setPressed(null); }, PRESS_MS), ); }, TRAVEL_S * 1000), ); } step += 1; timers.push(setTimeout(tick, beat)); }; timers.push(setTimeout(tick, 400)); return () => { cancelled = true; timers.forEach(clearTimeout); }; }, [ready, inView, reduce, interval, playersKey, words]); return ( <div ref={containerRef} className={cn('relative', className)}> <p className="text-balance"> {words.map((word, i) => { const owner = owners[i] ?? null; const isPressed = pressed?.word === i; const baseRot = scatter ? (seeded(i * 1.7 + 13) - 0.5) * 6 : 0; const baseY = scatter ? (seeded(i * 2.9 + 31) - 0.5) * 0.3 : 0; return ( <React.Fragment key={i}> <motion.span ref={(el) => { wordRefs.current[i] = el; }} className="inline-block whitespace-pre transition-colors" style={{ color: owner !== null ? players[owner]?.color : undefined, transitionDuration: owner !== null ? '150ms' : '500ms', }} animate={ reduce ? { rotate: baseRot, y: `${baseY}em` } : { scale: isPressed ? 0.88 : 1, rotate: isPressed ? baseRot - 5 : baseRot, y: isPressed ? `${baseY + 0.08}em` : `${baseY}em`, } } transition={ isPressed ? { duration: 0.09, ease: EASE_OUT } : DROP } > {word} </motion.span> {i < words.length - 1 ? ' ' : null} </React.Fragment> ); })} </p> <div aria-hidden className="pointer-events-none absolute inset-0 select-none" > {players.map((pl, i) => { const pose = cursors[i]; const isPressing = pressed?.player === i; return ( <motion.div key={pl.name + i} className="absolute left-0 top-0" initial={false} animate={{ transform: pose ? `translate3d(${pose.x}px, ${pose.y}px, 0)` : 'translate3d(-48px, -48px, 0)', opacity: pose ? 1 : 0, }} transition={ reduce ? { duration: 0 } : { transform: { duration: TRAVEL_S, ease: EASE_IN_OUT }, opacity: { duration: 0.3, ease: EASE_OUT, delay: i * 0.06, }, } } > <motion.span className="block [transform-origin:11px_6px] [filter:drop-shadow(0_1px_1px_rgba(0,0,0,0.3))_drop-shadow(0_4px_8px_rgba(0,0,0,0.2))]" animate={{ scale: isPressing && !reduce ? 0.9 : 1, rotate: pose?.rot ?? 0, }} transition={ isPressing ? { duration: 0.09, ease: EASE_OUT } : SPRING } > <HandCursor color={pl.color} pressed={isPressing && !reduce} /> </motion.span> <span className="absolute left-[18px] top-[26px] whitespace-nowrap rounded-full border-[1.5px] border-[#141612] px-2.5 py-0.5 text-[12px] font-bold tracking-[-0.01em] text-[#141612] shadow-[0_2px_6px_-1px_rgba(0,0,0,0.25)]" style={{ backgroundColor: pl.color }} > {pl.name} </span> </motion.div> ); })} </div> </div> );}Usage
import { MultiplayerText } from '@/components/ui/multiplayer-text';
export default function Example() {
return (
<MultiplayerText
text="Crafted for your next SaaS"
className="text-3xl font-semibold"
/>
);
}Examples
Pass your own teammates as players.
Reviewed by real people
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | - | The headline. Each word is a click target. |
players | MultiplayerTextPlayer[] | Mira, Jonas, Ada | The teammates roaming the headline. |
interval | number | 1400 | Milliseconds between cursor moves. |
scatter | boolean | true | Seeded per-word tilt and baseline drift. |
className | string | - | Sets typography on the headline. |
Each player is { name: string; color: string }.