Live cursor
✕remind me to review the PR
Playground
Bubble
Text
Size15px
Hold2.40s
An iMessage bubble that follows your cursor, cycling through requests — each one slides up into the bubble and out the top. (ref: folk.com)
Code
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
export const meta = {
title: "Live cursor",
date: "2026-07-16",
size: "lg" as const,
tags: ["Interaction", "Motion"],
description:
"An iMessage bubble that follows your cursor, cycling through requests — each one slides up into the bubble and out the top. (ref: folk.com)",
prompt:
"Build a folk-style live cursor in React: a flat iMessage bubble with a tail and a sky-blue top that eases toward the real cursor with a slight velocity tilt, hides the OS cursor, and cycles a list of messages by sliding each one up into the bubble and out the top. Controls for bubble color, text color, font size, and hold time.",
};
export const params = {
bubbleColor: "#0a84ff",
textColor: "#ffffff",
fontSize: 15,
speed: 2.4,
};
export const controls = {
bubbleColor: { type: "color", label: "Bubble" },
textColor: { type: "color", label: "Text" },
fontSize: { type: "range", min: 12, max: 20, step: 1, unit: "px", label: "Size" },
speed: { type: "range", min: 1, max: 5, step: 0.1, unit: "s", label: "Hold" },
} as const;
export const presets = {
iMessage: { bubbleColor: "#0a84ff", textColor: "#ffffff" },
Grape: { bubbleColor: "#7c5cff", textColor: "#ffffff" },
Mint: { bubbleColor: "#10b981", textColor: "#ffffff" },
Ink: { bubbleColor: "#161616", textColor: "#ffffff" },
};
type Params = typeof params;
const MESSAGES = [
"remind me to review the PR",
"who did i meet at ethbangkok?",
"book a call with the team",
"add lunch with alex tomorrow",
"draft a reply to the investor",
"what did i ship this week?",
];
const FONT = "ui-sans-serif, -apple-system, system-ui, 'Segoe UI', Roboto, sans-serif";
const FOLLOW = 0.16; // how quickly the bubble catches the cursor
const TILT_MAX = 4; // degrees; the velocity tilt is clamped to this
const SLIDE_IN = 400;
const SLIDE_OUT = 340;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/** Eases the bubble toward the cursor with a slight velocity tilt that springs upright. */
function useCursorFollow(hostRef: RefObject<HTMLDivElement>) {
const [pos, setPos] = useState({ x: 140, y: 100, rot: 0 });
const target = useRef({ x: 140, y: 100 });
useEffect(() => {
const s = { x: 140, y: 100, prevX: 140, rot: 0, rotVel: 0 };
let raf = 0;
const tick = () => {
s.x += (target.current.x - s.x) * FOLLOW;
s.y += (target.current.y - s.y) * FOLLOW;
const aim = clamp((s.x - s.prevX) * 0.5, -TILT_MAX, TILT_MAX);
s.prevX = s.x;
s.rotVel = (s.rotVel + (aim - s.rot) * 0.1) * 0.8; // spring + damping
s.rot += s.rotVel;
setPos({ x: s.x, y: s.y, rot: s.rot });
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const onMove = (e: React.MouseEvent) => {
const r = hostRef.current?.getBoundingClientRect();
if (r) target.current = { x: e.clientX - r.left, y: e.clientY - r.top };
};
return { pos, onMove };
}
/** Slides the current message in, holds, slides it out, then advances. */
function useMessageCycle(count: number, holdMs: number) {
const [index, setIndex] = useState(0);
const [phase, setPhase] = useState<"in" | "out">("in");
useEffect(() => {
setPhase("in");
const toOut = setTimeout(() => setPhase("out"), SLIDE_IN + holdMs);
const toNext = setTimeout(() => setIndex((i) => (i + 1) % count), SLIDE_IN + holdMs + SLIDE_OUT);
return () => {
clearTimeout(toOut);
clearTimeout(toNext);
};
}, [index, holdMs, count]);
return { index, phase };
}
export default function Cursor({ bubbleColor, textColor, fontSize, speed }: Params) {
const hostRef = useRef<HTMLDivElement>(null);
const { pos, onMove } = useCursorFollow(hostRef);
const { index, phase } = useMessageCycle(MESSAGES.length, speed * 1000);
// Pre-measure each message; during the exit, size to the next one so the incoming
// text isn't clipped while the bubble is still resizing.
const sizers = useRef<(HTMLSpanElement | null)[]>([]);
const [widths, setWidths] = useState<number[]>([]);
useEffect(() => setWidths(sizers.current.map((el) => el?.offsetWidth ?? 0)), [fontSize]);
const shown = phase === "out" ? (index + 1) % MESSAGES.length : index;
const textStyle = { fontFamily: FONT, fontWeight: 500, fontSize };
return (
<div
ref={hostRef}
onMouseMove={onMove}
className="relative h-full w-full cursor-none overflow-hidden bg-[#f5f5f6]"
>
<div
className="pointer-events-none absolute left-0 top-0 will-change-transform"
style={{
transform: `translate3d(${pos.x}px, ${pos.y}px, 0) rotate(${pos.rot}deg)`,
transformOrigin: "50% 0%",
}}
>
<div
className="msg-bubble"
style={
{ ...textStyle, color: textColor, lineHeight: 1.25, "--bub": bubbleColor } as React.CSSProperties
}
>
<div className="msg-clip" style={{ width: widths[shown] }}>
<span
key={`${index}-${phase}`}
className={`whitespace-nowrap ${phase === "out" ? "msg-out" : "msg-in"}`}
>
{MESSAGES[index]}
</span>
</div>
{/* off-screen copies, one per message, used only to measure widths */}
<div aria-hidden className="invisible absolute left-0 top-0" style={textStyle}>
{MESSAGES.map((m, i) => (
<span
key={i}
ref={(el) => {
sizers.current[i] = el;
}}
className="block w-fit whitespace-nowrap"
>
{m}
</span>
))}
</div>
<Tail color={bubbleColor} />
</div>
</div>
</div>
);
}
function Tail({ color }: { color: string }) {
return (
<svg
width="17"
height="17"
viewBox="0 0 17 17"
aria-hidden
className="absolute bottom-[-1px] right-[-4px]"
style={{ color }}
>
<path
fill="currentColor"
d="M1 1 C1 8.5 4.5 14 12.5 15.5 C9 13.5 8 9.5 8 3 C8 1.3 6.5 1 1 1 Z"
/>
</svg>
);
}
dateJul 16, 2026
tagsInteraction, Motion