A cursor spotlight where an SVG goo filter makes the letters bloom and fuse like wet ink.
npx shadcn@latest add @paragon/ink-bleed-text"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface InkBleedTextProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** The text to render. */
children: React.ReactNode;
/** Ink color that bleeds/thickens under the cursor. */
color?: string;
/** Bleed intensity — Gaussian blur radius feeding the goo threshold, in px. */
intensity?: number;
/** Radius of the hover spotlight in px. */
radius?: number;
/** Render plain text with no bleed. */
static?: boolean;
}
/**
* InkBleedText — a hover spotlight where the letters bleed like wet ink. A
* duplicate ink-colored copy of the text runs through an SVG "goo" filter
* (feGaussianBlur → feColorMatrix alpha threshold), which fattens and fuses the
* strokes wherever it's revealed. A radial mask follows the cursor so only the
* letters under the pointer thicken and bloom, while the rest stays crisp — as
* if the pointer were a drop of solvent spreading through the type.
*
* The gooey layer is `aria-hidden`; the real, legible text stays on top and in
* the DOM. The mask is driven by CSS custom properties (no re-render per move).
* Skipped on coarse pointers; reduced motion (or `static`) renders plain text.
*/
export function InkBleedText({
children,
color = "var(--color-primary)",
intensity = 4,
radius = 70,
static: isStatic = false,
className,
style,
...props
}: InkBleedTextProps) {
const id = React.useId().replace(/[:]/g, "");
const reducedMotion = useReducedMotion() ?? false;
const hostRef = React.useRef<HTMLSpanElement>(null);
const inkRef = React.useRef<HTMLSpanElement>(null);
const [fine, setFine] = React.useState(false);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(() => {
const host = hostRef.current;
const ink = inkRef.current;
if (!host || !ink || !fine || isStatic || reducedMotion) return;
let raf = 0;
const target = { x: 0, y: 0, on: 0 };
const cur = { x: 0, y: 0, on: 0 };
const onMove = (e: PointerEvent) => {
const r = host.getBoundingClientRect();
target.x = e.clientX - r.left;
target.y = e.clientY - r.top;
target.on = 1;
};
const onLeave = () => {
target.on = 0;
};
const tick = () => {
cur.x += (target.x - cur.x) * 0.25;
cur.y += (target.y - cur.y) * 0.25;
cur.on += (target.on - cur.on) * 0.15;
ink.style.setProperty("--ink-x", `${cur.x}px`);
ink.style.setProperty("--ink-y", `${cur.y}px`);
ink.style.opacity = String(cur.on);
raf = requestAnimationFrame(tick);
};
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerleave", onLeave);
raf = requestAnimationFrame(tick);
return () => {
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(raf);
};
}, [fine, isStatic, reducedMotion]);
if (isStatic || reducedMotion || !fine) {
return (
<span
ref={hostRef}
data-slot="ink-bleed-text"
className={cn("inline-block", className)}
style={style}
{...props}
>
{children}
</span>
);
}
const mask = `radial-gradient(circle ${radius}px at var(--ink-x, -999px) var(--ink-y, -999px),
#000 0%, #000 55%, transparent 100%)`;
return (
<span
ref={hostRef}
data-slot="ink-bleed-text"
className={cn("relative inline-block", className)}
style={style}
{...props}
>
{/* Goo filter — self-contained, no remote refs. */}
<svg
aria-hidden
width="0"
height="0"
className="absolute"
style={{ position: "absolute", width: 0, height: 0 }}
>
<defs>
<filter id={`ink-goo-${id}`}>
<feGaussianBlur
in="SourceGraphic"
stdDeviation={intensity}
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 -9"
result="goo"
/>
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
</defs>
</svg>
{/* Bleeding ink layer, spotlight-masked to the cursor. */}
<span
ref={inkRef}
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
color,
filter: `url(#ink-goo-${id})`,
opacity: 0,
WebkitMaskImage: mask,
maskImage: mask,
}}
>
{children}
</span>
{/* Crisp, legible text on top. */}
<span className="relative">{children}</span>
</span>
);
}