# Noise dissolve

> The title forms from noise and dissolves back into it.

- Type: Component (pixel)
- Page: https://textlab.dev/components/noise-dissolve
- Markdown: https://textlab.dev/components/noise-dissolve.md
- Registry item: https://textlab.dev/r/noise-dissolve.json
- Source file: `registry/textlab/noise-dissolve/noise-dissolve.tsx`
- Dependencies: none
- Variants: burn, dissolve
- Keywords: dissolve, burn, webgl, shader, noise, disintegrate

## When to use

- Dramatic reveals for launches, games and film.
- Moments of transformation: before and after, old and new.

## When not to use

- Text that must stay readable while hovered.
- Several WebGL effects on one page.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/noise-dissolve
pnpm dlx shadcn@latest add @textlab/noise-dissolve
```

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/noise-dissolve.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 { NoiseDissolve } from "@/components/textlab/noise-dissolve/noise-dissolve";

export function Example() {
  return <NoiseDissolve as="h1" text="Ashes" />;
}
```

## API reference

### NoiseDissolve

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"burn" \| "dissolve"` | `"burn"` | A glowing ember edge, or a soft dissolve. |
| `duration` | `number` | `1.4` | Seconds each part takes. |
| `hover` | `boolean` | `true` | Dissolve on hover and re-form on leave. |
| `edge` | `[number, number, number]` | `[1, 0.45, 0.1]` | Burn edge color as RGB from 0 to 1. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |

## Accessibility

- Decorative canvas; the text is read from a hidden copy.
- Without WebGL, a CSS fade shows the real text.

## Motion

- Forms over 1.4 s at a constant rate, so the burn edge travels evenly.
- Hover eats roughly half the letters, never all of them.

## Reduced motion

Plain text; the shader never loads.

## Performance

- Loaded only in view; draws only while the threshold is moving.

## 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: burn, dissolve.

## Related

- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.
- [Ember text](https://textlab.dev/components/ember-text.md): Letters land hot and cool down to the text color.
- [Ink bleed](https://textlab.dev/components/ink-bleed.md): Text soaks in like ink on paper.

## Also in pixel

- [ASCII text](https://textlab.dev/components/ascii-text.md): The title rendered in ASCII glyphs that ripple.
- [Dither text](https://textlab.dev/components/dither-text.md): Ordered dither and halftone dots form the type.
- [Pixel sort](https://textlab.dev/components/pixel-sort.md): Rows sort into place like a glitch artwork.
- [Particle text](https://textlab.dev/components/particle-text.md): Particles gather into letters and scatter on touch.

## Source

### components/textlab/noise-dissolve/noise-dissolve.tsx

```tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ElementType } from "react";
import { CanvasText } from "../lib/canvas-text";
import { fitCanvas, rasterize } from "../lib/raster";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
import type { TextShader } from "../lib/webgl";

export type NoiseDissolveVariant = "dissolve" | "burn";

export interface NoiseDissolveProps {
  text: string;
  /** dissolve fades through soft noise, burn eats the letters with a glowing ember edge. */
  variant?: NoiseDissolveVariant;
  /** Seconds to form or dissolve. */
  duration?: number;
  /** Dissolve on hover and re-form on leave. */
  hover?: boolean;
  /** Edge glow color for burn, as r, g, b from 0 to 1. */
  edge?: [number, number, number];
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * The title forms out of noise as it scrolls in, and dissolves back into it on hover. WebGL loads
 * lazily; without it, a soft CSS fade stands in.
 */
export function NoiseDissolve({ text, variant = "burn", duration = 1.4, hover = true, edge = [1, 0.45, 0.1], as, reducedMotion = "user", className, style }: NoiseDissolveProps) {
  const { ref: root, on } = useTrigger<HTMLElement>({ trigger: "inView", amount: 0.2 });
  const canvas = useRef<HTMLCanvasElement | null>(null);
  const shader = useRef<TextShader | null>(null);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const reduced = useReduced(reducedMotion);
  const threshold = useRef(1);
  const target = useRef(1);

  useEffect(() => {
    if (!on || reduced) return;
    target.current = 0;
    let disposed = false;
    let observer: ResizeObserver | null = null;
    import("./gl").then(({ createDissolve }) => {
      const node = root.current;
      const c = canvas.current;
      if (disposed || !node || !c) return;
      const s = createDissolve(c);
      if (!s) {
        setFailed(true);
        return;
      }
      shader.current = s;
      const build = () => {
        const { width, height, dpr } = fitCanvas(c);
        const raster = rasterize(node, text, width, height, { dpr, color: getComputedStyle(node).color });
        s.resize(c.width, c.height);
        s.setTexture(raster.canvas);
        s.uniform("u_scale", Math.max(4, width / 60));
        s.uniform("u_threshold", threshold.current);
        s.draw();
      };
      build();
      observer = new ResizeObserver(build);
      observer.observe(node);
      setReady(true);
    });
    return () => {
      disposed = true;
      observer?.disconnect();
      shader.current?.dispose();
      shader.current = null;
      setReady(false);
    };
  }, [on, reduced, text, root]);

  useFrame(
    (_, dt) => {
      const s = shader.current;
      if (!s) return;
      const before = threshold.current;
      const step = dt / duration;
      threshold.current = before + Math.max(-step, Math.min(step, target.current - before));
      if (before === threshold.current) return;
      s.uniform("u_threshold", threshold.current);
      s.uniform("u_edge", edge[0], edge[1], edge[2]);
      s.uniform("u_burn", variant === "burn" ? 1 : 0);
      s.draw();
    },
    { ref: root, enabled: ready },
  );

  const fallback = reduced || failed;
  return (
    <CanvasText
      text={text}
      as={as}
      className={className}
      style={style}
      rootRef={root}
      canvasRef={canvas}
      bleed={0.2}
      fallback={fallback ? text : undefined}
      canvasStyle={{ opacity: ready ? 1 : 0 }}
    >
      {!ready && !fallback && (
        // Until the shader arrives, a CSS fade keeps the title from popping in.
        <span aria-hidden="true" style={{ position: "absolute", inset: 0, whiteSpace: "pre", opacity: on ? 1 : 0, filter: on ? "blur(0)" : "blur(6px)", transition: "opacity 0.6s, filter 0.6s" }}>
          {text}
        </span>
      )}
      {hover && ready && (
        <span
          aria-hidden="true"
          style={{ position: "absolute", inset: 0 }}
          onPointerEnter={() => {
            target.current = 0.35;
          }}
          onPointerLeave={() => {
            target.current = 0;
          }}
        />
      )}
    </CanvasText>
  );
}

export default NoiseDissolve;
```

### components/textlab/noise-dissolve/gl.ts

```tsx
"use client";

import { createTextShader } from "../lib/webgl";

const FRAGMENT = `
uniform sampler2D u_tex;
uniform float u_threshold;
uniform float u_scale;
uniform vec3 u_edge;
uniform float u_burn;
varying vec2 v_uv;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
  return v;
}
void main() {
  vec4 tex = texture2D(u_tex, v_uv);
  float n = fbm(v_uv * u_scale);
  // Pixels whose noise is below the threshold are gone; a thin band just above it glows.
  float keep = smoothstep(u_threshold, u_threshold + 0.02, n);
  float edge = smoothstep(u_threshold + 0.09, u_threshold, n) * keep * step(0.001, u_threshold);
  vec3 color = mix(tex.rgb, u_edge * tex.a, edge * u_burn);
  gl_FragColor = vec4(color, tex.a) * keep;
}`;

export function createDissolve(canvas: HTMLCanvasElement) {
  return createTextShader(canvas, FRAGMENT);
}
```

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