Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:41Get Lifetime →
loopkit
freeNew148 lines · framer-motion

Cursor Blob

A soft glow that trails just behind the pointer. A bounded demo panel where a blurred, glowing blob follows the real pointer on a spring, always a beat behind, fading in and out as the pointer enters and leaves.

Use it for
Cursor-aware hero panels, feature callouts or any section that wants a premium, tactile feel without a heavy canvas effect.
Themes
Light + dark
License
Commercial use
Live
Cursors

Move your pointer here

The glow trails a little behind — spring physics, not a 1:1 follow.

tsx
"use client";

import * as React from "react";
import { motion, useMotionValue, useSpring } from "framer-motion";
import { cn } from "@/lib/utils";

/**
 * A bounded demo section: while the real pointer is inside it, a soft blurred
 * glowing blob trails the pointer on a spring (never a 1:1 follow). Falls back
 * to the plain default cursor on touch devices and when the user prefers
 * reduced motion — checked once on mount, no custom effect is attempted then.
 */

export interface CursorBlobProps {
  heading?: string;
  hint?: string;
  /** Diameter of the glow blob, in px. */
  size?: number;
  /** Blur radius applied to the blob, in px. */
  blur?: number;
  /** Spring stiffness driving the trail — lower feels laggier, higher feels snappier. */
  stiffness?: number;
  /** Which design token drives the blob's hue. */
  color?: "signal" | "info" | "lime" | "ok";
  className?: string;
}

const colorVar: Record<NonNullable<CursorBlobProps["color"]>, string> = {
  signal: "var(--signal)",
  info: "var(--info)",
  lime: "var(--lime)",
  ok: "var(--ok)",
};

const SPRING_DAMPING = 22;
const SPRING_MASS = 0.6;

export function CursorBlob({
  heading = "Move your pointer here",
  hint = "The glow trails a little behind — spring physics, not a 1:1 follow.",
  size = 220,
  blur = 40,
  stiffness = 120,
  color = "signal",
  className,
}: CursorBlobProps) {
  const [interactive, setInteractive] = React.useState(false);
  const [hovering, setHovering] = React.useState(false);
  const [isDark, setIsDark] = React.useState(false);

  React.useEffect(() => {
    const coarse = window.matchMedia("(pointer: coarse)").matches || window.matchMedia("(hover: none)").matches;
    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    setInteractive(!coarse && !reduceMotion);

    setIsDark(document.documentElement.classList.contains("dark"));
    const mo = new MutationObserver(() => setIsDark(document.documentElement.classList.contains("dark")));
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
    return () => mo.disconnect();
  }, []);

  const mx = useMotionValue(0);
  const my = useMotionValue(0);
  const blobX = useSpring(mx, { stiffness, damping: SPRING_DAMPING, mass: SPRING_MASS });
  const blobY = useSpring(my, { stiffness, damping: SPRING_DAMPING, mass: SPRING_MASS });

  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!interactive) return;
    const rect = e.currentTarget.getBoundingClientRect();
    mx.set(e.clientX - rect.left);
    my.set(e.clientY - rect.top);
  };

  const handlePointerEnter = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!interactive) return;
    const rect = e.currentTarget.getBoundingClientRect();
    mx.set(e.clientX - rect.left);
    my.set(e.clientY - rect.top);
    setHovering(true);
  };

  const handlePointerLeave = () => setHovering(false);

  return (
    <section
      className={cn(
        "relative w-full overflow-hidden rounded-2xl border border-border bg-card px-4 py-16 sm:px-6 lg:py-24",
        className,
      )}
      style={{ cursor: interactive ? "none" : "auto" }}
      onPointerMove={handlePointerMove}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
    >
      <div
        className="absolute inset-0 [background-image:radial-gradient(circle_at_1px_1px,var(--border)_1px,transparent_1px)] [background-size:26px_26px] opacity-[0.3] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_40%,black,transparent_75%)]"
        aria-hidden
      />

      <motion.div
        aria-hidden
        className="pointer-events-none absolute left-0 top-0 rounded-full"
        style={{
          width: size,
          height: size,
          marginLeft: -size / 2,
          marginTop: -size / 2,
          x: blobX,
          y: blobY,
          filter: `blur(${blur}px)`,
          background: `radial-gradient(circle, ${colorVar[color]} 0%, transparent 70%)`,
          mixBlendMode: isDark ? "screen" : "multiply",
        }}
        initial={false}
        animate={{ opacity: interactive && hovering ? 1 : 0 }}
        transition={{ duration: 0.25, ease: "easeOut" }}
      />

      <div className="relative z-10 mx-auto flex max-w-xl flex-col items-center text-center">
        <span className="inline-flex items-center gap-2 rounded-full border border-border bg-background/70 px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.16em] text-muted-foreground backdrop-blur">
          <span className="size-1.5 rounded-full bg-signal" aria-hidden />
          Cursors
        </span>
        <h3 className="mt-5 font-display text-[clamp(1.6rem,4vw,2.5rem)] font-extrabold leading-[1.05] tracking-[-0.03em] text-foreground">
          {heading}
        </h3>
        <p className="mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground">{hint}</p>
        <div className="mt-7 flex flex-wrap items-center justify-center gap-3">
          <button
            type="button"
            className="inline-flex h-11 items-center rounded-full bg-primary px-5 text-sm font-semibold text-primary-foreground"
          >
            Primary action
          </button>
          <button
            type="button"
            className="inline-flex h-11 items-center rounded-full border-2 border-foreground px-5 text-sm font-semibold text-foreground"
          >
            Secondary
          </button>
        </div>
      </div>
    </section>
  );
}

export default CursorBlob;

How it behaves

  • →A div with a radial-gradient background, filter: blur() and mix-blend-mode (screen in dark mode, multiply in light) trails the pointer; position is driven by useMotionValue + useSpring, never a 1:1 follow.
  • →The glow fades in on pointer-enter and fades out on pointer-leave inside the panel; it never renders or tracks outside the panel's own element.
  • →size sets the blob diameter, blur sets its blur radius, stiffness sets the spring stiffness driving the trail (lower feels laggier), color picks which token drives its hue.
  • →On mount, checks (pointer: coarse), (hover: none) and prefers-reduced-motion: reduce; if any match, the panel renders as a plain static section with the normal pointer and no glow.
  • →cursor: none is applied only to the panel's own element, only once JS has confirmed it is safe — never to <body> or <html>.

Accessibility

  • ✓The glow div is `aria-hidden`; it carries no content and never affects the accessibility tree.
  • ✓Heading, copy and buttons are real elements reachable and readable with the effect off, exactly as with it on.
  • ✓Falls back to the plain default cursor and a static panel for touch pointers and `prefers-reduced-motion`, so no one is forced to parse a moving glow.
  • ✓Honours prefers-reduced-motion.