Text Effects

Ink Bleed Text

A cursor spotlight where an SVG goo filter makes the letters bloom and fuse like wet ink.

Install

npx shadcn@latest add @paragon/ink-bleed-text

ink-bleed-text.tsx

"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>
  );
}