liten

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.

Terminal
bun add motion

Copy the component into components/ui/multiplayer-text.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
textstring-The headline. Each word is a click target.
playersMultiplayerTextPlayer[]Mira, Jonas, AdaThe teammates roaming the headline.
intervalnumber1400Milliseconds between cursor moves.
scatterbooleantrueSeeded per-word tilt and baseline drift.
classNamestring-Sets typography on the headline.

Each player is { name: string; color: string }.

On this page0%