Fazekit

Code

"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface LiquidCursorProps {
  children: React.ReactNode;
  /** Blob diameter in px. */
  size?: number;
  /** Number of trailing droplets that merge into the blob. */
  trail?: number;
  /** Blob colour. White + "difference" inverts whatever is underneath in both themes. */
  color?: string;
  blend?: React.CSSProperties["mixBlendMode"];
  /** Elements the blob wraps around. */
  targets?: string;
  /** Hide the system cursor inside the area. */
  hideNativeCursor?: boolean;
  /** Blur radius of the goo filter. */
  goo?: number;
  className?: string;
}

interface Pt {
  x: number;
  y: number;
}

export function LiquidCursor({
  children,
  size = 26,
  trail = 5,
  color = "#ffffff",
  blend = "difference",
  targets = "a, button, [data-cursor]",
  hideNativeCursor = true,
  goo = 7,
  className,
}: LiquidCursorProps) {
  const reduce = useReducedMotion() ?? false;
  const scopeRef = React.useRef<HTMLDivElement>(null);
  const blobRef = React.useRef<HTMLDivElement>(null);
  const labelRef = React.useRef<HTMLSpanElement>(null);
  const dotRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const filterId = `goo-${React.useId().replace(/:/g, "")}`;
  const [enabled, setEnabled] = React.useState(false);

  const state = React.useRef({
    pointer: null as Pt | null,
    target: null as Element | null,
    label: "",
    visible: false,
  });

  React.useEffect(() => {
    const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
    const upd = () => setEnabled(mq.matches);
    upd();
    mq.addEventListener("change", upd);
    return () => mq.removeEventListener("change", upd);
  }, []);

  React.useEffect(() => {
    const scope = scopeRef.current;
    const blob = blobRef.current;
    if (!enabled || !scope || !blob) return;
    const count = reduce ? 0 : trail;
    const pos: Pt = { x: -100, y: -100 };
    const dim = { w: size, h: size, r: size / 2 };
    const dots: Pt[] = Array.from({ length: count }, () => ({ x: -100, y: -100 }));
    let last = { x: -100, y: -100 };
    let raf = 0;
    let running = true;
    let opacity = 0;
    let magnet = 0;

    const onMove = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      const r = scope.getBoundingClientRect();
      const p = { x: e.clientX - r.left, y: e.clientY - r.top };
      if (!state.current.pointer) {
        pos.x = p.x;
        pos.y = p.y;
        dots.forEach((d) => ((d.x = p.x), (d.y = p.y)));
      }
      state.current.pointer = p;
      const hit = (e.target as Element | null)?.closest?.(targets);
      const t = hit && scope.contains(hit) && hit.getAttribute("data-cursor") !== "none" ? hit : null;
      state.current.target = t;
      state.current.label = t?.getAttribute("data-cursor-label") ?? "";
      state.current.visible = true;
    };
    const onLeave = () => {
      state.current.visible = false;
      state.current.target = null;
    };
    const onDown = () => blob.animate([{ scale: "1" }, { scale: "0.82" }, { scale: "1" }], { duration: 260, easing: "ease-out" });
    scope.addEventListener("pointermove", onMove);
    scope.addEventListener("pointerleave", onLeave);
    scope.addEventListener("pointerdown", onDown);

    const io = new IntersectionObserver(([e]) => {
      running = e.isIntersecting;
      cancelAnimationFrame(raf);
      if (running) {
        prev = performance.now();
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(scope);

    let prev = performance.now();
    const loop = (now: number) => {
      if (!running) return;
      const f = Math.min(3, (now - prev) / 16.67);
      prev = now;
      const s = state.current;
      const p = s.pointer;
      const scopeRect = scope.getBoundingClientRect();
      let goalX = p?.x ?? pos.x;
      let goalY = p?.y ?? pos.y;
      let goalW = size;
      let goalH = size;
      let goalR = size / 2;
      const hasLabel = !!s.label;
      if (s.target && p) {
        const tr = s.target.getBoundingClientRect();
        const cx = tr.left - scopeRect.left + tr.width / 2;
        const cy = tr.top - scopeRect.top + tr.height / 2;
        if (hasLabel) {
          goalW = goalH = size * 3;
          goalR = goalW / 2;
        } else {
          const pad = 6;
          goalW = tr.width + pad * 2;
          goalH = tr.height + pad * 2;
          const br = parseFloat(getComputedStyle(s.target).borderTopLeftRadius) || 8;
          goalR = Math.min(goalH / 2, br + pad);
          // Magnetic pull: stay mostly centred, lean toward the pointer.
          goalX = cx + (p.x - cx) * 0.12;
          goalY = cy + (p.y - cy) * 0.12;
        }
      }
      magnet += ((s.target && !hasLabel ? 1 : 0) - magnet) * 0.2 * f;
      const k = reduce ? 1 : 1 - Math.pow(1 - 0.28, f);
      const kd = reduce ? 1 : 1 - Math.pow(1 - 0.2, f);
      pos.x += (goalX - pos.x) * k;
      pos.y += (goalY - pos.y) * k;
      dim.w += (goalW - dim.w) * kd;
      dim.h += (goalH - dim.h) * kd;
      dim.r += (goalR - dim.r) * kd;
      opacity += ((s.visible ? 1 : 0) - opacity) * 0.2 * f;

      // Squash & stretch along the velocity when free.
      const vx = pos.x - last.x;
      const vy = pos.y - last.y;
      last = { x: pos.x, y: pos.y };
      const speed = Math.min(40, Math.hypot(vx, vy));
      const stretch = reduce ? 0 : (speed / 40) * 0.45 * (1 - magnet);
      const ang = Math.atan2(vy, vx);
      blob.style.width = `${dim.w}px`;
      blob.style.height = `${dim.h}px`;
      blob.style.borderRadius = `${dim.r}px`;
      // Over a target the blob becomes a soft inverted plate rather than a solid fill.
      blob.style.opacity = `${opacity * (1 - magnet * 0.74)}`;
      blob.style.transform = `translate(${pos.x - dim.w / 2}px, ${pos.y - dim.h / 2}px) rotate(${ang}rad) scale(${1 + stretch}, ${1 - stretch * 0.6}) rotate(${-ang}rad)`;
      if (labelRef.current) {
        const l = labelRef.current;
        l.textContent = s.label;
        l.style.opacity = hasLabel ? `${opacity}` : "0";
        l.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
      }
      // Trail follows the blob, each droplet chasing the one before it.
      let lead: Pt = pos;
      for (let i = 0; i < dots.length; i++) {
        const d = dots[i];
        const kk = 1 - Math.pow(1 - (0.42 - i * 0.04), f);
        d.x += (lead.x - d.x) * kk;
        d.y += (lead.y - d.y) * kk;
        lead = d;
        const el = dotRefs.current[i];
        if (!el) continue;
        const ds = size * (0.8 - i * 0.1) * (1 - magnet * 0.9);
        el.style.width = el.style.height = `${Math.max(0, ds)}px`;
        el.style.opacity = `${opacity}`;
        el.style.transform = `translate(${d.x - ds / 2}px, ${d.y - ds / 2}px)`;
      }
      raf = requestAnimationFrame(loop);
    };
    return () => {
      running = false;
      cancelAnimationFrame(raf);
      io.disconnect();
      scope.removeEventListener("pointermove", onMove);
      scope.removeEventListener("pointerleave", onLeave);
      scope.removeEventListener("pointerdown", onDown);
    };
  }, [enabled, reduce, size, trail, targets]);

  return (
    <div
      ref={scopeRef}
      className={cn("relative isolate", enabled && hideNativeCursor && "cursor-none [&_*]:cursor-none", className)}
    >
      {children}
      {enabled && (
        <div aria-hidden className="pointer-events-none absolute inset-0 z-50 overflow-hidden" style={{ mixBlendMode: blend }}>
          <svg className="absolute size-0" aria-hidden focusable="false">
            <defs>
              <filter id={filterId}>
                <feGaussianBlur in="SourceGraphic" stdDeviation={goo} result="b" />
                <feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9" result="g" />
                <feComposite in="SourceGraphic" in2="g" operator="atop" />
              </filter>
            </defs>
          </svg>
          <div className="absolute inset-0" style={{ filter: reduce ? undefined : `url(#${filterId})` }}>
            <div ref={blobRef} className="absolute top-0 left-0 will-change-transform" style={{ background: color, opacity: 0 }} />
            {Array.from({ length: reduce ? 0 : trail }, (_, i) => (
              <div
                key={i}
                ref={(el) => {
                  dotRefs.current[i] = el;
                }}
                className="absolute top-0 left-0 rounded-full will-change-transform"
                style={{ background: color, opacity: 0 }}
              />
            ))}
          </div>
          <span
            ref={labelRef}
            className="absolute top-0 left-0 text-[11px] font-semibold tracking-wider whitespace-nowrap uppercase"
            style={{ color: "#000", opacity: 0 }}
          />
        </div>
      )}
    </div>
  );
}

More in Motion & Scroll

View all →