# Chromatic text

> The color channels split and rejoin.

- Type: Component (distort)
- Page: https://textlab.dev/components/chromatic-text
- Markdown: https://textlab.dev/components/chromatic-text.md
- Registry item: https://textlab.dev/r/chromatic-text.json
- Source file: `registry/textlab/chromatic-text/chromatic-text.tsx`
- Dependencies: none
- Variants: hover, velocity, pulse
- Keywords: chromatic aberration, rgb split, fringe, lens, glitch

## When to use

- Music, fashion and camera brands.
- Display headlines that should react to energy.

## When not to use

- Body text; fringes reduce legibility.
- Interfaces where it would read as a rendering bug.

## Installation

### CLI

```bash
npx shadcn@latest add @textlab/chromatic-text
pnpm dlx shadcn@latest add @textlab/chromatic-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/chromatic-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 { ChromaticText } from "@/components/textlab/chromatic-text/chromatic-text";

export function Example() {
  return <ChromaticText as="h2" text="Out of focus" variant="velocity" />;
}
```

## API reference

### ChromaticText

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"hover" \| "velocity" \| "pulse"` | `"hover"` | Split while hovered, with pointer speed, or in a brief pulse every few seconds. |
| `amount` | `number` | `0.06` | Largest channel offset in em. |
| `colors` | `[string, string]` | `red and cyan` | Fringe colors. |
| `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 real text stays on top and unchanged; fringes are decorative copies.
- Pulse is brief and infrequent, never a flash.

## Motion

- Channels slide on damped springs, so the split eases in and out.
- Velocity follows the direction you moved the pointer.

## Reduced motion

No fringes; plain text.

## Performance

- Two transformed copies; offsets are CSS variables written per frame only while visible.

## 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: hover, velocity, pulse.

## Related

- [Glitch text](https://textlab.dev/components/glitch-text.md): A controlled digital glitch on cue.
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.
- [Slice reveal](https://textlab.dev/components/slice-reveal.md): The title arrives in offset horizontal slices.

## Also in distort

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

## Source

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

```tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ElementType, type PointerEvent as ReactPointerEvent } from "react";
import { srOnly } from "../lib/a11y";
import { damp } from "../lib/math";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";

export type ChromaticTextVariant = "hover" | "velocity" | "pulse";

export interface ChromaticTextProps {
  text: string;
  /** hover splits while pointed at, velocity splits with pointer speed, pulse splits briefly every few seconds. */
  variant?: ChromaticTextVariant;
  /** Largest channel offset, in em. */
  amount?: number;
  /** Fringe colors, warm then cool. */
  colors?: [string, string];
  as?: ElementType;
  reducedMotion?: ReducedMotion;
  className?: string;
  style?: CSSProperties;
}

/**
 * The color channels split and rejoin, like a lens at its limit. Two tinted copies slide apart
 * behind the real text, so the letters themselves never lose their shape.
 */
export function ChromaticText({ text, variant = "hover", amount = 0.06, colors = ["oklch(0.65 0.25 25)", "oklch(0.75 0.15 210)"], as: Tag = "span", reducedMotion = "user", className, style }: ChromaticTextProps) {
  const root = useRef<HTMLElement | null>(null);
  const warm = useRef<HTMLSpanElement | null>(null);
  const cool = useRef<HTMLSpanElement | null>(null);
  const reduced = useReduced(reducedMotion);
  const [hover, setHover] = useState(false);
  const target = useRef(0);
  const current = useRef(0);
  const angle = useRef(0);
  const last = useRef<{ x: number; y: number; t: number } | null>(null);
  const resting = useRef(true);

  useEffect(() => {
    if (reduced || variant !== "velocity") return;
    const move = (event: PointerEvent) => {
      if (event.pointerType === "touch") return;
      const now = performance.now();
      const prev = last.current;
      last.current = { x: event.clientX, y: event.clientY, t: now };
      if (!prev) return;
      const dx = event.clientX - prev.x;
      const dy = event.clientY - prev.y;
      const speed = Math.hypot(dx, dy) / Math.max(8, now - prev.t);
      target.current = Math.min(1, speed / 2.5);
      angle.current = Math.atan2(dy, dx);
    };
    window.addEventListener("pointermove", move, { passive: true });
    return () => window.removeEventListener("pointermove", move);
  }, [reduced, variant]);

  useFrame(
    (time, dt) => {
      const node = root.current;
      if (!node) return;
      if (variant === "hover") target.current = hover ? 1 : 0;
      if (variant === "pulse") {
        const phase = time % 3.2;
        target.current = phase < 0.18 ? 1 : 0;
        angle.current = Math.sin(time * 1.7);
      }
      if (variant === "velocity") target.current = damp(target.current, 0, 3, dt);
      current.current = damp(current.current, target.current, variant === "pulse" ? 30 : 12, dt);
      // Nothing to write once the channels are back together; idle text costs nothing.
      const still = current.current < 0.0005 && target.current === 0;
      if (still && resting.current) return;
      resting.current = still;
      const a = angle.current;
      const r = current.current * amount;
      const x = (Math.cos(a) * r).toFixed(4);
      const y = (Math.sin(a) * r * 0.5).toFixed(4);
      // Transforms go straight onto each fringe; a variable on the parent would restyle every child.
      if (warm.current) warm.current.style.transform = `translate(${x}em, ${y}em)`;
      if (cool.current) cool.current.style.transform = `translate(${-x}em, ${-y}em)`;
    },
    { ref: root, enabled: !reduced },
  );

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

  const fringe = (color: string): CSSProperties => ({
    position: "absolute",
    inset: 0,
    color,
    opacity: 0.85,
    pointerEvents: "none",
  });

  return (
    <Tag
      ref={root}
      className={className}
      style={{ position: "relative", display: "inline-block", isolation: "isolate", ...style }}
      onPointerEnter={(event: ReactPointerEvent) => event.pointerType !== "touch" && setHover(true)}
      onPointerLeave={(event: ReactPointerEvent) => event.pointerType !== "touch" && setHover(false)}
    >
      <span style={srOnly}>{text}</span>
      <span ref={warm} aria-hidden="true" style={fringe(colors[0])}>
        {text}
      </span>
      <span ref={cool} aria-hidden="true" style={fringe(colors[1])}>
        {text}
      </span>
      <span aria-hidden="true" style={{ position: "relative" }}>
        {text}
      </span>
    </Tag>
  );
}

export default ChromaticText;
```

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