Skip to content
jswnthui

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

Installation

  1. 1. Install the dependencies.

    terminal
    npm install clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
emojisstring[]4 ingredientsThe bubbles, head first.
childrenReactNodeโ€”With children, the trail only follows the pointer over them and hides the cursor only there. Without, it takes the whole page.
classNamestringโ€”Applied to the wrapper, when there is one.