# Liquid text

> Ripples run through the letters under the cursor.

- Type: Component (distort)
- Page: https://textlab.dev/components/liquid-text
- Markdown: https://textlab.dev/components/liquid-text.md
- Registry item: https://textlab.dev/r/liquid-text.json
- Source file: `registry/textlab/liquid-text/liquid-text.tsx`
- Dependencies: none
- Variants: ripple, wave, still
- Keywords: liquid, water, ripple, displacement, turbulence, svg filter

## When to use

- Water, wellness and audio brands.
- Large display words people will hover.

## When not to use

- Text that must be read while the pointer is over it.
- Small sizes; the ripple scale is in pixels.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/liquid-text
pnpm dlx shadcn@latest add @textlab/liquid-text
```

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/liquid-text.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 { LiquidText } from "@/components/textlab/liquid-text/liquid-text";

export function Example() {
  return <LiquidText as="h2" text="Still water" variant="still" />;
}
```

## API reference

### LiquidText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"ripple" \| "wave" \| "still"` | `"ripple"` | Stir under the pointer, keep a slow current running, or stay calm until touched. |
| `strength` | `number` | `14` | Displacement in px at full stir. |
| `radius` | `number` | `1.6` | Radius of the stirred area in em. |
| `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

- Only a decorative copy is distorted; screen readers read the text once.
- Outside the pointer's circle the text is untouched.

## Motion

- The current drifts slowly on two sine waves so it never loops visibly.
- Stir fades in and out with the pointer instead of switching.

## Reduced motion

Plain, still text.

## Performance

- One SVG turbulence filter. The loop pauses offscreen and when the tab is hidden.

## 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, wave, still.

## Related

- [Heat haze](https://textlab.dev/components/heat-haze.md): A mirage warps the air around the type.
- [Gooey text](https://textlab.dev/components/gooey-text.md): Words melt into each other.
- [Ink bleed](https://textlab.dev/components/ink-bleed.md): Text soaks in like ink on paper.

## Also in distort

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

## Source

### components/textlab/liquid-text/liquid-text.tsx

```tsx
"use client";

import { useRef, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { useFrame } from "../lib/use-frame";
import { usePointerVars } from "../lib/use-pointer-vars";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useSvgId } from "../lib/use-svg-id";

export type LiquidTextVariant = "ripple" | "wave" | "still";

export interface LiquidTextProps {
  text: string;
  /** ripple stirs the letters under the pointer, wave keeps a slow current running, still is calm until touched. */
  variant?: LiquidTextVariant;
  /** Displacement strength in px at full stir. */
  strength?: number;
  /** Radius of the stirred area, in em. */
  radius?: number;
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * Ripples run through the letters under the cursor. A displaced copy of the text is revealed only
 * around the pointer, so the rest of the word stays crisp and readable.
 */
export function LiquidText({ text, variant = "ripple", strength = 14, radius = 1.6, as: Tag = "span", reducedMotion = "user", className, style }: LiquidTextProps) {
  const id = useSvgId("tl-liquid");
  const root = useRef<HTMLElement | null>(null);
  const turbulence = useRef<SVGFETurbulenceElement | null>(null);
  const displace = useRef<SVGFEDisplacementMapElement | null>(null);
  const reduced = useReduced(reducedMotion);
  usePointerVars(root, { enabled: !reduced, rate: 10, rest: null });

  useFrame(
    (time) => {
      const t = turbulence.current;
      const d = displace.current;
      const node = root.current;
      if (!t || !d || !node) return;
      const inside = Number(node.style.getPropertyValue("--tl-in") || 0);
      const ambient = variant === "wave" ? 0.45 : variant === "ripple" ? 0.12 : 0;
      const amount = Math.max(ambient, inside);
      t.setAttribute("baseFrequency", `${0.012 + Math.sin(time * 0.6) * 0.004} ${0.05 + Math.cos(time * 0.8) * 0.012}`);
      d.setAttribute("scale", (amount * strength).toFixed(2));
    },
    { ref: root, enabled: !reduced },
  );

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

  // The stirred copy shows inside the circle and the crisp copy outside it, cross-faded at the edge.
  const at = `circle ${radius}em at var(--tl-x, -100px) var(--tl-y, -100px)`;
  const inner = variant === "wave" ? "none" : `radial-gradient(${at}, #000 25%, transparent 100%)`;
  const outer = variant === "wave" ? "none" : `radial-gradient(${at}, transparent 25%, #000 100%)`;

  return (
    <Tag ref={root} className={className} style={{ position: "relative", display: "inline-block", ...style }}>
      <svg aria-hidden="true" width="0" height="0" style={{ position: "absolute" }}>
        <defs>
          <filter id={id} x="-10%" y="-30%" width="120%" height="160%">
            <feTurbulence ref={turbulence} type="fractalNoise" baseFrequency="0.012 0.05" numOctaves="2" seed="3" result="noise" />
            <feDisplacementMap ref={displace} in="SourceGraphic" in2="noise" scale="0" xChannelSelector="R" yChannelSelector="G" />
          </filter>
        </defs>
      </svg>
      <span style={srOnly}>{text}</span>
      <span aria-hidden="true" style={{ visibility: variant === "wave" ? "hidden" : undefined, WebkitMaskImage: outer, maskImage: outer }}>
        {text}
      </span>
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, filter: `url(#${id})`, WebkitMaskImage: inner, maskImage: inner }}>
        {text}
      </span>
    </Tag>
  );
}

export default LiquidText;
```

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