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.
npx shadcn@latest add @paragon/ink-bleed-text"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>
);
}