# Shader ripple

> A WebGL ripple bends the title under the pointer.

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

## When to use

- Signature hero moments that should feel physical.
- Large display words people will hover.

## When not to use

- Several on one page; each needs a WebGL context.
- Small text.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/shader-ripple
pnpm dlx shadcn@latest add @textlab/shader-ripple
```

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/shader-ripple.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 { ShaderRipple } from "@/components/textlab/shader-ripple/shader-ripple";

export function Example() {
  return <ShaderRipple as="h1" text="Surface tension" />;
}
```

## API reference

### ShaderRipple

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"ripple" \| "lens"` | `"ripple"` | Rings travelling out from the pointer, or a magnifying circle under it. |
| `strength` | `number` | `1` | Effect strength, 0 to 2. |
| `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

- The canvas is decorative; the text is read from a hidden copy.
- Without WebGL, or before it loads, the real text shows in place.

## Motion

- Rings travel outward and fade with distance; the effect eases in and out with hover.

## Reduced motion

Plain text; the shader never loads.

## Performance

- The shader module is imported only when the title enters the viewport.
- Draws only while hovered; one texture upload per resize.

## 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: ripple, lens.

## Related

- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
- [Liquid text](https://textlab.dev/components/liquid-text.md): Ripples run through the letters under the cursor.
- [Particle text](https://textlab.dev/components/particle-text.md): Particles gather into letters and scatter on touch.

## 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.

## Source

### components/textlab/shader-ripple/shader-ripple.tsx

```tsx
"use client";

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

export type ShaderRippleVariant = "ripple" | "lens";

export interface ShaderRippleProps {
  text: string;
  /** ripple sends rings through the letters from the pointer; lens magnifies a circle under it. */
  variant?: ShaderRippleVariant;
  /** Effect strength, 0 to 2. */
  strength?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * A WebGL ripple bends the title under the pointer. The shader loads only when the title scrolls
 * into view; until then, and wherever WebGL is unavailable, the plain text is shown.
 */
export function ShaderRipple({ text, variant = "ripple", strength = 1, as, reducedMotion = "user", className, style }: ShaderRippleProps) {
  const { ref: root, on } = useTrigger<HTMLElement>({ trigger: "inView", amount: 0.1 });
  const canvas = useRef<HTMLCanvasElement | null>(null);
  const shader = useRef<TextShader | null>(null);
  const [ready, setReady] = useState(false);
  const reduced = useReduced(reducedMotion);
  const pointer = usePointer(root, { enabled: !reduced });
  const state = useRef({ hover: 0, mx: 0.5, my: 0.5, time: 0, bleed: 0, width: 1, height: 1 });

  useEffect(() => {
    if (!on || reduced) return;
    let disposed = false;
    let observer: ResizeObserver | null = null;
    import("./gl").then(({ createRipple }) => {
      const node = root.current;
      const c = canvas.current;
      if (disposed || !node || !c) return;
      const s = createRipple(c);
      if (!s) 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);
        state.current.bleed = (width - node.getBoundingClientRect().width) / 2;
        state.current.width = width;
        state.current.height = height;
        s.uniform("u_aspect", width / height);
      };
      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 st = state.current;
      const p = pointer.current;
      st.time += dt;
      st.hover = damp(st.hover, p.active && p.inside ? 1 : 0, 6, dt);
      if (p.active) {
        st.mx = damp(st.mx, (p.x + st.bleed) / st.width, 14, dt);
        st.my = damp(st.my, (p.y + st.bleed) / st.height, 14, dt);
      }
      if (st.hover < 0.001) return;
      s.uniform("u_mouse", st.mx, st.my);
      s.uniform("u_time", st.time);
      s.uniform("u_hover", st.hover);
      s.uniform("u_mode", variant === "lens" ? 1 : 0);
      s.uniform("u_strength", strength);
      s.draw();
    },
    { ref: root, enabled: ready },
  );

  // Draw one still frame as soon as the shader is ready, before any hover.
  useEffect(() => {
    if (!ready || !shader.current) return;
    shader.current.uniform("u_hover", 0);
    shader.current.draw();
  }, [ready]);

  return (
    <CanvasText
      text={text}
      as={as}
      className={className}
      style={style}
      rootRef={root}
      canvasRef={canvas}
      bleed={0.4}
      fallback={reduced ? text : undefined}
      canvasStyle={{ opacity: ready ? 1 : 0 }}
    >
      {!ready && !reduced && (
        <span aria-hidden="true" style={{ position: "absolute", inset: 0, whiteSpace: "pre" }}>
          {text}
        </span>
      )}
    </CanvasText>
  );
}

export default ShaderRipple;
```

### components/textlab/shader-ripple/gl.ts

```tsx
"use client";

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

const FRAGMENT = `
uniform sampler2D u_tex;
uniform vec2 u_mouse;
uniform float u_aspect;
uniform float u_time;
uniform float u_hover;
uniform float u_mode;
uniform float u_strength;
varying vec2 v_uv;
void main() {
  vec2 uv = v_uv;
  vec2 d = (uv - u_mouse) * vec2(u_aspect, 1.0);
  float dist = length(d);
  vec2 dir = dist > 0.0001 ? d / dist : vec2(0.0);
  if (u_mode < 0.5) {
    // Ripple: rings travel outward from the pointer and fade with distance.
    float wave = sin(dist * 38.0 - u_time * 7.0) * exp(-dist * 5.0);
    uv += dir / vec2(u_aspect, 1.0) * wave * 0.018 * u_strength * u_hover;
  } else {
    // Lens: magnify inside a soft circle around the pointer.
    float r = 0.22;
    float k = smoothstep(r, 0.0, dist) * 0.45 * u_strength * u_hover;
    uv = u_mouse + (uv - u_mouse) * (1.0 - k);
  }
  gl_FragColor = texture2D(u_tex, uv);
}`;

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

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