Ink Bleed Text
Text Effects

Ink Bleed Text

A reveal where each character floods in like a drop of ink — an SVG goo filter blooms and fuses the strokes, then they contract into crisp text.

Install

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

ink-bleed-text.tsx

"use client";

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

// The house `--ease-out` token, as a cubic-bezier array for motion/react
// (its `ease` prop takes beziers, not CSS variables).
const EASE_OUT = [0.22, 1, 0.36, 1] as const;

export interface InkBleedTextProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The text that bleeds in. */
  children: string;
  /** Ink color of the bleed halo. Defaults to the current text color. */
  ink?: string;
  /** Peak bleed spread in px — how far the ink blooms before it contracts. */
  spread?: number;
  /** Ms each character takes to settle from bloom to crisp. */
  speed?: number;
  /** Ms between adjacent characters starting to bleed in (the wave). */
  stagger?: number;
  /** Ms before the reveal begins. */
  delay?: number;
  /** `view` — bleed in once on scroll-in (default). `mount` — always on mount. */
  trigger?: "view" | "mount";
  /** Render the crisp text with no bleed. */
  static?: boolean;
}

/**
 * InkBleedText — a reveal where each character floods in like a drop of ink
 * hitting paper: it starts as a soft, over-inked bloom (heavy blur plus a
 * spread of ink-colored shadow) and contracts into a crisp glyph. An SVG "goo"
 * filter (feGaussianBlur then feColorMatrix alpha threshold) wraps the
 * animating layer so neighbouring blooms fuse and then separate as they
 * sharpen — unmistakably wet ink, not a plain fade. A left-to-right stagger
 * sends the wave across the word.
 *
 * The real text is a crisp layer underneath (always in the DOM, high-contrast
 * in both themes); the bleeding layer is decorative and fades out as it
 * settles so the final result is pixel-crisp. `view` fires once on scroll-in;
 * `mount` runs every mount (demos remount on replay). Reduced motion (or
 * `static`) renders the final text with no motion.
 */
export function InkBleedText({
  children,
  ink,
  spread = 10,
  speed = 620,
  stagger = 45,
  delay = 0,
  trigger = "view",
  static: isStatic = false,
  className,
  style,
  ...props
}: InkBleedTextProps) {
  const id = React.useId().replace(/[:]/g, "");
  const reducedMotion = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.4 });

  const chars = React.useMemo(() => Array.from(children), [children]);
  const animated = !isStatic && !reducedMotion;
  const active = trigger === "mount" ? true : inView;
  const inkColor = ink ?? "currentColor";

  if (!animated) {
    return (
      <span
        ref={ref}
        data-slot="ink-bleed-text"
        className={cn("inline-block", className)}
        style={style}
        {...props}
      >
        {children}
      </span>
    );
  }

  const dur = speed / 1000;

  return (
    <span
      ref={ref}
      data-slot="ink-bleed-text"
      className={cn("relative inline-block", className)}
      style={style}
      {...props}
    >
      {/* Goo filter. A steep alpha threshold on a blurred copy fattens and
          fuses the glyph blooms into connected ink. Self-contained, no refs. */}
      <svg
        aria-hidden
        width="0"
        height="0"
        style={{ position: "absolute", width: 0, height: 0 }}
      >
        <defs>
          <filter
            id={`ink-goo-${id}`}
            x="-40%"
            y="-40%"
            width="180%"
            height="180%"
          >
            <feGaussianBlur in="SourceGraphic" stdDeviation="1.4" 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 18 -7"
              result="goo"
            />
            <feComposite in="SourceGraphic" in2="goo" operator="atop" />
          </filter>
        </defs>
      </svg>

      {/* Full string for assistive tech; the visual layers are decorative. */}
      <span className="sr-only">{children}</span>

      <span aria-hidden="true" className="relative">
        {chars.map((ch, i) => {
          const isSpace = /\s/.test(ch);
          if (isSpace) {
            return (
              <span key={i} className="inline-block whitespace-pre">
                {ch}
              </span>
            );
          }
          const at = delay / 1000 + (i * stagger) / 1000;
          return (
            <span key={i} className="relative inline-block whitespace-pre">
              {/* Crisp glyph reserves the box width and fades in last. */}
              <motion.span
                className="inline-block"
                initial={{ opacity: 0 }}
                animate={active ? { opacity: 1 } : { opacity: 0 }}
                transition={{
                  duration: dur * 0.5,
                  ease: EASE_OUT,
                  delay: at + dur * 0.45,
                }}
              >
                {ch}
              </motion.span>

              {/* Ink bloom. Outer layer runs the goo filter; the inner motion
                  span animates its own blur + ink-shadow spread down to crisp,
                  so the two filters compose instead of overwriting. */}
              <span
                className="pointer-events-none absolute inset-0"
                style={{ color: inkColor, filter: `url(#ink-goo-${id})` }}
              >
                <motion.span
                  className="inline-block"
                  style={{ willChange: "filter, opacity, transform" }}
                  initial={{
                    opacity: 0,
                    filter: `blur(${spread}px)`,
                    textShadow: `0 0 ${spread}px currentColor, 0 0 ${spread * 0.5}px currentColor`,
                    scale: 1.18,
                  }}
                  animate={
                    active
                      ? {
                          opacity: [0, 1, 1, 0],
                          filter: [
                            `blur(${spread}px)`,
                            `blur(${spread * 0.45}px)`,
                            "blur(0.6px)",
                            "blur(0px)",
                          ],
                          textShadow: [
                            `0 0 ${spread}px currentColor, 0 0 ${spread * 0.5}px currentColor`,
                            `0 0 ${spread * 0.5}px currentColor, 0 0 ${spread * 0.25}px currentColor`,
                            "0 0 1px currentColor, 0 0 0 currentColor",
                            "0 0 0 currentColor, 0 0 0 currentColor",
                          ],
                          scale: [1.18, 1.06, 1, 1],
                        }
                      : { opacity: 0 }
                  }
                  transition={{
                    duration: dur,
                    ease: EASE_OUT,
                    delay: at,
                    times: [0, 0.35, 0.75, 1],
                  }}
                >
                  {ch}
                </motion.span>
              </span>
            </span>
          );
        })}
      </span>
    </span>
  );
}