Home/Components/Custome Cursor/Liquid Trail Cursor - React Gooey Metaball Cursor
Code · Live Preview
Componenttsx
// src/components/LiquidCursor/LiquidCursor.tsx
"use client";

/**
 * Liquid Trail Cursor
 * A gooey, metaball-style cursor: a chain of blobs trails the pointer and melts
 * together like liquid. It swells over links and buttons, squishes when you
 * press, and splashes droplets on click that pull apart and merge back in.
 * Over links and buttons the blob turns see-through, so their text stays readable.
 *
 * Stack: React + TypeScript + Tailwind CSS v4
 * Deps:  none (runs on a single requestAnimationFrame loop, no re-renders)
 *
 * Overrides on any element:
 *   data-cursor="pointer" → swell as if it were a link
 *   data-cursor="hide"    → hide the cursor in this area
 *   data-cursor="default" → never swell here
 *
 * Only runs on devices with a mouse / trackpad (hover + fine pointer).
 */

import { useEffect, useId, useRef, useSyncExternalStore } from "react";

export interface LiquidCursorProps {
  /** Blob colour (any CSS colour) */
  color?: string;
  /** Head blob diameter in px */
  size?: number;
  /** Number of blobs in the trail (more = longer tail) */
  trail?: number;
  /** How tightly each blob follows the one ahead (0.1 = lazy, 0.6 = tight) */
  follow?: number;
  /** How much the head grows over links / buttons */
  hoverScale?: number;
  /** Blob opacity over links / buttons (0–1), so their content shows through */
  hoverOpacity?: number;
  /** Droplets on click */
  splash?: boolean;
  /** Invert whatever is underneath instead of using `color` */
  invert?: boolean;
}

const INTERACTIVE =
  'a[href], button:not(:disabled), [role="button"], label[for], select, summary, input[type="checkbox"], input[type="radio"], input[type="submit"], input[type="button"], input[type="range"]';

const SPARKS = 8;

function useMediaQuery(query: string): boolean {
  return useSyncExternalStore(
    (onChange) => {
      const m = window.matchMedia(query);
      m.addEventListener("change", onChange);
      return () => m.removeEventListener("change", onChange);
    },
    () => window.matchMedia(query).matches,
    () => false,
  );
}

function useReducedMotionPref(): boolean {
  return useMediaQuery("(prefers-reduced-motion: reduce)");
}

type Mode = "default" | "pointer" | "hide";

function modeFor(target: EventTarget | null): Mode {
  if (!(target instanceof Element)) return "default";
  const explicit = target.closest<HTMLElement>("[data-cursor]");
  const v = explicit?.dataset.cursor;
  if (v === "hide") return "hide";
  if (v === "pointer") return "pointer";
  if (v === "default") return "default";
  return target.closest(INTERACTIVE) ? "pointer" : "default";
}

export default function LiquidCursor({
  color = "#7c5cff",
  size = 28,
  trail = 12,
  follow = 0.35,
  hoverScale = 2.2,
  hoverOpacity = 0.3,
  splash = true,
  invert = false,
}: LiquidCursorProps) {
  const enabled = useMediaQuery("(hover: hover) and (pointer: fine)");
  const reduce = useReducedMotionPref();
  const filterId = `goo-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;

  const count = reduce ? 1 : Math.max(1, trail);
  const sizes = Array.from({ length: count }, (_, i) =>
    Math.round(size * (1 - (i / count) * 0.72)),
  );

  const layerRef = useRef<HTMLDivElement>(null);
  const blobRefs = useRef<(HTMLDivElement | null)[]>([]);
  const sparkRefs = useRef<(HTMLDivElement | null)[]>([]);

  useEffect(() => {
    if (!enabled) return;

    const root = document.documentElement;
    root.classList.add("lc-active");

    const OFF = -500;
    const mouse = { x: OFF, y: OFF };
    const pts = Array.from({ length: count }, () => ({ x: OFF, y: OFF }));
    const sparks = Array.from({ length: SPARKS }, () => ({
      x: 0,
      y: 0,
      vx: 0,
      vy: 0,
      life: 0,
    }));
    const blobSizes = Array.from({ length: count }, (_, i) =>
      Math.round(size * (1 - (i / count) * 0.72)),
    );

    // Invert mode already shows the content through, so it stays solid
    const hoverAlpha = invert ? 1 : Math.min(Math.max(hoverOpacity, 0), 1);

    let mode: Mode = "default";
    let hover = 1;
    let press = 1;
    let pressTarget = 1;
    let vis = 0;
    let visTarget = 0;
    let alpha = 1;
    let first = true;
    let raf = 0;

    const onMove = (e: PointerEvent) => {
      if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
      mouse.x = e.clientX;
      mouse.y = e.clientY;
      if (first) {
        // Start the whole trail at the pointer instead of flying in from the corner
        pts.forEach((p) => {
          p.x = mouse.x;
          p.y = mouse.y;
        });
        first = false;
      }
      visTarget = 1;
    };
    const onOver = (e: PointerEvent) => {
      mode = modeFor(e.target);
    };
    const onScroll = () => {
      mode = modeFor(document.elementFromPoint(mouse.x, mouse.y));
    };
    const onOut = (e: MouseEvent) => {
      if (!e.relatedTarget) visTarget = 0;
    };
    const onDown = () => {
      pressTarget = 0.75;
      if (!splash || reduce || mode === "hide") return;
      const burst = 5 + Math.floor(Math.random() * 4);
      for (let i = 0; i < SPARKS && i < burst; i++) {
        const a = (i / burst) * Math.PI * 2 + Math.random() * 0.6;
        const speed = 3.5 + Math.random() * 3.5;
        const s = sparks[i];
        s.x = mouse.x;
        s.y = mouse.y;
        s.vx = Math.cos(a) * speed;
        s.vy = Math.sin(a) * speed;
        s.life = 1;
      }
    };
    const onUp = () => {
      pressTarget = 1;
    };

    const tick = () => {
      const hoverTarget = mode === "pointer" ? hoverScale : 1;
      const alphaTarget = mode === "pointer" ? hoverAlpha : 1;
      const shown = mode === "hide" ? 0 : visTarget;

      hover += (hoverTarget - hover) * 0.18;
      press += (pressTarget - press) * 0.3;
      vis += (shown - vis) * 0.2;
      alpha += (alphaTarget - alpha) * 0.2;

      // Fade the whole goo layer (after the filter, so the shape stays intact)
      if (layerRef.current) layerRef.current.style.opacity = alpha.toFixed(3);

      // Head chases the pointer, every other blob chases the one ahead of it
      const headFollow = reduce ? 1 : Math.min(follow * 1.6, 1);
      pts[0].x += (mouse.x - pts[0].x) * headFollow;
      pts[0].y += (mouse.y - pts[0].y) * headFollow;
      for (let i = 1; i < count; i++) {
        pts[i].x += (pts[i - 1].x - pts[i].x) * follow;
        pts[i].y += (pts[i - 1].y - pts[i].y) * follow;
      }

      for (let i = 0; i < count; i++) {
        const el = blobRefs.current[i];
        if (!el) continue;
        const s = blobSizes[i];
        // Head swells on hover; the tail tucks in so it reads as one big drop
        const k = (i === 0 ? hover * press : 1 / Math.sqrt(hover)) * vis;
        el.style.transform = `translate3d(${pts[i].x - s / 2}px, ${pts[i].y - s / 2}px, 0) scale(${k})`;
      }

      for (let i = 0; i < SPARKS; i++) {
        const el = sparkRefs.current[i];
        if (!el) continue;
        const s = sparks[i];
        if (s.life > 0) {
          s.x += s.vx;
          s.y += s.vy;
          s.vx *= 0.9;
          s.vy *= 0.9;
          s.life -= 0.028;
        }
        const k = Math.max(s.life, 0) * vis;
        el.style.transform = `translate3d(${s.x - 7}px, ${s.y - 7}px, 0) scale(${k})`;
      }

      raf = requestAnimationFrame(tick);
    };

    window.addEventListener("pointermove", onMove, { passive: true });
    document.addEventListener("pointerover", onOver);
    document.addEventListener("mouseout", onOut);
    window.addEventListener("pointerdown", onDown);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("blur", onUp);
    window.addEventListener("scroll", onScroll, { passive: true });
    raf = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(raf);
      root.classList.remove("lc-active");
      window.removeEventListener("pointermove", onMove);
      document.removeEventListener("pointerover", onOver);
      document.removeEventListener("mouseout", onOut);
      window.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("blur", onUp);
      window.removeEventListener("scroll", onScroll);
    };
  }, [
    enabled,
    reduce,
    count,
    size,
    follow,
    hoverScale,
    hoverOpacity,
    splash,
    invert,
  ]);

  if (!enabled) return null;

  const fill = invert ? "#ffffff" : color;

  return (
    <>
      <style>{`html.lc-active, html.lc-active * { cursor: none !important; }`}</style>

      {/* Goo filter: blur the blobs, then sharpen the alpha so they fuse */}
      <svg aria-hidden="true" className="pointer-events-none fixed size-0">
        <defs>
          <filter id={filterId}>
            <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
            <feColorMatrix
              in="blur"
              mode="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10"
            />
          </filter>
        </defs>
      </svg>

      <div
        ref={layerRef}
        aria-hidden="true"
        className="pointer-events-none fixed inset-0 z-[9999]"
        style={{
          filter: `url(#${filterId})`,
          mixBlendMode: invert ? "difference" : undefined,
        }}
      >
        {sizes.map((s, i) => (
          <div
            key={i}
            ref={(el) => {
              blobRefs.current[i] = el;
            }}
            className="absolute left-0 top-0 rounded-full will-change-transform"
            style={{
              width: s,
              height: s,
              background: fill,
              transform: "scale(0)",
            }}
          />
        ))}
        {Array.from({ length: SPARKS }, (_, i) => (
          <div
            key={`s-${i}`}
            ref={(el) => {
              sparkRefs.current[i] = el;
            }}
            className="absolute left-0 top-0 size-3.5 rounded-full will-change-transform"
            style={{ background: fill, transform: "scale(0)" }}
          />
        ))}
      </div>
    </>
  );
}
// Component Source: https://codekk.dev/
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

A gooey, metaball-style custom cursor for React built with TypeScript and Tailwind CSS v4, with no animation library needed. A chain of blobs trails behind the pointer and melts together like liquid, using an SVG goo filter that blurs the blobs and then sharpens their edges so they fuse into one shape. When you move fast, it stretches into a long, wobbly tail, and when you stop, it pulls back into a single drop. Over links and buttons, the head swells into a big drop and the tail tucks in, so it reads as one rounded bead. The drop also turns see-through, so button text and icons stay readable underneath it. Pressing the mouse squishes the drop, and clicking splashes out droplets that fly apart and fade away. Props control the color, size, tail length, how tightly the blobs follow each other, how much the drop swells, and how see-through it gets over buttons. An invert option swaps the color for a difference blend that inverts whatever is underneath, so the cursor stays visible on any background. Data attributes let you force the swell on any element, hide the cursor in a section, or stop it from swelling in specific areas. Everything runs in a single requestAnimationFrame loop that moves the blobs directly, so moving the mouse never causes React to re-render. It only turns on for devices with a mouse or trackpad. On phones and tablets it doesn't render at all, and touch behavior stays normal. When prefers-reduced-motion is enabled, the tail and splash are switched off and it becomes a single blob that follows the pointer. All cursor layers are hidden from screen readers and ignore pointer events, so clicks and keyboard navigation work exactly as before. It works in Vite, Create React App, and Next.js.

Added Oct 2, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation