Ink Bleed
A heading that materializes as if ink were being absorbed by paper - each glyph soaks from a wet accent blot into a crisp, fibre-edged letter.
Depth, not glow.
Each glyph starts as a diffuse wet blot carrying the accent, then concentrates into a crisp letter as it soaks in, cooling to the inherited text color. A static turbulence displacement feathers the settled edge like real paper fibre. Glyphs soak in reading order when the heading scrolls into view. Built with Motion.
Installation
Complete the shared Setup first, then add Motion.
bun add motionCopy the component into components/ui/ink-bleed.tsx.
'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;export type InkBleedProps = { text: string; accent?: string; stagger?: number; duration?: number; roughness?: number; repeat?: boolean; className?: string;};function seeded(n: number) { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x);}export function InkBleed({ text, accent = '#f0883e', stagger = 0.055, duration = 0.9, roughness = 1.6, repeat = false, className,}: InkBleedProps) { const reduce = useReducedMotion(); const filterId = React.useId().replace(/[:]/g, ''); const containerRef = React.useRef<HTMLSpanElement>(null); const [soaked, setSoaked] = React.useState(false); React.useEffect(() => { const el = containerRef.current; if (!el) return; const io = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) setSoaked(true); else if (repeat) setSoaked(false); }, { threshold: 0.35 }, ); io.observe(el); return () => io.disconnect(); }, [repeat]); const words = React.useMemo(() => text.split(/(\s+)/), [text]); let glyphIndex = 0; return ( <span ref={containerRef} style={{ '--ink-accent': accent } as React.CSSProperties} className={cn('inline-block', className)} > <svg aria-hidden width="0" height="0" className="absolute"> <defs> <filter id={filterId} x="-20%" y="-20%" width="140%" height="140%" colorInterpolationFilters="sRGB" > <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves={2} seed={4} result="fibre" /> <feDisplacementMap in="SourceGraphic" in2="fibre" scale={roughness} xChannelSelector="R" yChannelSelector="G" /> </filter> </defs> </svg> <span className="inline"> {words.map((chunk, wi) => { if (/^\s+$/.test(chunk)) { return <span key={`s${wi}`}>{chunk}</span>; } return ( <span key={`w${wi}`} style={roughness > 0 ? { filter: `url(#${filterId})` } : undefined} className="inline-block whitespace-nowrap" > {Array.from(chunk).map((char) => { const i = glyphIndex++; const wet = 0.34 + seeded(i * 1.7 + 3) * 0.12; const dry = reduce || soaked; return ( <motion.span key={i} className="inline-block" initial={false} animate={ reduce ? { opacity: 1, filter: 'blur(0em)' } : dry ? { opacity: 1, filter: 'blur(0em)', textShadow: `0 0 0em transparent`, } : { opacity: 0, filter: `blur(${wet}em)`, textShadow: `0 0 ${wet}em var(--ink-accent)`, } } transition={ reduce ? { duration: 0 } : { duration, delay: dry ? i * stagger : 0, ease: EASE_OUT, } } > {char} </motion.span> ); })} </span> ); })} </span> </span> );}Usage
import { InkBleed } from '@/components/ui/ink-bleed';
export default function Example() {
return (
<h2 className="text-5xl font-semibold">
<InkBleed text="Depth, not glow." />
</h2>
);
}Examples
Any accent, with a tighter stagger.
Written in ink, not printed in pixels.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | - | The heading text. |
accent | string | "#f0883e" | The color the wet ink carries while it soaks in. |
stagger | number | 0.055 | Seconds between each glyph starting to soak. |
duration | number | 0.9 | Seconds a single glyph takes to soak. |
roughness | number | 1.6 | Displacement of the paper-fibre edge; 0 is clean. |
repeat | boolean | false | Replay the soak each time it re-enters view. |
className | string | - | Forwarded to the wrapper. |
Multiplayer Text
A headline visited by named multiplayer hand cursors that glide to a word, click it, and claim it in that teammate's ink.
Transcript Stream
Text that settles onto the page as if speech were being transcribed live - a write-head glides the line and each glyph resolves out of a soft blur.