liten

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.

Terminal
bun add motion

Copy the component into components/ui/ink-bleed.tsx.

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

Example.tsx
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

PropTypeDefaultDescription
textstring-The heading text.
accentstring"#f0883e"The color the wet ink carries while it soaks in.
staggernumber0.055Seconds between each glyph starting to soak.
durationnumber0.9Seconds a single glyph takes to soak.
roughnessnumber1.6Displacement of the paper-fibre edge; 0 is clean.
repeatbooleanfalseReplay the soak each time it re-enters view.
classNamestring-Forwarded to the wrapper.
On this page0%