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.