# Ink bleed

> Text soaks in like ink on paper.

- Type: Component (distort)
- Page: https://textlab.dev/components/ink-bleed
- Markdown: https://textlab.dev/components/ink-bleed.md
- Registry item: https://textlab.dev/r/ink-bleed.json
- Source file: `registry/textlab/ink-bleed/ink-bleed.tsx`
- Dependencies: motion
- Variants: ink, watercolor, stamp
- Keywords: ink, bleed, watercolor, stamp, paper, organic

## When to use

- Editorial, stationery and craft brands.
- Serif display type on paper-like backgrounds.

## When not to use

- Thin weights; the threshold can break hairlines.
- Busy photographic backgrounds.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/ink-bleed
pnpm dlx shadcn@latest add @textlab/ink-bleed
```

The `@textlab` name needs `"registries": { "@textlab": "https://textlab.dev/r/{name}.json" }` in `components.json`. Without it, use the full URL:

```bash
npx shadcn@latest add https://textlab.dev/r/ink-bleed.json
```

Import the tokens once in your root layout: `import "@/components/textlab/tokens.css";`

### Manual

1. Install the dependencies: `npm install motion@12`
2. Copy `text-fx` (https://textlab.dev/r/text-fx.json) and this component into `components/textlab/`.

## Usage

```tsx
import { InkBleed } from "@/components/textlab/ink-bleed/ink-bleed";

export function Example() {
  return <InkBleed as="h2" text="Written by hand" variant="watercolor" />;
}
```

## API reference

### InkBleed

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"ink" \| "watercolor" \| "stamp"` | `"ink"` | Rough fibrous ink, soft watercolor bloom, or a grainy stamp. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `trigger` | `"mount" \| "inView" \| "hover" \| "manual"` | `"inView"` | What starts the animation. manual follows `active`. |
| `active` | `boolean` | `false` | Drives trigger="manual". |
| `once` | `boolean` | `true` | In view: play once instead of on every entry. |
| `delay` | `number` | `0` | Seconds before the first part moves. |
| `duration` | `number` | `1.8` | Seconds each part takes. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
| `onComplete` | `() => void` | – | Called when a one-shot animation finishes. |

## Accessibility

- The filtered layer is decorative; the text is read from a hidden copy.

## Motion

- Spreads with the emphasized ease: quick soak, slow settle.
- Edges stay slightly fibrous at rest, like real ink.

## Reduced motion

Plain text with no filter.

## Performance

- One SVG filter updated for the length of the animation only.

## Notes for AI

- Use only the props documented above. Do not invent props.
- Keep the screen-reader text and the reduced-motion branch when editing the source.
- Pick a variant from: ink, watercolor, stamp.

## Related

- [Outline draw](https://textlab.dev/components/outline-draw.md): The outline draws itself, then fills.
- [Signature](https://textlab.dev/components/signature.md): A handwritten signature writes itself.
- [Gooey text](https://textlab.dev/components/gooey-text.md): Words melt into each other.

## Also in distort

- [Liquid text](https://textlab.dev/components/liquid-text.md): Ripples run through the letters under the cursor.
- [Chromatic text](https://textlab.dev/components/chromatic-text.md): The color channels split and rejoin.
- [Glitch text](https://textlab.dev/components/glitch-text.md): A controlled digital glitch on cue.
- [Heat haze](https://textlab.dev/components/heat-haze.md): A mirage warps the air around the type.

## Source

### components/textlab/ink-bleed/ink-bleed.tsx

```tsx
"use client";

import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { animate } from "motion/react";
import { srOnly } from "../lib/a11y";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useSvgId } from "../lib/use-svg-id";
import { useTrigger, type Trigger } from "../lib/use-trigger";

export type InkBleedVariant = "ink" | "watercolor" | "stamp";

export interface InkBleedProps {
  text: string;
  /** ink soaks in with rough edges, watercolor blooms softly, stamp presses down with a grainy edge. */
  variant?: InkBleedVariant;
  as?: ElementType;
  trigger?: Exclude<Trigger, "always">;
  active?: boolean;
  once?: boolean;
  delay?: number;
  duration?: number;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
  onComplete?: () => void;
}

/**
 * Text soaks in like ink on paper. A blur collapses through an alpha threshold, so the letters grow
 * outward from their cores with fibrous edges, then the edges settle sharp.
 */
export function InkBleed({ text, variant = "ink", as: Tag = "span", trigger = "inView", active, once = true, delay = 0, duration = 1.8, reducedMotion = "user", className, style, onComplete }: InkBleedProps) {
  const id = useSvgId("tl-ink");
  const { ref, on, bind } = useTrigger<HTMLElement>({ trigger, active, once });
  const reduced = useReduced(reducedMotion);
  const hydrated = useHydrated();
  const blur = useRef<SVGFEGaussianBlurElement | null>(null);
  const disp = useRef<SVGFEDisplacementMapElement | null>(null);
  const matrix = useRef<SVGFEColorMatrixElement | null>(null);
  const layer = useRef<HTMLSpanElement | null>(null);

  useEffect(() => {
    if (reduced) return;
    const apply = (p: number) => {
      // p runs 0 (dry paper) to 1 (fully inked).
      const spread = (1 - p) * (variant === "watercolor" ? 14 : 9);
      blur.current?.setAttribute("stdDeviation", spread.toFixed(2));
      disp.current?.setAttribute("scale", ((1 - p) * (variant === "stamp" ? 18 : 10) + (variant === "stamp" ? 3 : 1.2)).toFixed(2));
      const gain = variant === "watercolor" ? 3 + p * 4 : 8 + p * 14;
      const cut = variant === "watercolor" ? 1 - p * 0.4 : 5.5 - p * 1.5 + (1 - p) * 3;
      matrix.current?.setAttribute("values", `1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 ${gain.toFixed(2)} -${cut.toFixed(2)}`);
      if (layer.current) layer.current.style.opacity = String(Math.min(1, p * 3));
    };
    if (!on) {
      apply(0);
      return;
    }
    const controls = animate(0, 1, { duration, delay, ease: [0.16, 1, 0.3, 1], onUpdate: apply, onComplete });
    return () => controls.stop();
    // onComplete is a callback; its identity should not restart the bleed.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [on, reduced, variant, duration, delay]);

  if (reduced) {
    return (
      <Tag ref={ref} className={className} style={style} {...bind}>
        {text}
      </Tag>
    );
  }

  return (
    <Tag
      ref={ref}
      className={className}
      style={{ position: "relative", display: "inline-block", ...style }}
      data-tl-motion={reducedMotion === "user" ? "user" : undefined}
      data-tl-pending={hydrated ? undefined : ""}
      {...bind}
    >
      <svg aria-hidden="true" width="0" height="0" style={{ position: "absolute" }}>
        <defs>
          <filter id={id} x="-20%" y="-40%" width="140%" height="180%">
            <feTurbulence type="fractalNoise" baseFrequency={variant === "stamp" ? "0.9" : "0.045"} numOctaves={variant === "stamp" ? 1 : 3} seed="7" result="paper" />
            <feGaussianBlur ref={blur} in="SourceGraphic" stdDeviation="0" result="soft" />
            <feDisplacementMap ref={disp} in="soft" in2="paper" scale="1.2" xChannelSelector="R" yChannelSelector="G" result="fibres" />
            <feColorMatrix ref={matrix} in="fibres" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -4" />
          </filter>
        </defs>
      </svg>
      <span style={srOnly}>{text}</span>
      <span ref={layer} data-tl-part="" aria-hidden="true" style={{ display: "inline-block", filter: `url(#${id})`, opacity: 0, padding: "0.15em 0.2em", margin: "-0.15em -0.2em" }}>
        {text}
      </span>
    </Tag>
  );
}

export default InkBleed;
```

Full library index: https://textlab.dev/llms.txt
