Motion
Emoji Cursor
A chain of emoji bubbles strung on a thin curve, each one chasing the one ahead of it behind the pointer.
Morni
Move around in here
The chain only follows the pointer over the card, and hides the cursor only there
import EmojiCursor from "@/components/ui/emoji-cursor";
export default function EmojiCursorDemo({ fullscreen }: { fullscreen?: boolean }) {
const stage = (
<div className="grid h-full w-full place-items-center bg-[#291C0E] text-center">
<div className="space-y-3">
<p className="font-morni-brand text-7xl leading-none text-[#A78D78] md:text-8xl">Morni</p>
<p className="font-morni-body text-sm text-[#BEB5A9]">Move around in here</p>
</div>
</div>
);
if (fullscreen) {
return (
<EmojiCursor className="h-dvh w-full">
{stage}
</EmojiCursor>
);
}
return (
<div className="w-full min-w-0 space-y-3">
<EmojiCursor className="h-[420px] w-full overflow-hidden rounded-card border border-line">
{stage}
</EmojiCursor>
<p className="text-center text-xs text-faint">
The chain only follows the pointer over the card, and hides the cursor only there
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/emoji-cursor.tsx "use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { cn } from "@/lib/utils"; const EMOJIS = ["๐พ", "๐", "๐ง ", "๐ถ๏ธ"]; /** How quickly each bubble closes on the one ahead of it โ the head is the keenest, and each one after it lags a little more. */ const LERP = [0.18, 0.13, 0.09, 0.06]; const SIZE = 46; function catmullRom(pts: { x: number; y: number }[]) { if (pts.length < 2) return ""; let d = `M ${pts[0]!.x} ${pts[0]!.y}`; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[Math.max(0, i - 1)]!; const p1 = pts[i]!; const p2 = pts[i + 1]!; const p3 = pts[Math.min(pts.length - 1, i + 2)]!; const cp1x = p1.x + (p2.x - p0.x) / 6; const cp1y = p1.y + (p2.y - p0.y) / 6; const cp2x = p2.x - (p3.x - p1.x) / 6; const cp2y = p2.y - (p3.y - p1.y) / 6; d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`; } return d; } type EmojiCursorProps = { /** The bubbles, head first. Four reads best; the lag tuning has four steps. */ emojis?: string[]; /** With children, the trail only follows the pointer while it is over them, and only hides the cursor there. Without, it takes the whole page. */ children?: ReactNode; className?: string; }; /** A chain of emoji bubbles that trails the pointer, strung on a thin curve, * each bubble chasing the one ahead of it. */ export default function EmojiCursor({ emojis = EMOJIS, children, className }: EmojiCursorProps) { const scoped = children !== undefined; const [isPointer, setIsPointer] = useState(false); const [inside, setInside] = useState(!scoped); const wrapRef = useRef<HTMLDivElement | null>(null); const circleRefs = useRef<(HTMLDivElement | null)[]>([]); const pathRef = useRef<SVGPathElement | null>(null); const nodes = useRef(Array.from({ length: LERP.length }, () => ({ x: -200, y: -200 }))); const mouse = useRef({ x: -200, y: -200 }); const rafId = useRef(0); useEffect(() => { // Only run on devices with a real pointer (not touch-only) if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return; setIsPointer(true); const target: HTMLElement | Window = scoped && wrapRef.current ? wrapRef.current : window; const onMove = (e: Event) => { const { clientX, clientY } = e as MouseEvent; mouse.current = { x: clientX, y: clientY }; }; // Scoped, the chain starts where the pointer comes in rather than // flying across from wherever it was last seen. const onEnter = (e: Event) => { const { clientX, clientY } = e as MouseEvent; mouse.current = { x: clientX, y: clientY }; nodes.current.forEach((n) => { n.x = clientX; n.y = clientY; }); setInside(true); }; const onLeave = () => setInside(false); target.addEventListener("mousemove", onMove); if (scoped) { target.addEventListener("mouseenter", onEnter); target.addEventListener("mouseleave", onLeave); } const tick = () => { const n = nodes.current; n[0]!.x += (mouse.current.x - n[0]!.x) * LERP[0]!; n[0]!.y += (mouse.current.y - n[0]!.y) * LERP[0]!; for (let i = 1; i < n.length; i++) { n[i]!.x += (n[i - 1]!.x - n[i]!.x) * LERP[i]!; n[i]!.y += (n[i - 1]!.y - n[i]!.y) * LERP[i]!; } circleRefs.current.forEach((el, i) => { if (el && n[i]) { el.style.left = `${n[i].x - SIZE / 2}px`; el.style.top = `${n[i].y - SIZE / 2}px`; } }); if (pathRef.current) { pathRef.current.setAttribute("d", catmullRom(n.slice(0, emojis.length))); } rafId.current = requestAnimationFrame(tick); }; rafId.current = requestAnimationFrame(tick); return () => { target.removeEventListener("mousemove", onMove); target.removeEventListener("mouseenter", onEnter); target.removeEventListener("mouseleave", onLeave); cancelAnimationFrame(rafId.current); }; }, [scoped, emojis.length]); const trail = isPointer && ( <div aria-hidden className="transition-opacity duration-300" style={{ opacity: inside ? 1 : 0 }} > {!scoped && <style>{`* { cursor: none !important; }`}</style>} <svg className="pointer-events-none fixed inset-0" style={{ width: "100vw", height: "100vh", zIndex: 9998 }} > <path ref={pathRef} stroke="rgba(225,212,194,0.45)" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" /> </svg> {emojis.slice(0, LERP.length).map((emoji, i) => ( <div key={i} ref={(el) => { circleRefs.current[i] = el; }} className="pointer-events-none fixed flex items-center justify-center rounded-full" style={{ width: SIZE, height: SIZE, zIndex: 9999, border: "1.5px solid rgba(225,212,194,0.65)", background: "rgba(41,28,14,0.45)", backdropFilter: "blur(3px)", fontSize: "20px", lineHeight: 1, }} > {emoji} </div> ))} </div> ); if (!scoped) return trail || null; return ( <div ref={wrapRef} className={cn(isPointer && "cursor-none [&_*]:cursor-none", className)}> {children} {trail} </div> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| emojis | string[] | 4 ingredients | The bubbles, head first. |
| children | ReactNode | โ | With children, the trail only follows the pointer over them and hides the cursor only there. Without, it takes the whole page. |
| className | string | โ | Applied to the wrapper, when there is one. |