# Textlab
> Textlab is a React library of text effects: reveals, numbers, typing, light, filters, scroll, drawing, cursor, physics, paths, pixels and kinetic type. Every component installs with the shadcn CLI, copies its source into your project, ships a reduced-motion branch and screen-reader text, and has a Markdown page for agents.
Textlab has 70 components. Free and open source (MIT).
## Install
Each component is a shadcn registry item. It pulls in the shared utilities (`text-fx`) and tokens (`text-fx-tokens`).
```bash
npx shadcn@latest add https://textlab.dev/r/blur-reveal.json
```
Or register the namespace once in `components.json` and install by name:
```json
{ "registries": { "@textlab": "https://textlab.dev/r/{name}.json" } }
```
```bash
npx shadcn@latest add @textlab/blur-reveal @textlab/scramble-text
```
Import the tokens once in the root layout: `import "@/components/textlab/tokens.css";`. Files install into `components/textlab/` and import each other with relative paths. Dark mode: `.dark` or `data-theme="dark"` on ``.
## Rules for agents
- Prefer an existing Textlab component over writing a new text effect.
- Read the component's Markdown before using it; use only documented props.
- Shared props mean the same thing everywhere: `text`, `as`, `variant`, `trigger` (mount, inView, hover, manual), `active`, `once`, `delay`, `stagger`, `duration`, `ease`, `reducedMotion`, `className`.
- Keep the screen-reader text and the reduced-motion branch when you edit the source.
- Use the tokens (`--tl-foreground`, `--tl-accent`, `--tl-fx-*`) instead of raw colors and check both themes.
- Sentence case copy, no em dashes, no eyebrow labels.
## AI
- [MCP server](https://textlab.dev/api/mcp): streamable HTTP, read only. Tools: list_components, search_components, get_component, get_install_command, get_tokens.
- [Agent skill](https://textlab.dev/r/skills/textlab/SKILL.md)
- [Registry index](https://textlab.dev/r/registry.json)
- [Catalog](https://textlab.dev/r/catalog.json): metadata for every component.
- [Full context](https://textlab.dev/llms-full.txt): this file with every component's Markdown inlined.
- [Small context](https://textlab.dev/llms-small.txt): one line per component.
## Components
### Reveal
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
- [Tracking reveal](https://textlab.dev/components/tracking-reveal.md): Letter spacing breathes open as it appears.
- [Slice reveal](https://textlab.dev/components/slice-reveal.md): The title arrives in offset horizontal slices.
- [Focus pull](https://textlab.dev/components/focus-pull.md): Focus racks from the center letter outward.
### Numbers
- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
### Typing
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
### Light
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
- [Ember text](https://textlab.dev/components/ember-text.md): Letters land hot and cool down to the text color.
### 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.
- [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.
### Scroll
- [Scroll fill](https://textlab.dev/components/scroll-fill.md): Words light up as you read down the page.
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
### Draw
- [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.
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
- [Underline draw](https://textlab.dev/components/underline-draw.md): A hand-drawn underline sketches in.
- [Strike-through](https://textlab.dev/components/strike-through.md): Cross out a word and write the new one.
- [Circle annotate](https://textlab.dev/components/circle-annotate.md): A pen circles the word that matters.
### Cursor
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
### Physics
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
### Path
- [Path text](https://textlab.dev/components/path-text.md): Text travels along a wave, circle or spiral.
### 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.
- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.
- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
### Kinetic
- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
- [Word sequence](https://textlab.dev/components/word-sequence.md): A sentence choreographed word by word.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
- [Echo text](https://textlab.dev/components/echo-text.md): Every change leaves fading echoes behind.
## Docs
- [Introduction](https://textlab.dev/docs/introduction)
- [Installation](https://textlab.dev/docs/installation)
- [Tokens](https://textlab.dev/docs/tokens)
- [AI and MCP](https://textlab.dev/docs/ai)
- [Changelog](https://textlab.dev/docs/changelog)
- [Guides](https://textlab.dev/guides)
- [Compare](https://textlab.dev/compare)
- [Roadmap](https://textlab.dev/roadmap)
---
# Blur reveal
> Words sharpen into focus.
- Type: Component (reveal)
- Page: https://textlab.dev/components/blur-reveal
- Markdown: https://textlab.dev/components/blur-reveal.md
- Registry item: https://textlab.dev/r/blur-reveal.json
- Source file: `registry/textlab/blur-reveal/blur-reveal.tsx`
- Dependencies: motion
- Variants: word, char, line
- Keywords: blur, focus, fade, in view, heading
## When to use
- Hero and section titles that should feel calm and deliberate.
- Short lines of copy, up to two sentences.
## When not to use
- Body paragraphs. Use scroll-fill for long copy that follows the reader.
- Labels that change often. Use text-morph.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/blur-reveal
pnpm dlx shadcn@latest add @textlab/blur-reveal
```
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/blur-reveal.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 { BlurReveal } from "@/components/textlab/blur-reveal/blur-reveal";
export function Example() {
return ;
}
```
## API reference
### BlurReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"word" \| "char" \| "line"` | `"word"` | What sharpens on its own: each word, each letter or each measured line. |
| `blur` | `number` | `12` | Starting blur in px. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `trigger` | `"mount" \| "inView" \| "hover" \| "manual"` | `"inView"` | What starts the animation. manual follows `active`. |
| `once` | `boolean` | `true` | In view: play once instead of on every entry. |
| `delay` | `number` | `0` | Seconds before the first part moves. |
| `stagger` | `number` | `0.06 (word), 0.02 (char), 0.1 (line)` | Seconds between parts. |
| `duration` | `number` | `0.8` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"enter"` | Easing token or cubic bezier. |
| `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 full text is in a visually hidden span; the animated parts are aria-hidden.
- Keeps the heading tag you pass with as.
## Motion
- Triggers when 45% of the element is visible.
- Opacity leads, blur clears next, the rise settles last.
## Reduced motion
Shows the text immediately with no blur or movement.
## Performance
- Animates opacity, filter and transform only.
- Blur is the costliest part; keep char variant under ~80 letters.
## 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: word, char, line.
## Related
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Focus pull](https://textlab.dev/components/focus-pull.md): Focus racks from the center letter outward.
## Also in reveal
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
## Source
### components/textlab/blur-reveal/blur-reveal.tsx
```tsx
"use client";
import { RevealText, type RevealBaseProps } from "../lib/reveal";
export type BlurRevealVariant = "word" | "char" | "line";
export interface BlurRevealProps extends RevealBaseProps {
/** word sharpens each word, char each letter, line each measured line. */
variant?: BlurRevealVariant;
/** Starting blur in px. */
blur?: number;
}
const BY = { word: "words", char: "chars", line: "lines" } as const;
/** Words sharpen into focus. Opacity leads so the copy reads before the blur has fully cleared. */
export function BlurReveal({ variant = "word", blur = 12, ...props }: BlurRevealProps) {
return (
);
}
export default BlurReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Word reveal
> Words rise out of a clip.
- Type: Component (reveal)
- Page: https://textlab.dev/components/word-reveal
- Markdown: https://textlab.dev/components/word-reveal.md
- Registry item: https://textlab.dev/r/word-reveal.json
- Source file: `registry/textlab/word-reveal/word-reveal.tsx`
- Dependencies: motion
- Variants: rise, fall, scale
- Keywords: word, rise, clip, mask, heading, stagger
## When to use
- Editorial headlines where each word should land with weight.
- Hero lines on load with trigger="mount".
## When not to use
- Long paragraphs; the stagger gets slow past ~20 words.
- Text inside buttons or form labels.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/word-reveal
pnpm dlx shadcn@latest add @textlab/word-reveal
```
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/word-reveal.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 { WordReveal } from "@/components/textlab/word-reveal/word-reveal";
export function Example() {
return ;
}
```
## API reference
### WordReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"rise" \| "fall" \| "scale"` | `"rise"` | rise comes up from below the line, fall drops from above, scale grows from the baseline. |
| `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. |
| `stagger` | `number` | `0.06` | Seconds between parts. |
| `duration` | `number` | `0.8` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"enter"` | Easing token or cubic bezier. |
| `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
- Screen readers get the full sentence once; the clipped words are aria-hidden.
- Pass as="h1" or as="h2" to keep the outline.
## Motion
- Each word travels its own height, so the motion scales with font size.
- Words start 60 ms apart and settle with the enter ease.
## Reduced motion
Renders the sentence in place with no clip or movement.
## Responsive behavior
- Words wrap naturally at any width; each word animates on its own line.
## Performance
- Transform and opacity only.
- One motion span per word.
## 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: rise, fall, scale.
## Related
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
## Also in reveal
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
- [Tracking reveal](https://textlab.dev/components/tracking-reveal.md): Letter spacing breathes open as it appears.
## Source
### components/textlab/word-reveal/word-reveal.tsx
```tsx
"use client";
import { RevealText, type RevealBaseProps } from "../lib/reveal";
export type WordRevealVariant = "rise" | "fall" | "scale";
export interface WordRevealProps extends RevealBaseProps {
/** rise comes up from below the line, fall drops in from above, scale grows from the baseline. */
variant?: WordRevealVariant;
}
const HIDDEN = {
rise: { y: "110%" },
fall: { y: "-110%" },
scale: { scale: 0.4, opacity: 0, y: "20%" },
} as const;
/** Words rise out of a clip, one after another. The clip keeps descenders and leaves layout untouched. */
export function WordReveal({ variant = "rise", ...props }: WordRevealProps) {
return (
);
}
export default WordReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Line reveal
> Lines lift in, re-measured on resize.
- Type: Component (reveal)
- Page: https://textlab.dev/components/line-reveal
- Markdown: https://textlab.dev/components/line-reveal.md
- Registry item: https://textlab.dev/r/line-reveal.json
- Source file: `registry/textlab/line-reveal/line-reveal.tsx`
- Dependencies: motion
- Variants: rise, mask, tilt
- Keywords: line, paragraph, resize, wrap, mask, tilt
## When to use
- Two to four line intros and pull quotes.
- Copy whose wrap changes across breakpoints.
## When not to use
- Single words; use word-reveal.
- Body text longer than a short paragraph.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/line-reveal
pnpm dlx shadcn@latest add @textlab/line-reveal
```
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/line-reveal.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 { LineReveal } from "@/components/textlab/line-reveal/line-reveal";
export function Example() {
return ;
}
```
## API reference
### LineReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"rise" \| "mask" \| "tilt"` | `"rise"` | rise lifts from a clip, mask uncovers top to bottom, tilt swings up on a hinge. |
| `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. |
| `stagger` | `number` | `0.1` | Seconds between parts. |
| `duration` | `number` | `0.8` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"enter"` | Easing token or cubic bezier. |
| `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 sentence is read once from a visually hidden copy.
- Line groups are aria-hidden presentation only.
## Motion
- Lines start 100 ms apart.
- Line breaks are measured after fonts load and again whenever the width changes.
## Reduced motion
Shows the paragraph as normal text with no grouping or movement.
## Responsive behavior
- Re-groups lines on resize, so narrow screens get more, shorter lines.
## Performance
- One measure per frame at most, triggered by ResizeObserver.
- Transform, opacity and clip-path 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: rise, mask, tilt.
## Related
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
## Also in reveal
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [Tracking reveal](https://textlab.dev/components/tracking-reveal.md): Letter spacing breathes open as it appears.
## Source
### components/textlab/line-reveal/line-reveal.tsx
```tsx
"use client";
import { RevealText, type RevealBaseProps } from "../lib/reveal";
export type LineRevealVariant = "rise" | "mask" | "tilt";
export interface LineRevealProps extends RevealBaseProps {
/** rise lifts each line out of a clip, mask uncovers it top to bottom, tilt swings it up on a hinge. */
variant?: LineRevealVariant;
}
/**
* Lines lift in one after another. Lines are measured from the real layout and re-measured
* when the container resizes, so the reveal always follows how the text actually wraps.
*/
export function LineReveal({ variant = "rise", ...props }: LineRevealProps) {
if (variant === "mask") {
return ;
}
if (variant === "tilt") {
return (
);
}
return ;
}
export default LineReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Character reveal
> Letters arrive one at a time.
- Type: Component (reveal)
- Page: https://textlab.dev/components/char-reveal
- Markdown: https://textlab.dev/components/char-reveal.md
- Registry item: https://textlab.dev/r/char-reveal.json
- Source file: `registry/textlab/char-reveal/char-reveal.tsx`
- Dependencies: motion
- Variants: fade, drop, spin, scale
- Keywords: character, letter, stagger, drop, spin, pop
## When to use
- Short display words and names.
- Moments where the arrival itself is the point.
## When not to use
- Anything over ~60 letters; the tail gets long.
- Text people need to read quickly.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/char-reveal
pnpm dlx shadcn@latest add @textlab/char-reveal
```
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/char-reveal.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 { CharReveal } from "@/components/textlab/char-reveal/char-reveal";
export function Example() {
return ;
}
```
## API reference
### CharReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"fade" \| "drop" \| "spin" \| "scale"` | `"fade"` | fade is quiet, drop falls into place, spin turns up from the baseline, scale pops up from small. |
| `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. |
| `stagger` | `number` | `0.02` | Seconds between parts. |
| `duration` | `number` | `0.6 (fade), 0.7` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"enter"` | Easing token or cubic bezier. |
| `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
- Letters are aria-hidden; the word is read once.
- Grapheme-aware, so emoji and accents never split.
## Motion
- 20 ms between letters; the total stays under a second for most words.
- Words are nowrap, so lines never jump while letters move.
## Reduced motion
Shows the text immediately.
## Performance
- One motion span per letter. Fine for headings, not paragraphs.
## 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: fade, drop, spin, scale.
## Related
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
- [Focus pull](https://textlab.dev/components/focus-pull.md): Focus racks from the center letter outward.
## Also in reveal
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
## Source
### components/textlab/char-reveal/char-reveal.tsx
```tsx
"use client";
import { RevealText, type RevealBaseProps } from "../lib/reveal";
export type CharRevealVariant = "fade" | "drop" | "spin" | "scale";
export interface CharRevealProps extends RevealBaseProps {
/** fade is quiet, drop falls into place, spin turns up from the baseline, scale pops from nothing. */
variant?: CharRevealVariant;
}
const HIDDEN = {
fade: { opacity: 0, y: "0.12em" },
drop: { opacity: 0, y: "-0.7em" },
spin: { opacity: 0, rotate: -90, scale: 0.5 },
scale: { opacity: 0, scale: 0.35 },
} as const;
/** Letters arrive one at a time. Each word stays unbreakable so lines never reflow mid-animation. */
export function CharReveal({ variant = "fade", duration, ...props }: CharRevealProps) {
return (
);
}
export default CharReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Wipe reveal
> A soft edge uncovers the line.
- Type: Component (reveal)
- Page: https://textlab.dev/components/wipe-reveal
- Markdown: https://textlab.dev/components/wipe-reveal.md
- Registry item: https://textlab.dev/r/wipe-reveal.json
- Source file: `registry/textlab/wipe-reveal/wipe-reveal.tsx`
- Dependencies: motion
- Variants: left, right, center, diagonal
- Keywords: wipe, mask, sweep, unveil, gradient
## When to use
- Titles that should feel unveiled rather than assembled.
- Text that must stay selectable and unsplit.
## When not to use
- Multi-line paragraphs; the sweep crosses the whole block at once.
- When you need per-word timing; use word-reveal.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/wipe-reveal
pnpm dlx shadcn@latest add @textlab/wipe-reveal
```
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/wipe-reveal.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 { WipeReveal } from "@/components/textlab/wipe-reveal/wipe-reveal";
export function Example() {
return ;
}
```
## API reference
### WipeReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"left" \| "right" \| "center" \| "diagonal"` | `"left"` | Direction of the sweep; center opens from the middle. |
| `softness` | `number` | `0.18` | Width of the feathered edge as a share of the text, 0 to 0.5. |
| `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` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"standard"` | Easing token or cubic bezier. |
| `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 text is never split, so assistive tech reads it as plain text.
- Mask padding keeps descenders inside without changing layout.
## Motion
- The edge travels with the standard ease so the sweep speed feels even.
- It overshoots by the soft width so the last letter finishes fully opaque.
## Reduced motion
Shows the text with no mask.
## Performance
- Animates one CSS variable on one element; the mask is GPU composited.
## 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: left, right, center, diagonal.
## Related
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Outline draw](https://textlab.dev/components/outline-draw.md): The outline draws itself, then fills.
## Also in reveal
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
## Source
### components/textlab/wipe-reveal/wipe-reveal.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { motion } from "motion/react";
import { duration as durations, resolveEase, type EaseInput } from "../lib/motion-tokens";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type WipeRevealVariant = "left" | "right" | "center" | "diagonal";
export interface WipeRevealProps {
text: string;
/** Direction of the sweep. center opens from the middle outward. */
variant?: WipeRevealVariant;
/** Width of the soft edge as a share of the text, 0 to 0.5. */
softness?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
amount?: number;
delay?: number;
duration?: number;
ease?: EaseInput;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
id?: string;
onComplete?: () => void;
}
function mask(variant: WipeRevealVariant, edge: number): string {
const e = `${edge * 100}%`;
if (variant === "center") {
return `linear-gradient(90deg, transparent calc(50% - var(--tl-wipe)), #000 calc(50% - var(--tl-wipe) + ${e}), #000 calc(50% + var(--tl-wipe) - ${e}), transparent calc(50% + var(--tl-wipe)))`;
}
const angle = variant === "left" ? 90 : variant === "right" ? 270 : 115;
return `linear-gradient(${angle}deg, #000 calc(var(--tl-wipe) - ${e}), transparent var(--tl-wipe))`;
}
/**
* A soft edge uncovers the line. The mask sweeps a CSS variable, so no letter is ever cut by a hard
* clip and the text stays real, selectable text the whole time.
*/
export function WipeReveal({
text,
variant = "left",
softness = 0.18,
as: Tag = "span",
trigger = "inView",
active,
once = true,
amount,
delay = 0,
duration = durations.reveal * 1.25,
ease = "standard",
reducedMotion = "user",
className,
style,
id,
onComplete,
}: WipeRevealProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once, amount });
const hydrated = useHydrated();
const reduced = useReduced(reducedMotion);
const edge = Math.min(0.5, Math.max(0, softness));
// The sweep runs past the far edge by the soft width, so the last letter ends fully opaque.
const end = variant === "center" ? `${50 + edge * 100}%` : `${100 + edge * 100 + (variant === "diagonal" ? 20 : 0)}%`;
const image = mask(variant, edge);
if (reduced) {
return (
{text}
);
}
return (
{text}
);
}
export default WipeReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# In-view title
> Bring a section title in as it scrolls into view.
- Type: Component (reveal)
- Page: https://textlab.dev/components/in-view-title
- Markdown: https://textlab.dev/components/in-view-title.md
- Registry item: https://textlab.dev/r/in-view-title.json
- Source file: `registry/textlab/in-view-title/in-view-title.tsx`
- Dependencies: motion
- Variants: blur, words, lines, wipe
- Keywords: in view, scroll, section title, heading, reveal on scroll
## When to use
- Section headings that should animate as the reader arrives.
- Pages where every section title needs the same entrance.
## When not to use
- Hero copy that animates on load; use blur-reveal with trigger="mount".
- A paragraph that follows scroll progress; use scroll-fill.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/in-view-title
pnpm dlx shadcn@latest add @textlab/in-view-title
```
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/in-view-title.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 { InViewTitle } from "@/components/textlab/in-view-title/in-view-title";
export function Example() {
return ;
}
```
## API reference
### InViewTitle
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"blur" \| "words" \| "lines" \| "wipe"` | `"blur"` | blur suits most headings; words and lines rise from a clip; wipe uncovers left to right. |
| `as` | `"h1" \| "h2" \| "h3" \| "h4"` | `"h2"` | Heading level. |
| `once` | `boolean` | `true` | In view: play once instead of on every entry. |
| `amount` | `number` | `0.45` | Share of the title that must be visible before it starts. |
| `delay` | `number` | `0` | Seconds before the first part moves. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
| `id` | `string` | – | Forwarded to the heading, for anchor links. |
## Accessibility
- Renders a real heading tag with the full text for screen readers.
- Choose as to match the page outline.
## Motion
- Starts when 45% of the title is visible.
- Titles already scrolled past show immediately.
- Hiding with once={false} is a quick fade with no stagger.
## Reduced motion
The title appears instantly with no blur, clip or mask.
## Responsive behavior
- Words, blur and wipe wrap naturally; lines are re-measured on resize.
## Performance
- One IntersectionObserver per title.
- Fine for headings, not long body text.
## 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: blur, words, lines, wipe.
## Related
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
## Also in reveal
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Split text](https://textlab.dev/components/split-text.md): Stagger anything by chars, words or lines.
- [Tracking reveal](https://textlab.dev/components/tracking-reveal.md): Letter spacing breathes open as it appears.
## Source
### components/textlab/in-view-title/in-view-title.tsx
```tsx
"use client";
import type { CSSProperties } from "react";
import { BlurReveal } from "../blur-reveal/blur-reveal";
import { LineReveal } from "../line-reveal/line-reveal";
import { WipeReveal } from "../wipe-reveal/wipe-reveal";
import { WordReveal } from "../word-reveal/word-reveal";
import type { ReducedMotion } from "../lib/use-reduced";
export type InViewTitleVariant = "blur" | "words" | "lines" | "wipe";
export interface InViewTitleProps {
text: string;
/** blur suits most headings; words and lines rise from a clip; wipe uncovers left to right. */
variant?: InViewTitleVariant;
as?: "h1" | "h2" | "h3" | "h4";
/** Set false to replay on every entry. */
once?: boolean;
/** Share of the title that must be visible before it starts, 0 to 1. */
amount?: number;
delay?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
id?: string;
}
/**
* A section title that reveals itself as it scrolls into view, in four styles that share timing.
* Titles already above the viewport show at once, and server-rendered titles appear on their own if
* scripts are slow.
*/
export function InViewTitle({ text, variant = "blur", as = "h2", once = true, amount = 0.45, delay = 0, reducedMotion, className, style, id }: InViewTitleProps) {
const shared = { text, as, once, amount, delay, reducedMotion, className, style, id, trigger: "inView" as const };
if (variant === "words") return ;
if (variant === "lines") return ;
if (variant === "wipe") return ;
return ;
}
export default InViewTitle;
```
Full library index: https://textlab.dev/llms.txt
---
# Split text
> Stagger anything by chars, words or lines.
- Type: Component (reveal)
- Page: https://textlab.dev/components/split-text
- Markdown: https://textlab.dev/components/split-text.md
- Registry item: https://textlab.dev/r/split-text.json
- Source file: `registry/textlab/split-text/split-text.tsx`
- Dependencies: motion
- Variants: words, chars, lines
- Keywords: split, stagger, chars, words, lines, splittext
## When to use
- Custom entrances the other reveals do not cover.
- Choreography where order matters: center out, edges in.
## When not to use
- When a named reveal already fits; they carry better defaults.
- Long body copy split by chars.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/split-text
pnpm dlx shadcn@latest add @textlab/split-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/split-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 { SplitText } from "@/components/textlab/split-text/split-text";
export function Example() {
return ;
}
```
## API reference
### SplitText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"chars" \| "words" \| "lines"` | `"words"` | How the text is split. Lines are measured from layout and re-measured on resize. |
| `preset` | `"fade-up" \| "fade-down" \| "zoom" \| "blur" \| "flip"` | `"fade-up"` | A named starting pose. |
| `from` | `TargetAndTransition \| (index, total) => TargetAndTransition` | – | Custom starting pose per part. Overrides preset. |
| `order` | `"start" \| "end" \| "center" \| "edges"` | `"start"` | Which part moves first. |
| `clip` | `boolean` | `false` | Clip each part so it appears out of nothing. |
| `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. |
| `stagger` | `number` | – | Seconds between parts. |
| `duration` | `number` | `0.8` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"enter"` | Easing token or cubic bezier. |
| `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
- One visually hidden copy for screen readers; every part is aria-hidden.
- Splits by grapheme, so emoji and combined letters stay whole.
## Motion
- Default stagger: 20 ms per char, 60 ms per word, 100 ms per line.
- Opacity leads, filter follows, transform settles last.
## Reduced motion
Shows plain text with no splitting.
## Performance
- Up to ~200 parts per instance.
- Line mode measures once per frame on 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: words, chars, lines.
## Related
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
## Also in reveal
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Tracking reveal](https://textlab.dev/components/tracking-reveal.md): Letter spacing breathes open as it appears.
## Source
### components/textlab/split-text/split-text.tsx
```tsx
"use client";
import type { TargetAndTransition } from "motion/react";
import { RevealText, type RevealBaseProps, type RevealOrder } from "../lib/reveal";
import type { SplitBy } from "../lib/split";
export type SplitTextVariant = "chars" | "words" | "lines";
export type SplitTextPreset = "fade-up" | "fade-down" | "zoom" | "blur" | "flip";
export interface SplitTextProps extends RevealBaseProps {
/** How the text is split. lines are measured from layout and re-measured on resize. */
variant?: SplitTextVariant;
/** A named starting pose. Ignored when `from` is set. */
preset?: SplitTextPreset;
/** Full control over where each part starts. Receives the part index and count when a function. */
from?: TargetAndTransition | ((index: number, total: number) => TargetAndTransition);
/** Which part moves first. */
order?: RevealOrder;
/** Clip each part so it appears out of nothing. */
clip?: boolean;
}
const PRESETS: Record = {
"fade-up": { opacity: 0, y: "0.4em" },
"fade-down": { opacity: 0, y: "-0.4em" },
zoom: { opacity: 0, scale: 0.6 },
blur: { opacity: 0, filter: "blur(8px)" },
flip: { opacity: 0, transformPerspective: 500, rotateX: 90 },
};
/**
* Stagger anything by chars, words or lines. The escape hatch under every reveal: pick a split,
* a starting pose and an order, and it handles screen readers, line detection and reduced motion.
*/
export function SplitText({ variant = "words", preset = "fade-up", from, order = "start", clip = false, ...props }: SplitTextProps) {
const by: SplitBy = variant;
return (
);
}
export default SplitText;
```
Full library index: https://textlab.dev/llms.txt
---
# Tracking reveal
> Letter spacing breathes open as it appears.
- Type: Component (reveal)
- Page: https://textlab.dev/components/tracking-reveal
- Markdown: https://textlab.dev/components/tracking-reveal.md
- Registry item: https://textlab.dev/r/tracking-reveal.json
- Source file: `registry/textlab/tracking-reveal/tracking-reveal.tsx`
- Dependencies: motion
- Variants: open, close
- Keywords: tracking, letter spacing, kerning, luxury, breathe
## When to use
- Luxury or editorial titles where restraint matters.
- Short uppercase labels and wordmarks.
## When not to use
- Long titles; the outer letters travel far.
- Body text.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/tracking-reveal
pnpm dlx shadcn@latest add @textlab/tracking-reveal
```
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/tracking-reveal.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 { TrackingReveal } from "@/components/textlab/tracking-reveal/tracking-reveal";
export function Example() {
return ;
}
```
## API reference
### TrackingReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"open" \| "close"` | `"open"` | open starts packed and breathes out; close starts wide and gathers in. |
| `spread` | `number` | `0.08` | Distance each letter travels per step from the center, in em. |
| `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.1` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"emphasized"` | Easing token or cubic bezier. |
| `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
- Letters are aria-hidden; the title is read once.
- No reflow, so surrounding layout never moves.
## Motion
- Every letter moves together; distance grows with its offset from the center.
- Uses the emphasized ease for a long, soft landing.
## Reduced motion
Shows the text at its normal spacing.
## Performance
- Transforms instead of letter-spacing, so no layout work per frame.
## 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: open, close.
## Related
- [Focus pull](https://textlab.dev/components/focus-pull.md): Focus racks from the center letter outward.
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
## Also in reveal
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
## Source
### components/textlab/tracking-reveal/tracking-reveal.tsx
```tsx
"use client";
import { RevealText, type RevealBaseProps } from "../lib/reveal";
export type TrackingRevealVariant = "open" | "close";
export interface TrackingRevealProps extends RevealBaseProps {
/** open starts tightly packed and breathes out; close starts spread wide and gathers in. */
variant?: TrackingRevealVariant;
/** How far each letter travels per step from the center, in em. */
spread?: number;
}
/**
* Letter spacing breathes open as the word appears. Letters move with transforms from the
* text's center instead of animating letter-spacing, so nothing reflows.
*/
export function TrackingReveal({ variant = "open", spread = 0.08, duration = 1.1, ease = "emphasized", ...props }: TrackingRevealProps) {
const sign = variant === "open" ? -1 : 1.6;
return (
({ x: `${(i - (total - 1) / 2) * spread * sign}em`, opacity: 0, filter: "blur(4px)" })}
{...props}
/>
);
}
export default TrackingReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Slice reveal
> The title arrives in offset horizontal slices.
- Type: Component (reveal)
- Page: https://textlab.dev/components/slice-reveal
- Markdown: https://textlab.dev/components/slice-reveal.md
- Registry item: https://textlab.dev/r/slice-reveal.json
- Source file: `registry/textlab/slice-reveal/slice-reveal.tsx`
- Dependencies: motion
- Variants: 5, 3, 8
- Keywords: slice, stripes, offset, print, registration, poster
## When to use
- Bold single-line display type, posters and title cards.
- Brands with a print or editorial feel.
## When not to use
- Text that wraps onto several lines; slices are cut across the whole block.
- Small sizes where slices are thinner than a stroke.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/slice-reveal
pnpm dlx shadcn@latest add @textlab/slice-reveal
```
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/slice-reveal.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 { SliceReveal } from "@/components/textlab/slice-reveal/slice-reveal";
export function Example() {
return ;
}
```
## API reference
### SliceReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"3" \| "5" \| "8"` | `"5"` | Number of horizontal slices. |
| `offset` | `number` | `0.35` | Starting distance of each slice as a share of the width. |
| `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. |
| `stagger` | `number` | `0.05` | Seconds between parts. |
| `duration` | `number` | `0.8` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"emphasized"` | Easing token or cubic bezier. |
| `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 slices are aria-hidden copies; screen readers read the text once.
- An invisible copy reserves the exact size, so nothing shifts.
## Motion
- Slices alternate direction and start 50 ms apart.
- Opacity resolves early so the shape reads before the slices align.
## Reduced motion
Shows the line whole and still.
## Responsive behavior
- Keeps the line on one row; size it with a fluid font size.
## Performance
- N copies of the text, each with a static clip-path and a transform.
## 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: 5, 3, 8.
## Related
- [Glitch text](https://textlab.dev/components/glitch-text.md): A controlled digital glitch on cue.
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
## Also in reveal
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
## Source
### components/textlab/slice-reveal/slice-reveal.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { motion } from "motion/react";
import { SrText } from "../lib/a11y";
import { duration as durations, resolveEase, type EaseInput } from "../lib/motion-tokens";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type SliceRevealVariant = "3" | "5" | "8";
export interface SliceRevealProps {
text: string;
/** Number of horizontal slices. */
variant?: SliceRevealVariant;
/** How far each slice starts from home, as a share of the text width. */
offset?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
stagger?: number;
duration?: number;
ease?: EaseInput;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
onComplete?: () => void;
}
/**
* The title arrives in offset horizontal slices that slide together, alternating sides,
* like a print registered by hand. Each slice is a clipped copy of the same line.
*/
export function SliceReveal({
text,
variant = "5",
offset = 0.35,
as: Tag = "span",
trigger = "inView",
active,
once = true,
delay = 0,
stagger = 0.05,
duration = durations.reveal,
ease = "emphasized",
reducedMotion = "user",
className,
style,
onComplete,
}: SliceRevealProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const hydrated = useHydrated();
const reduced = useReduced(reducedMotion);
const count = Number(variant);
if (reduced) {
return (
{text}
);
}
const curve = resolveEase(ease);
return (
{text}
{/* The invisible copy holds the size, so slices can be absolutely positioned on top. */}
{text}
{Array.from({ length: count }, (_, i) => {
const top = (i / count) * 100;
const bottom = 100 - ((i + 1) / count) * 100;
const from = (i % 2 === 0 ? -1 : 1) * offset * 100;
return (
{text}
);
})}
);
}
export default SliceReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Focus pull
> Focus racks from the center letter outward.
- Type: Component (reveal)
- Page: https://textlab.dev/components/focus-pull
- Markdown: https://textlab.dev/components/focus-pull.md
- Registry item: https://textlab.dev/r/focus-pull.json
- Source file: `registry/textlab/focus-pull/focus-pull.tsx`
- Dependencies: motion
- Variants: center, start, end
- Keywords: focus, rack focus, lens, blur, cinematic
## When to use
- Cinematic title cards and intros.
- A single word that deserves a moment.
## When not to use
- Text that must be readable immediately.
- More than about 30 letters.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/focus-pull
pnpm dlx shadcn@latest add @textlab/focus-pull
```
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/focus-pull.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 { FocusPull } from "@/components/textlab/focus-pull/focus-pull";
export function Example() {
return ;
}
```
## API reference
### FocusPull
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"center" \| "start" \| "end"` | `"center"` | Where focus lands first. |
| `blur` | `number` | `14` | Starting blur in px. |
| `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. |
| `stagger` | `number` | `0.045` | Seconds between parts. |
| `duration` | `number` | `0.9` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"enter"` | Easing token or cubic bezier. |
| `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
- Letters are aria-hidden; the word is read once.
- Starts at low opacity rather than invisible, so the shape is present early.
## Motion
- Letters sharpen in order of distance from the focal letter.
- Scale settles with blur, like a lens finding focus.
## Reduced motion
The text is sharp and still from the start.
## Performance
- Per-letter blur is costly; keep it to display words.
## 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: center, start, end.
## Related
- [Blur reveal](https://textlab.dev/components/blur-reveal.md): Words sharpen into focus.
- [Tracking reveal](https://textlab.dev/components/tracking-reveal.md): Letter spacing breathes open as it appears.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
## Also in reveal
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Line reveal](https://textlab.dev/components/line-reveal.md): Lines lift in, re-measured on resize.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
## Source
### components/textlab/focus-pull/focus-pull.tsx
```tsx
"use client";
import { RevealText, type RevealBaseProps } from "../lib/reveal";
export type FocusPullVariant = "center" | "start" | "end";
export interface FocusPullProps extends RevealBaseProps {
/** Where focus lands first: the middle letter, the first or the last. */
variant?: FocusPullVariant;
/** Starting blur in px. */
blur?: number;
}
/**
* Focus racks across the word like a camera lens. Letters start soft and slightly too large,
* then sharpen in order from the focal point outward.
*/
export function FocusPull({ variant = "center", blur = 14, stagger = 0.045, duration = 0.9, ...props }: FocusPullProps) {
return (
);
}
export default FocusPull;
```
Full library index: https://textlab.dev/llms.txt
---
# Rolling number
> Each digit rolls to its new value.
- Type: Component (numbers)
- Page: https://textlab.dev/components/rolling-number
- Markdown: https://textlab.dev/components/rolling-number.md
- Registry item: https://textlab.dev/r/rolling-number.json
- Source file: `registry/textlab/rolling-number/rolling-number.tsx`
- Dependencies: motion
- Variants: shortest, up, down
- Keywords: number, counter, digits, roll, slot, animated number, ticker
## When to use
- Counters, balances and scores that change while people watch.
- Dashboards where the change itself is information.
## When not to use
- Numbers that never change after load; use stat-reveal.
- Values updating many times a second; the motion becomes noise.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/rolling-number
pnpm dlx shadcn@latest add @textlab/rolling-number
```
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/rolling-number.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 { RollingNumber } from "@/components/textlab/rolling-number/rolling-number";
export function Example() {
return ;
}
```
## API reference
### RollingNumber
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | The number to show. |
| `variant` | `"shortest" \| "up" \| "down"` | `"shortest"` | shortest takes the nearer way round; up and down always roll one way. |
| `format` | `Intl.NumberFormatOptions` | – | Formatting, for example currency or compact notation. |
| `locale` | `string` | `"en-US"` | Fixed by default so server and client render the same string. |
| `duration` | `number` | `0.6` | Seconds a digit takes to settle. |
| `bounce` | `number` | `0.1` | Spring bounce, 0 to 0.5. |
| `live` | `boolean` | `false` | Announce changes politely to screen readers. |
| `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
- Screen readers read the formatted value, not the reels.
- Set live to announce updates; leave it off for values that tick constantly.
## Motion
- Each reel runs on a spring, so rapid updates interrupt smoothly.
- Columns are keyed from the right; new leading digits slide in and old ones slide out.
## Reduced motion
The new value replaces the old one with no rolling.
## Performance
- One spring per digit. Reels re-center silently after each roll so the DOM stays fixed size.
## 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: shortest, up, down.
## Related
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
## Also in numbers
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
## Source
### components/textlab/rolling-number/rolling-number.tsx
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion, useSpring, useTransform } from "motion/react";
import { SrText } from "../lib/a11y";
import { formatNumber, isDigit } from "../lib/format";
import { spring as springs } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type RollingNumberVariant = "shortest" | "up" | "down";
export interface RollingNumberProps {
value: number;
/** shortest takes the nearer way round; up always rolls upward; down always rolls downward. */
variant?: RollingNumberVariant;
/** Intl.NumberFormat options, for example { style: "currency", currency: "EUR" }. */
format?: Intl.NumberFormatOptions;
/** Defaults to en-US so server and client agree. */
locale?: string;
/** Seconds a digit takes to settle. */
duration?: number;
/** Roll bounce, 0 to 0.5. */
bounce?: number;
/** Announce changes to screen readers. */
live?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
// Three cycles of digits let a column keep rolling the same way, then quietly re-center.
const CELLS = Array.from({ length: 30 }, (_, i) => i % 10);
function Digit({ digit, variant, duration, bounce }: { digit: number; variant: RollingNumberVariant; duration: number; bounce: number }) {
const pos = useRef(10 + digit);
const spring = useSpring(10 + digit, { visualDuration: duration, bounce });
const y = useTransform(spring, (v) => `${-v * 10 / 3}%`);
useEffect(() => {
const current = ((Math.round(pos.current) % 10) + 10) % 10;
if (current === digit) return;
const upSteps = (digit - current + 10) % 10;
const downSteps = upSteps - 10;
const step = variant === "up" ? upSteps : variant === "down" ? downSteps : Math.abs(downSteps) < upSteps ? downSteps : upSteps;
// Re-center into the middle cycle before rolling, so the column never runs out of digits.
const base = 10 + current;
if (Math.round(pos.current) !== base) spring.jump(base + (spring.get() - Math.round(pos.current)));
pos.current = base + step;
spring.set(pos.current);
}, [digit, variant, spring]);
return (
0
{CELLS.map((n, i) => (
{n}
))}
);
}
/**
* Each digit rolls to its new value on its own reel. Columns are keyed from the right, so the
* ones digit stays the ones digit when the number grows, and new leading digits slide in.
*/
export function RollingNumber({
value,
variant = "shortest",
format,
locale,
duration = 0.6,
bounce = springs.smooth.bounce,
live = false,
as: Tag = "span",
reducedMotion = "user",
className,
style,
}: RollingNumberProps) {
const reduced = useReduced(reducedMotion);
const text = formatNumber(value, locale, format);
const chars = Array.from(text);
if (reduced) {
return (
{text}
);
}
return (
{text}
{chars.map((char, i) => {
const key = chars.length - i;
return (
{isDigit(char) ? : char}
);
})}
);
}
export default RollingNumber;
```
Full library index: https://textlab.dev/llms.txt
---
# Odometer
> A mechanical wheel that spins past every digit.
- Type: Component (numbers)
- Page: https://textlab.dev/components/odometer
- Markdown: https://textlab.dev/components/odometer.md
- Registry item: https://textlab.dev/r/odometer.json
- Source file: `registry/textlab/odometer/odometer.tsx`
- Dependencies: motion
- Variants: mechanical, smooth
- Keywords: odometer, counter, mechanical, wheel, mileage, meter
## When to use
- Visitor counts, mileage and totals with a physical feel.
- Big jumps where you want people to sense the distance travelled.
## When not to use
- Currency with decimals; use rolling-number or price-ticker.
- Negative numbers.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/odometer
pnpm dlx shadcn@latest add @textlab/odometer
```
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/odometer.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 { Odometer } from "@/components/textlab/odometer/odometer";
export function Example() {
return ;
}
```
## API reference
### Odometer
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | A whole, non-negative number. |
| `variant` | `"mechanical" \| "smooth"` | `"mechanical"` | mechanical clicks a wheel over only when its neighbour passes 9; smooth spins every wheel continuously. |
| `digits` | `number` | `1` | Minimum number of wheels, padded with zeros. |
| `duration` | `number` | `1.6` | Seconds to travel to the new value. |
| `ease` | `EaseName \| [number, number, number, number]` | `"standard"` | Easing token or cubic bezier. |
| `framed` | `boolean` | `false` | Thin separators between wheels. |
| `locale` | `string` | `"en-US"` | Used for the screen-reader label. |
| `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
- Screen readers get the formatted number once; the wheels are aria-hidden.
## Motion
- One motion value drives every wheel, so they always agree.
- Edges are masked so wheels read as curved drums.
## Reduced motion
The new value appears immediately.
## Performance
- One animation per change; wheel positions are derived transforms.
## 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: mechanical, smooth.
## Related
- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
## Also in numbers
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
## Source
### components/textlab/odometer/odometer.tsx
```tsx
"use client";
import { useEffect, type CSSProperties, type ElementType } from "react";
import { animate, motion, useMotionValue, useTransform, type MotionValue } from "motion/react";
import { SrText } from "../lib/a11y";
import { formatNumber } from "../lib/format";
import { resolveEase, type EaseInput } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type OdometerVariant = "mechanical" | "smooth";
export interface OdometerProps {
value: number;
/** mechanical turns a wheel only while the one to its right passes 9; smooth spins every wheel continuously. */
variant?: OdometerVariant;
/** Minimum number of wheels, padded with zeros. */
digits?: number;
/** Seconds to travel from the old value to the new one. */
duration?: number;
ease?: EaseInput;
/** Show thin separators between wheels, like a real counter. */
framed?: boolean;
locale?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CELLS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
/** Position of wheel `place` (0 is ones) for a continuous value. */
function wheel(v: number, place: number, variant: OdometerVariant) {
const unit = 10 ** place;
if (variant === "smooth" || place === 0) return (((v / unit) % 10) + 10) % 10;
const whole = Math.floor(v / unit) % 10;
// The wheel only moves during the last unit before its neighbour rolls over, like gears.
const carry = Math.min(1, Math.max(0, (v % unit) - (unit - 1)));
return whole + carry;
}
function Wheel({ value, place, variant, framed }: { value: MotionValue; place: number; variant: OdometerVariant; framed: boolean }) {
const y = useTransform(value, (v) => `${(-wheel(v, place, variant) * 100) / CELLS.length}%`);
return (
0
{CELLS.map((n, i) => (
{n}
))}
);
}
/**
* A mechanical counter. The value travels through every number in between, so low wheels spin
* fast while high wheels click over only when they must.
*/
export function Odometer({
value,
variant = "mechanical",
digits = 1,
duration = 1.6,
ease = "standard",
framed = false,
locale,
as: Tag = "span",
reducedMotion = "user",
className,
style,
}: OdometerProps) {
const reduced = useReduced(reducedMotion);
const target = Math.max(0, Math.round(value));
const mv = useMotionValue(target);
useEffect(() => {
if (reduced) {
mv.set(target);
return;
}
const controls = animate(mv, target, { duration, ease: resolveEase(ease) });
return () => controls.stop();
}, [target, duration, ease, reduced, mv]);
const count = Math.max(digits, String(target).length);
const label = formatNumber(target, locale);
if (reduced) {
return (
{String(target).padStart(count, "0")}
);
}
return (
{label}
{Array.from({ length: count }, (_, i) => (
0} />
))}
);
}
export default Odometer;
```
Full library index: https://textlab.dev/llms.txt
---
# Split-flap board
> An airport board that flips to the next word.
- Type: Component (numbers)
- Page: https://textlab.dev/components/split-flap
- Markdown: https://textlab.dev/components/split-flap.md
- Registry item: https://textlab.dev/r/split-flap.json
- Source file: `registry/textlab/split-flap/split-flap.tsx`
- Dependencies: none
- Variants: dark, light, mini
- Keywords: split flap, solari, airport, departures, board, flip
## When to use
- Status boards, departures, scoreboards and countdown labels.
- Short uppercase words and codes.
## When not to use
- Lowercase prose; the default charset is uppercase.
- Long sentences; each tile flips through the alphabet.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/split-flap
pnpm dlx shadcn@latest add @textlab/split-flap
```
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/split-flap.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 { SplitFlap } from "@/components/textlab/split-flap/split-flap";
export function Example() {
return ;
}
```
## API reference
### SplitFlap
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"dark" \| "light" \| "mini"` | `"dark"` | Classic black board, cream tiles, or compact tiles that take the text color. |
| `length` | `number` | – | Pad or trim to this many tiles. Keep it fixed when the text changes. |
| `charset` | `string` | `" A–Z 0–9 :.-/"` | Characters each tile cycles through. Others jump straight in. |
| `speed` | `number` | `50` | Milliseconds per half flip. |
| `stagger` | `number` | `35` | Milliseconds between neighbouring tiles starting. |
| `live` | `boolean` | `false` | Announce changes politely to screen readers. |
| `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
- Screen readers read the text once, not the flipping letters.
- Tiles have a fixed width, so the line never shifts.
## Motion
- Each flip is two halves: the top leaf falls with ease-in, the bottom leaf lands with ease-out.
- Tiles start 35 ms apart from left to right.
## Reduced motion
Tiles show the final letters at once.
## Performance
- CSS keyframes only; no JavaScript animation loop.
- A tile does work only while it is flipping.
## 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: dark, light, mini.
## Related
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
## Also in numbers
- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
## Source
### components/textlab/split-flap/split-flap.tsx
```tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type SplitFlapVariant = "dark" | "light" | "mini";
export interface SplitFlapProps {
text: string;
/** dark is the classic black board, light is cream tiles, mini is compact and borderless. */
variant?: SplitFlapVariant;
/** Pad or trim to this many tiles. Defaults to the text length. */
length?: number;
/** Characters every tile cycles through, in order. Unknown characters jump straight in. */
charset?: string;
/** Milliseconds per flip. */
speed?: number;
/** Milliseconds between neighbouring tiles starting. */
stagger?: number;
/** Announce changes to screen readers. */
live?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const DEFAULT_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/";
const CSS = `
@keyframes tl-flap-top { from { transform: rotateX(0deg); } to { transform: rotateX(-90deg); } }
@keyframes tl-flap-bottom { from { transform: rotateX(90deg); } to { transform: rotateX(0deg); } }
.tl-flap { position: relative; display: inline-block; width: 0.8em; height: 1.18em; margin: 0 0.035em; perspective: 4em; border-radius: 0.12em; font-variant-numeric: tabular-nums; text-align: center; line-height: 1.18em; background: var(--tl-flap-bg); box-shadow: var(--tl-flap-shadow); }
.tl-flap-half { position: absolute; left: 0; right: 0; height: 50%; overflow: hidden; background: var(--tl-flap-bg); color: var(--tl-flap-fg); backface-visibility: hidden; }
.tl-flap-half > span { position: absolute; left: 0; right: 0; height: 1.18em; }
.tl-flap-top { top: 0; border-radius: 0.12em 0.12em 0 0; transform-origin: 50% 100%; }
.tl-flap-bottom { bottom: 0; border-radius: 0 0 0.12em 0.12em; transform-origin: 50% 0%; }
.tl-flap-bottom > span { bottom: 0; }
.tl-flap-top::after { content: ""; position: absolute; inset: auto 0 0 0; height: 1px; background: var(--tl-flap-seam); }
/* The top leaf falls under gravity, so it accelerates (ease-in); the bottom leaf lands and settles (ease-out). */
.tl-flap-leaf-top { z-index: 2; animation: tl-flap-top var(--tl-flap-ms) cubic-bezier(0.4, 0, 1, 1) forwards; }
.tl-flap-leaf-bottom { z-index: 2; transform: rotateX(90deg); animation: tl-flap-bottom var(--tl-flap-ms) cubic-bezier(0, 0, 0.2, 1) var(--tl-flap-ms) forwards; }
`;
const THEMES: Record = {
dark: { "--tl-flap-bg": "oklch(0.24 0.006 260)", "--tl-flap-fg": "oklch(0.97 0.01 90)", "--tl-flap-seam": "oklch(0 0 0 / 0.7)", "--tl-flap-shadow": "inset 0 0 0 1px oklch(1 0 0 / 0.07), 0 2px 6px oklch(0 0 0 / 0.35)" } as CSSProperties,
light: { "--tl-flap-bg": "oklch(0.96 0.018 90)", "--tl-flap-fg": "oklch(0.22 0.01 260)", "--tl-flap-seam": "oklch(0 0 0 / 0.16)", "--tl-flap-shadow": "inset 0 0 0 1px oklch(0 0 0 / 0.08), 0 1px 3px oklch(0 0 0 / 0.12)" } as CSSProperties,
mini: { "--tl-flap-bg": "color-mix(in oklab, currentColor 10%, var(--tl-background, Canvas))", "--tl-flap-fg": "currentColor", "--tl-flap-seam": "color-mix(in oklab, currentColor 18%, transparent)", "--tl-flap-shadow": "none" } as CSSProperties,
};
function Tile({ target, charset, speed, delay, reduced }: { target: string; charset: string; speed: number; delay: number; reduced: boolean }) {
const [current, setCurrent] = useState(target);
const [next, setNext] = useState(target);
const [step, setStep] = useState(0);
const delayedFor = useRef(target);
useEffect(() => {
if (reduced) return;
// Wait for the running flip to land before starting the next one.
if (current === target || next !== current) return;
const from = charset.indexOf(current);
const to = charset.indexOf(target);
const following = from === -1 || to === -1 ? target : charset[(from + 1) % charset.length];
// Neighbours start a beat apart, but only at the start of each new word.
const wait = delayedFor.current === target ? 0 : delay;
delayedFor.current = target;
const id = setTimeout(() => {
setNext(following);
setStep((s) => s + 1);
}, wait);
return () => clearTimeout(id);
}, [current, next, target, charset, delay, reduced]);
useEffect(() => {
if (next === current) return;
// The top leaf falls, then the bottom leaf lands; after both, the new char is current.
const id = setTimeout(() => setCurrent(next), speed * 2);
return () => clearTimeout(id);
}, [next, current, speed]);
if (reduced) {
return (
{target}{target}
);
}
const flipping = next !== current;
return (
{next}{current}
{flipping && (
<>
{current}{next}
>
)}
);
}
/**
* An airport departures board. Each tile flips through the alphabet until it reaches its letter,
* neighbours starting a beat apart, with a top leaf that falls and a bottom leaf that lands.
*/
export function SplitFlap({
text,
variant = "dark",
length,
charset = DEFAULT_CHARSET,
speed = 50,
stagger = 35,
live = false,
as: Tag = "span",
reducedMotion = "user",
className,
style,
}: SplitFlapProps) {
const reduced = useReduced(reducedMotion);
const upper = charset === DEFAULT_CHARSET ? text.toUpperCase() : text;
const chars = graphemes(upper);
const size = length ?? chars.length;
const tiles = Array.from({ length: size }, (_, i) => chars[i] ?? " ");
return (
{text}
{tiles.map((char, i) => (
))}
);
}
export default SplitFlap;
```
Full library index: https://textlab.dev/llms.txt
---
# Price ticker
> Prices tick with a flash of up or down.
- Type: Component (numbers)
- Page: https://textlab.dev/components/price-ticker
- Markdown: https://textlab.dev/components/price-ticker.md
- Registry item: https://textlab.dev/r/price-ticker.json
- Source file: `registry/textlab/price-ticker/price-ticker.tsx`
- Dependencies: motion
- Variants: currency, percent, compact
- Keywords: price, stock, ticker, currency, crypto, delta, up down
## When to use
- Live prices, portfolio values and conversion rates.
- Any number where the direction of change matters as much as the value.
## When not to use
- Static prices on a pricing page.
- Values that change more than a few times a second.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/price-ticker
pnpm dlx shadcn@latest add @textlab/price-ticker
```
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/price-ticker.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 { PriceTicker } from "@/components/textlab/price-ticker/price-ticker";
export function Example() {
return ;
}
```
## API reference
### PriceTicker
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | The price, or a ratio of 1 for percent. |
| `variant` | `"currency" \| "percent" \| "compact"` | `"currency"` | Money with cents, a percentage, or compact money like $1.2K. |
| `currency` | `string` | `"USD"` | ISO currency code. |
| `locale` | `string` | `"en-US"` | Formatting locale. |
| `showDelta` | `boolean` | `true` | Show the change next to the price. |
| `upColor` | `string` | `green` | Flash and delta color for rises. |
| `downColor` | `string` | `red` | Flash and delta color for falls. |
| `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 delta is announced politely, so screen readers hear the direction.
- Direction is shown by an arrow and a sign, not color alone.
## Motion
- Digits roll up on a rise and down on a fall.
- The flash holds briefly, then fades to the text color over 1.2 s.
## Reduced motion
The price and delta update in place with no roll or flash.
## Performance
- Built on rolling-number; one spring per digit.
## 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: currency, percent, compact.
## Related
- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
## Also in numbers
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
## Source
### components/textlab/price-ticker/price-ticker.tsx
```tsx
"use client";
import { useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { RollingNumber } from "../rolling-number/rolling-number";
import { formatNumber } from "../lib/format";
import { ease } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type PriceTickerVariant = "currency" | "percent" | "compact";
export interface PriceTickerProps {
value: number;
/** currency formats as money, percent as a percentage of 1, compact as 1.2K style. */
variant?: PriceTickerVariant;
currency?: string;
locale?: string;
/** Show the change since the last value next to the price. */
showDelta?: boolean;
/** Color for rises and falls. Defaults to green and red tuned for both themes. */
upColor?: string;
downColor?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const FORMATS: Record Intl.NumberFormatOptions> = {
currency: (currency) => ({ style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }),
percent: () => ({ style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2 }),
compact: (currency) => ({ style: "currency", currency, notation: "compact", maximumFractionDigits: 1 }),
};
/**
* A price that ticks to its new value with a brief flash of up or down. The flash fades on its own
* and the arrow turns to point the way the price moved.
*/
export function PriceTicker({
value,
variant = "currency",
currency = "USD",
locale,
showDelta = true,
upColor = "oklch(0.62 0.16 150)",
downColor = "oklch(0.6 0.2 25)",
as: Tag = "span",
reducedMotion = "user",
className,
style,
}: PriceTickerProps) {
const reduced = useReduced(reducedMotion);
const [previous, setPrevious] = useState(value);
const [change, setChange] = useState<{ delta: number; id: number } | null>(null);
// Derive the change while rendering, the way React recommends for state that follows a prop.
if (value !== previous) {
setPrevious(value);
setChange((c) => ({ delta: value - previous, id: (c?.id ?? 0) + 1 }));
}
const format = FORMATS[variant](currency);
const up = (change?.delta ?? 0) >= 0;
const tint = change ? (up ? upColor : downColor) : undefined;
const deltaText = change
? `${up ? "+" : "−"}${formatNumber(Math.abs(change.delta), locale, variant === "percent" ? { style: "percent", maximumFractionDigits: 2 } : { ...format, notation: "standard" })}`
: "";
return (
{showDelta && (
{change && (
{deltaText}
)}
)}
);
}
export default PriceTicker;
```
Full library index: https://textlab.dev/llms.txt
---
# Countdown
> Count down to a moment, second by second.
- Type: Component (numbers)
- Page: https://textlab.dev/components/countdown
- Markdown: https://textlab.dev/components/countdown.md
- Registry item: https://textlab.dev/r/countdown.json
- Source file: `registry/textlab/countdown/countdown.tsx`
- Dependencies: motion
- Variants: flip, roll, plain
- Keywords: countdown, timer, launch, clock, days hours minutes seconds
## When to use
- Launches, sales and events with a fixed start time.
- Timers people glance at rather than read continuously.
## When not to use
- Elapsed-time stopwatches; this only counts down.
- Precise timing below one second.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/countdown
pnpm dlx shadcn@latest add @textlab/countdown
```
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/countdown.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 { Countdown } from "@/components/textlab/countdown/countdown";
export function Example() {
return ;
}
```
## API reference
### Countdown
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `to` (required) | `Date \| string \| number` | – | The moment to count down to. |
| `variant` | `"flip" \| "roll" \| "plain"` | `"flip"` | Split-flap tiles, rolling digits, or a soft crossfade. |
| `units` | `("days" \| "hours" \| "minutes" \| "seconds")[]` | `all four` | Units to show, largest first. |
| `labels` | `Partial> \| null` | `English labels` | Labels under each unit. null hides them. |
| `onComplete` | `() => void` | – | Called when a one-shot animation finishes. |
| `as` | `ElementType` | `"div"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
## Accessibility
- A visually hidden sentence reads the remaining time in words.
- It is not a live region, so it never interrupts every second.
## Motion
- Ticks align to the wall clock, so every second changes on the beat.
- Seconds use the same flip or roll as the larger units.
## Reduced motion
Digits change in place with no flip, roll or fade.
## Responsive behavior
- Scales with font size; set a fluid size on the root.
## Performance
- One timer per countdown, scheduled for the next whole second.
- Only units that change animate.
## 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: flip, roll, plain.
## Related
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Stat reveal](https://textlab.dev/components/stat-reveal.md): A stat counts up when it scrolls in.
## Also in numbers
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
## Source
### components/textlab/countdown/countdown.tsx
```tsx
"use client";
import { useEffect, useEffectEvent, useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { RollingNumber } from "../rolling-number/rolling-number";
import { SplitFlap } from "../split-flap/split-flap";
import { srOnly } from "../lib/a11y";
import { ease } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type CountdownVariant = "flip" | "roll" | "plain";
export type CountdownUnit = "days" | "hours" | "minutes" | "seconds";
export interface CountdownProps {
/** The moment to count down to. */
to: Date | string | number;
/** flip uses split-flap tiles, roll uses rolling digits, plain crossfades. */
variant?: CountdownVariant;
/** Units to show, largest first. */
units?: CountdownUnit[];
/** Labels under each unit. Pass null to hide them. */
labels?: Partial> | null;
onComplete?: () => void;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const SIZE: Record = { days: 86400, hours: 3600, minutes: 60, seconds: 1 };
const DEFAULT_LABELS: Record = { days: "Days", hours: "Hours", minutes: "Minutes", seconds: "Seconds" };
function split(ms: number, units: CountdownUnit[]) {
let rest = Math.max(0, Math.floor(ms / 1000));
const out = {} as Record;
for (const unit of units) {
out[unit] = Math.floor(rest / SIZE[unit]);
rest -= out[unit] * SIZE[unit];
}
return out;
}
function PlainDigits({ value }: { value: string }) {
return (
{value.replace(/\d/g, "0")}
{value}
);
}
/**
* Count down to a moment, second by second. The time is read only in the browser, so the server
* renders placeholders and the first client render matches it exactly.
*/
export function Countdown({
to,
variant = "flip",
units = ["days", "hours", "minutes", "seconds"],
labels = DEFAULT_LABELS,
onComplete,
as: Tag = "div",
reducedMotion = "user",
className,
style,
}: CountdownProps) {
const reduced = useReduced(reducedMotion);
const target = new Date(to).getTime();
const [now, setNow] = useState(null);
const finish = useEffectEvent(() => onComplete?.());
useEffect(() => {
let id = 0;
const tick = () => {
const t = Date.now();
setNow(t);
if (t >= target) {
finish();
return;
}
// Align ticks to the wall clock so seconds change together everywhere on the page.
id = window.setTimeout(tick, 1000 - (t % 1000) + 5);
};
tick();
return () => clearTimeout(id);
}, [target]);
const parts = now === null ? null : split(target - now, units);
const pad = (unit: CountdownUnit, n: number) => String(n).padStart(unit === "days" ? 1 : 2, "0");
const spoken = parts ? units.map((u) => `${parts[u]} ${(labels?.[u] ?? DEFAULT_LABELS[u]).toLowerCase()}`).join(", ") : "";
return (
{spoken}
{units.map((unit, i) => {
const value = parts ? pad(unit, parts[unit]) : unit === "days" ? "–" : "––";
return (
{i > 0 && :}
{!parts || reduced ? (
{value}
) : variant === "flip" ? (
) : variant === "roll" ? (
) : (
)}
{labels && {labels[unit] ?? DEFAULT_LABELS[unit]}}
);
})}
);
}
export default Countdown;
```
Full library index: https://textlab.dev/llms.txt
---
# Stat reveal
> A stat counts up when it scrolls in.
- Type: Component (numbers)
- Page: https://textlab.dev/components/stat-reveal
- Markdown: https://textlab.dev/components/stat-reveal.md
- Registry item: https://textlab.dev/r/stat-reveal.json
- Source file: `registry/textlab/stat-reveal/stat-reveal.tsx`
- Dependencies: motion
- Variants: count, spring, scramble
- Keywords: stat, count up, counter, metric, kpi, number reveal
## When to use
- Stats bands and impact numbers on marketing pages.
- Any number shown once that deserves a moment of arrival.
## When not to use
- Numbers that keep changing; use rolling-number.
- Several dozen stats on one screen.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/stat-reveal
pnpm dlx shadcn@latest add @textlab/stat-reveal
```
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/stat-reveal.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 { StatReveal } from "@/components/textlab/stat-reveal/stat-reveal";
export function Example() {
return ;
}
```
## API reference
### StatReveal
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` (required) | `number` | – | The final number. |
| `variant` | `"count" \| "spring" \| "scramble"` | `"count"` | Ease up, overshoot and settle, or shuffle digits then lock in. |
| `from` | `number` | `0` | Where the count starts. |
| `prefix` | `string` | – | Text before the number. |
| `suffix` | `string` | – | Text after the number. |
| `format` | `Intl.NumberFormatOptions` | – | Number formatting. |
| `locale` | `string` | `"en-US"` | Formatting locale. |
| `trigger` | `"mount" \| "inView" \| "manual"` | `"inView"` | What starts the animation. manual follows `active`. |
| `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. |
| `ease` | `EaseName \| [number, number, number, number]` | `"emphasized"` | Easing token or cubic bezier. |
| `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. |
| `onComplete` | `() => void` | – | Called when a one-shot animation finishes. |
## Accessibility
- Screen readers read the final value, never the in-between numbers.
- The final width is reserved, so nothing shifts while counting.
## Motion
- Counts with the emphasized ease: fast early, slow landing.
- Starts when 45% of the stat is visible.
## Reduced motion
Shows the final value immediately.
## Performance
- One motion value; text updates outside React renders for count and spring.
## 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: count, spring, scramble.
## Related
- [Rolling number](https://textlab.dev/components/rolling-number.md): Each digit rolls to its new value.
- [Odometer](https://textlab.dev/components/odometer.md): A mechanical wheel that spins past every digit.
- [Price ticker](https://textlab.dev/components/price-ticker.md): Prices tick with a flash of up or down.
## Also in numbers
- [Split-flap board](https://textlab.dev/components/split-flap.md): An airport board that flips to the next word.
- [Countdown](https://textlab.dev/components/countdown.md): Count down to a moment, second by second.
## Source
### components/textlab/stat-reveal/stat-reveal.tsx
```tsx
"use client";
import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { SrText } from "../lib/a11y";
import { formatNumber } from "../lib/format";
import { resolveEase, type EaseInput } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type StatRevealVariant = "count" | "spring" | "scramble";
export interface StatRevealProps {
value: number;
/** count eases up, spring overshoots a touch and settles, scramble shuffles digits then locks in. */
variant?: StatRevealVariant;
/** Number to count from. */
from?: number;
/** Text before and after the number, for example "$" and "+". */
prefix?: string;
suffix?: string;
format?: Intl.NumberFormatOptions;
locale?: string;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
duration?: number;
ease?: EaseInput;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
onComplete?: () => void;
}
/**
* A headline stat that counts up when it scrolls into view. The final value reserves its width
* from the first frame, so the line never grows as the digits climb.
*/
export function StatReveal({
value,
variant = "count",
from = 0,
prefix = "",
suffix = "",
format,
locale,
trigger = "inView",
active,
once = true,
delay = 0,
duration = 1.8,
ease = "emphasized",
as: Tag = "span",
reducedMotion = "user",
className,
style,
onComplete,
}: StatRevealProps) {
const { ref, on } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const mv = useMotionValue(from);
const decimals = format?.maximumFractionDigits ?? (Number.isInteger(value) ? 0 : 1);
const display = useTransform(mv, (v) => formatNumber(Number(v.toFixed(decimals)), locale, format));
const final = formatNumber(value, locale, format);
const [scrambled, setScrambled] = useState(null);
useEffect(() => {
if (reduced) return;
if (!on) {
mv.set(from);
return;
}
if (variant === "scramble") {
// Shuffle every digit, then lock them in left to right.
const start = performance.now() + delay * 1000;
let raf = 0;
const frame = (t: number) => {
const p = Math.min(1, Math.max(0, (t - start) / (duration * 1000)));
const locked = Math.floor(p * final.length);
setScrambled(
Array.from(final, (ch, i) => (i < locked || !/\d/.test(ch) ? ch : String(Math.floor(Math.random() * 10)))).join(""),
);
if (p < 1) raf = requestAnimationFrame(frame);
else {
setScrambled(null);
onComplete?.();
}
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}
const controls = animate(mv, value, variant === "spring" ? { type: "spring", visualDuration: duration * 0.6, bounce: 0.25, delay } : { duration, delay, ease: resolveEase(ease) });
controls.then(() => onComplete?.());
return () => controls.stop();
// onComplete is a callback; re-running on its identity would restart the count.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, value, from, variant, duration, delay, ease, reduced, final, mv]);
if (reduced) {
return (
{prefix}
{final}
{suffix}
);
}
return (
{`${prefix}${final}${suffix}`}
{prefix}
{final}
{suffix}
{prefix}
{variant === "scramble" ? {scrambled ?? (on ? final : final.replace(/\d/g, "0"))} : {display}}
{suffix}
);
}
export default StatReveal;
```
Full library index: https://textlab.dev/llms.txt
---
# Typewriter
> Types, deletes and retypes with a caret.
- Type: Component (typing)
- Page: https://textlab.dev/components/typewriter
- Markdown: https://textlab.dev/components/typewriter.md
- Registry item: https://textlab.dev/r/typewriter.json
- Source file: `registry/textlab/typewriter/typewriter.tsx`
- Dependencies: none
- Variants: bar, block, underscore
- Keywords: typewriter, typing, caret, cursor, type and delete, terminal
## When to use
- Rotating endings on hero lines.
- Terminal and chat-style interfaces.
## When not to use
- Critical information that must be readable at once.
- More than a handful of phrases.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/typewriter
pnpm dlx shadcn@latest add @textlab/typewriter
```
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/typewriter.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 { Typewriter } from "@/components/textlab/typewriter/typewriter";
export function Example() {
return ;
}
```
## API reference
### Typewriter
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` (required) | `string[]` | – | Phrases to type in turn. |
| `variant` | `"bar" \| "block" \| "underscore"` | `"bar"` | Caret shape. |
| `typeSpeed` | `number` | `70` | Milliseconds per typed letter, before jitter. |
| `deleteSpeed` | `number` | `38` | Milliseconds per deleted letter. |
| `pause` | `number` | `1600` | Milliseconds a finished phrase is held. |
| `loop` | `boolean` | `true` | Keep cycling through the phrases. |
| `humanize` | `boolean` | `true` | Small, repeatable variations in key timing. |
| `reserve` | `boolean` | `false` | Reserve the longest phrase's width so nothing around it moves. |
| `label` | `string` | – | What screen readers hear. Defaults to every phrase. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `trigger` | `"mount" \| "inView" \| "manual"` | `"inView"` | What starts the animation. manual follows `active`. |
| `delay` | `number` | `0` | Seconds before the first part moves. |
| `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
- Screen readers hear every phrase once from a hidden label, not each keystroke.
- Use reserve in centered layouts so surrounding text does not shift.
## Motion
- The caret is solid while typing or deleting and blinks only at rest.
- Spaces take a little longer, like a real typist.
## Reduced motion
Shows a phrase in full with no typing and no caret.
## Performance
- One timer at a time; no animation frames.
- The caret blink is a CSS keyframe.
## 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: bar, block, underscore.
## Related
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
## Also in typing
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
## Source
### components/textlab/typewriter/typewriter.tsx
```tsx
"use client";
import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { hashString, seeded } from "../lib/math";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type TypewriterVariant = "bar" | "block" | "underscore";
export interface TypewriterProps {
/** Phrases to type in turn. One phrase types once and stays. */
words: string[];
/** Caret shape. */
variant?: TypewriterVariant;
/** Milliseconds per typed letter, before jitter. */
typeSpeed?: number;
/** Milliseconds per deleted letter. */
deleteSpeed?: number;
/** Milliseconds to hold a finished phrase before deleting. */
pause?: number;
/** Keep cycling through the phrases. */
loop?: boolean;
/** Natural rhythm: small, deterministic variations in key timing. */
humanize?: boolean;
/** Reserve the width of the longest phrase so nothing around it moves. */
reserve?: boolean;
/** What screen readers hear. Defaults to every phrase in order. */
label?: string;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
delay?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
onComplete?: () => void;
}
const CSS = `
@keyframes tl-caret-blink { 0%, 45% { opacity: 1; } 55%, 100% { opacity: 0; } }
.tl-caret { display: inline-block; vertical-align: baseline; background: currentColor; animation: tl-caret-blink 1.05s steps(1) infinite; }
.tl-caret[data-busy] { animation: none; opacity: 1; }
.tl-caret[data-variant="bar"] { width: 0.07em; height: 1em; margin-left: 0.04em; transform: translateY(0.12em); border-radius: 1px; }
.tl-caret[data-variant="block"] { width: 0.55em; height: 1.05em; margin-left: 0.04em; transform: translateY(0.16em); opacity: 0.85; }
.tl-caret[data-variant="underscore"] { width: 0.55em; height: 0.08em; margin-left: 0.04em; }
@media (prefers-reduced-motion: reduce) { .tl-caret { animation: none; } }
`;
/**
* Types, deletes and retypes with a caret. The caret holds solid while keys are moving and only
* blinks at rest, the way real cursors behave.
*/
export function Typewriter({
words,
variant = "bar",
typeSpeed = 70,
deleteSpeed = 38,
pause = 1600,
loop = true,
humanize = true,
reserve = false,
label,
as: Tag = "span",
trigger = "inView",
active,
delay = 0,
reducedMotion = "user",
className,
style,
onComplete,
}: TypewriterProps) {
const { ref, on } = useTrigger({ trigger, active, once: true });
const reduced = useReduced(reducedMotion);
const [state, setState] = useState({ word: 0, length: 0, deleting: false });
const phrases = words.length ? words : [""];
const current = graphemes(phrases[state.word % phrases.length]);
useEffect(() => {
if (!on || reduced) return;
const { word, length, deleting } = state;
const total = current.length;
const last = word === phrases.length - 1;
const rand = seeded(hashString(`${word}-${length}-${deleting}`));
const jitter = humanize ? 0.6 + rand() * 0.9 : 1;
let wait: number;
let next = state;
if (!deleting && length < total) {
wait = (length === 0 && word === 0 ? delay * 1000 : 0) + typeSpeed * jitter + (current[length - 1] === " " ? typeSpeed * 0.8 : 0);
next = { word, length: length + 1, deleting: false };
} else if (!deleting) {
if (last && !loop) {
onComplete?.();
return;
}
if (phrases.length === 1 && !loop) return;
wait = pause;
next = { word, length, deleting: true };
} else if (length > 0) {
wait = deleteSpeed * (humanize ? 0.8 + rand() * 0.4 : 1);
next = { word, length: length - 1, deleting: true };
} else {
wait = typeSpeed * 4;
next = { word: (word + 1) % phrases.length, length: 0, deleting: false };
}
const id = setTimeout(() => setState(next), wait);
return () => clearTimeout(id);
// onComplete is a callback; its identity should not restart typing.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, reduced, state, current.length, phrases.length, typeSpeed, deleteSpeed, pause, loop, humanize, delay]);
const spoken = label ?? phrases.join(", ");
const longest = phrases.reduce((a, b) => (graphemes(b).length > graphemes(a).length ? b : a), "");
if (reduced) {
return (
{spoken}{phrases[phrases.length === 1 || !loop ? phrases.length - 1 : 0]}
);
}
const typed = current.slice(0, state.length).join("");
const busy = on && (state.deleting ? state.length > 0 : state.length < current.length && state.length > 0);
return (
{spoken}
{reserve && (
{longest}
)}
{typed}
);
}
export default Typewriter;
```
Full library index: https://textlab.dev/llms.txt
---
# Scramble text
> Letters decode into place.
- Type: Component (typing)
- Page: https://textlab.dev/components/scramble-text
- Markdown: https://textlab.dev/components/scramble-text.md
- Registry item: https://textlab.dev/r/scramble-text.json
- Source file: `registry/textlab/scramble-text/scramble-text.tsx`
- Dependencies: none
- Variants: random, symbols, binary
- Keywords: scramble, decrypt, decode, hacker, shuffle, cipher
## When to use
- Tech, security and launch moments.
- Short labels that should feel computed.
## When not to use
- Body copy.
- Anything that must be read during the first second.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/scramble-text
pnpm dlx shadcn@latest add @textlab/scramble-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/scramble-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 { ScrambleText } from "@/components/textlab/scramble-text/scramble-text";
export function Example() {
return ;
}
```
## API reference
### ScrambleText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"random" \| "symbols" \| "binary"` | `"random"` | Which glyphs flicker before each letter settles. |
| `charset` | `string` | – | Custom glyphs; overrides the variant. |
| `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.2` | Seconds each part takes. |
| `tick` | `number` | `45` | Milliseconds between glyph changes. |
| `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
- Screen readers only ever hear the real text.
- The real text reserves layout, so lines never jump.
## Motion
- Settles left to right with a short lead, so the end of the word stays alive longest.
- Whitespace never scrambles, keeping word shapes.
## Reduced motion
Shows the real text, no glyphs.
## Performance
- One rAF loop while running; idle after it settles.
- Glyphs change every 45 ms, not every frame.
## 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: random, symbols, binary.
## Related
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
## Also in typing
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
## Source
### components/textlab/scramble-text/scramble-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { SrText } from "../lib/a11y";
import { charsets, useScramble } from "../lib/scramble";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type ScrambleTextVariant = "random" | "symbols" | "binary";
export interface ScrambleTextProps {
text: string;
/** Which glyphs flicker before each letter settles. */
variant?: ScrambleTextVariant;
/** Custom glyphs. Overrides the variant's set. */
charset?: string;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
/** Seconds from noise to settled. */
duration?: number;
/** Milliseconds between glyph changes. */
tick?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
onComplete?: () => void;
}
/**
* Letters decode into place, left to right. The real text holds the layout underneath, so the
* scrambling glyphs can be any width without moving anything around them.
*/
export function ScrambleText({
text,
variant = "random",
charset,
as: Tag = "span",
trigger = "inView",
active,
once = true,
delay = 0,
duration = 1.2,
tick = 45,
reducedMotion = "user",
className,
style,
onComplete,
}: ScrambleTextProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const frame = useScramble(text, { active: on && !reduced, duration, delay, charset: charset ?? charsets[variant], tick, mode: "sweep", idle: "scrambled", onDone: onComplete });
if (reduced) {
return (
{text}
);
}
return (
{text}
{text}
{frame}
);
}
export default ScrambleText;
```
Full library index: https://textlab.dev/llms.txt
---
# Hover scramble
> Scrambles on hover, settles on leave.
- Type: Component (typing)
- Page: https://textlab.dev/components/hover-scramble
- Markdown: https://textlab.dev/components/hover-scramble.md
- Registry item: https://textlab.dev/r/hover-scramble.json
- Source file: `registry/textlab/hover-scramble/hover-scramble.tsx`
- Dependencies: none
- Variants: sweep, all
- Keywords: hover, scramble, nav link, button, glitch hover
## When to use
- Navigation links, footers and small buttons on technical brands.
- Any short label where hover feedback should feel playful.
## When not to use
- Primary calls to action; the label is briefly unreadable.
- Touch-first interfaces, where hover never happens.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/hover-scramble
pnpm dlx shadcn@latest add @textlab/hover-scramble
```
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/hover-scramble.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 { HoverScramble } from "@/components/textlab/hover-scramble/hover-scramble";
export function Example() {
return ;
}
```
## API reference
### HoverScramble
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"sweep" \| "all"` | `"sweep"` | sweep re-settles left to right; all snaps back together at the end. |
| `charset` | `string` | `symbols` | Glyphs used while scrambled. |
| `duration` | `number` | `0.5` | Seconds each part takes. |
| `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
- Keyboard focus triggers it too, through focus capture on the wrapper.
- Screen readers hear the label, never the glyphs.
## Motion
- Half a second, under the 300 ms-per-step budget for UI feedback.
- Leaving mid-scramble settles straight back to the word.
## Reduced motion
Hover does nothing; the label stays still.
## Performance
- Runs only while hovered.
- No layout work; the real label holds the size.
## 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: sweep, all.
## Related
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
## Also in typing
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
## Source
### components/textlab/hover-scramble/hover-scramble.tsx
```tsx
"use client";
import type { CSSProperties, ElementType, ReactNode } from "react";
import { SrText } from "../lib/a11y";
import { charsets, useScramble } from "../lib/scramble";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type HoverScrambleVariant = "sweep" | "all";
export interface HoverScrambleProps {
text: string;
/** sweep re-settles left to right; all stays scrambled, then snaps back together. */
variant?: HoverScrambleVariant;
charset?: string;
/** Seconds the scramble lasts. */
duration?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
/** Optional element to wrap, for example an icon after the label. */
children?: ReactNode;
}
/**
* Scrambles on hover or keyboard focus and settles back into the word. Made for nav links and
* buttons: put it inside a focusable element and it follows that element's focus.
*/
export function HoverScramble({ text, variant = "sweep", charset = charsets.symbols, duration = 0.5, as: Tag = "span", reducedMotion = "user", className, style, children }: HoverScrambleProps) {
const { ref, on, bind } = useTrigger({ trigger: "hover" });
const reduced = useReduced(reducedMotion);
const frame = useScramble(text, { active: on && !reduced, duration, charset, mode: variant, idle: "text", tick: 40 });
return (
{text}
{text}
{frame}
{children}
);
}
export default HoverScramble;
```
Full library index: https://textlab.dev/llms.txt
---
# Matrix decode
> Glyph rain resolves into the word.
- Type: Component (typing)
- Page: https://textlab.dev/components/matrix-decode
- Markdown: https://textlab.dev/components/matrix-decode.md
- Registry item: https://textlab.dev/r/matrix-decode.json
- Source file: `registry/textlab/matrix-decode/matrix-decode.tsx`
- Dependencies: motion
- Variants: rain, flicker
- Keywords: matrix, code rain, decode, hacker, katakana, terminal
## When to use
- Developer tools, terminals and security products.
- Short titles on dark backgrounds.
## When not to use
- Light, calm brands; the glow reads loud.
- Paragraphs.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/matrix-decode
pnpm dlx shadcn@latest add @textlab/matrix-decode
```
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/matrix-decode.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 { MatrixDecode } from "@/components/textlab/matrix-decode/matrix-decode";
export function Example() {
return ;
}
```
## API reference
### MatrixDecode
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"rain" \| "flicker"` | `"rain"` | rain drops a column of glyphs onto each letter; flicker cycles glyphs in place. |
| `glow` | `string` | `phosphor green` | Color letters flash as they lock in. |
| `charset` | `string` | `half-width katakana and digits` | Glyphs used in the rain. |
| `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.6` | 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. |
## Accessibility
- The rain is decorative and aria-hidden; the title is read once.
- Glyph positions are seeded, so server and client render the same frame.
## Motion
- Each letter catches its column just as it lands, then cools from the glow color over 0.9 s.
- Order is loosely left to right, with seeded randomness.
## Reduced motion
The title appears in place with no rain or glow.
## Performance
- One short motion per letter; nothing runs after it resolves.
## 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: rain, flicker.
## Related
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
## Also in typing
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
## Source
### components/textlab/matrix-decode/matrix-decode.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { motion } from "motion/react";
import { SrText } from "../lib/a11y";
import { hashString, seeded } from "../lib/math";
import { charsets } from "../lib/scramble";
import { splitWords } from "../lib/split";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type MatrixDecodeVariant = "rain" | "flicker";
export interface MatrixDecodeProps {
text: string;
/** rain drops a column of glyphs onto each letter; flicker cycles glyphs in place. */
variant?: MatrixDecodeVariant;
/** Glow color as letters resolve. */
glow?: string;
charset?: string;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
/** Seconds the whole word takes to resolve. */
duration?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Glyph rain resolves into the word. Each letter catches a short falling column of characters,
* flashes bright as it locks, then cools to the text color.
*/
export function MatrixDecode({
text,
variant = "rain",
glow = "oklch(0.86 0.2 150)",
charset = charsets.katakana,
as: Tag = "span",
trigger = "inView",
active,
once = true,
delay = 0,
duration = 1.6,
reducedMotion = "user",
className,
style,
}: MatrixDecodeProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const hydrated = useHydrated();
const reduced = useReduced(reducedMotion);
const words = splitWords(text);
const rand = seeded(hashString(text));
const total = words.reduce((n, w) => n + w.chars.length, 0);
if (reduced) {
return (
{text}
);
}
const glyph = () => charset[Math.floor(rand() * charset.length)];
return (
{text}
{words.map((word, w) => (
{word.chars.map((char, c) => {
const i = word.charOffset + c;
// Letters resolve in a loose left-to-right order with a little randomness.
const at = delay + (i / Math.max(total, 1)) * duration * 0.7 + rand() * duration * 0.25;
const column = Array.from({ length: 6 }, glyph);
const flicker = Array.from({ length: 4 }, glyph);
return (
{variant === "rain" ? (
{column.map((g, k) => (
{g}
))}
) : (
{flicker.map((g, k) => (
{g}
))}
)}
{char}
{/* A glowing copy flashes on top as the letter locks, then cools away. */}
{char}
);
})}
{w < words.length - 1 ? " " : null}
))}
);
}
export default MatrixDecode;
```
Full library index: https://textlab.dev/llms.txt
---
# Text morph
> One word turns into the next, letter by letter.
- Type: Component (typing)
- Page: https://textlab.dev/components/text-morph
- Markdown: https://textlab.dev/components/text-morph.md
- Registry item: https://textlab.dev/r/text-morph.json
- Source file: `registry/textlab/text-morph/text-morph.tsx`
- Dependencies: motion
- Variants: slide, blur, flip
- Keywords: morph, label, flip, layout animation, button state, text transition
## When to use
- Button labels that change state: Save to Saved, Follow to Following.
- Toggles and short status words.
## When not to use
- Long sentences; most letters change, so the morph reads as noise.
- Labels that change many times a second.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/text-morph
pnpm dlx shadcn@latest add @textlab/text-morph
```
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/text-morph.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 { TextMorph } from "@/components/textlab/text-morph/text-morph";
export function Example() {
return ;
}
```
## API reference
### TextMorph
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"slide" \| "blur" \| "flip"` | `"slide"` | How new letters arrive and old ones leave. Shared letters always glide. |
| `live` | `boolean` | `true` | Announce changes politely to screen readers. |
| `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 new label is announced politely by default.
- Letters are aria-hidden; screen readers never hear them one by one.
## Motion
- Shared letters glide on a smooth spring and can be interrupted mid-morph.
- Exits are quick (150 ms) so the new word never waits.
## Reduced motion
The label swaps instantly.
## Performance
- Layout animation per letter; keep labels short.
## 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: slide, blur, flip.
## Related
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
## Also in typing
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
## Source
### components/textlab/text-morph/text-morph.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { AnimatePresence, motion, type TargetAndTransition } from "motion/react";
import { SrText } from "../lib/a11y";
import { spring } from "../lib/motion-tokens";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type TextMorphVariant = "slide" | "blur" | "flip";
export interface TextMorphProps {
/** The current label. Change it and the letters morph. */
text: string;
/** How new letters arrive and old ones leave. Shared letters always glide. */
variant?: TextMorphVariant;
/** Announce changes to screen readers. */
live?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const ENTER: Record = {
slide: { from: { opacity: 0, y: "0.35em" }, exit: { opacity: 0, y: "-0.35em" } },
blur: { from: { opacity: 0, filter: "blur(6px)", scale: 0.9 }, exit: { opacity: 0, filter: "blur(6px)", scale: 0.9 } },
flip: { from: { opacity: 0, rotateX: -90 }, exit: { opacity: 0, rotateX: 90 } },
};
/** Key each letter by itself and its occurrence, so the second "o" in "loop" stays the second "o". */
function keyed(text: string) {
const seen = new Map();
return graphemes(text).map((char) => {
const n = seen.get(char) ?? 0;
seen.set(char, n + 1);
return { char, key: `${char}-${n}` };
});
}
/**
* One word turns into the next, letter by letter. Letters both words share glide to their new
* place (FLIP via layout animation); the rest leave and arrive in place.
*/
export function TextMorph({ text, variant = "slide", live = true, as: Tag = "span", reducedMotion = "user", className, style }: TextMorphProps) {
const reduced = useReduced(reducedMotion);
if (reduced) {
return (
{text}
);
}
const { from, exit } = ENTER[variant];
return (
{text}
{keyed(text).map(({ char, key }) => (
{char === " " ? " " : char}
))}
);
}
export default TextMorph;
```
Full library index: https://textlab.dev/llms.txt
---
# Word rotator
> Cycles words while the width follows.
- Type: Component (typing)
- Page: https://textlab.dev/components/word-rotator
- Markdown: https://textlab.dev/components/word-rotator.md
- Registry item: https://textlab.dev/r/word-rotator.json
- Source file: `registry/textlab/word-rotator/word-rotator.tsx`
- Dependencies: motion
- Variants: slide, blur, flip
- Keywords: rotating words, text rotate, cycle, hero, headline
## When to use
- Hero lines with a rotating noun.
- Short lists where each item fits the same sentence.
## When not to use
- Words that differ wildly in length on mobile; the sentence reflows each time.
- Essential information hidden in later words.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/word-rotator
pnpm dlx shadcn@latest add @textlab/word-rotator
```
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/word-rotator.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 { WordRotator } from "@/components/textlab/word-rotator/word-rotator";
export function Example() {
return
Built for
;
}
```
## API reference
### WordRotator
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` (required) | `string[]` | – | Words to cycle. |
| `variant` | `"slide" \| "blur" \| "flip"` | `"slide"` | How each word arrives. |
| `interval` | `number` | `2.4` | Seconds each word stays. |
| `pauseOnHover` | `boolean` | `true` | Pause while hovered or focused. |
| `label` | `string` | – | What screen readers hear. Defaults to every word. |
| `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
- Screen readers hear all words once instead of a moving target.
- Hover and focus pause the rotation.
## Motion
- The container's width animates on the same spring as the words, so neighbours glide.
- Old words leave as new ones arrive, overlapping instead of waiting.
## Reduced motion
Shows the first word, still.
## Performance
- One interval; layout animation on a single wrapper.
## 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: slide, blur, flip.
## Related
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
## Also in typing
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
## Source
### components/textlab/word-rotator/word-rotator.tsx
```tsx
"use client";
import { useEffect, useRef, useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion, type TargetAndTransition } from "motion/react";
import { srOnly } from "../lib/a11y";
import { ease } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type WordRotatorVariant = "slide" | "blur" | "flip";
export interface WordRotatorProps {
words: string[];
/** How each word arrives. */
variant?: WordRotatorVariant;
/** Seconds each word stays. */
interval?: number;
/** Pause while hovered or focused. */
pauseOnHover?: boolean;
/** What screen readers hear. Defaults to every word in order. */
label?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
// Full transform strings, so entering and leaving words can run on the compositor.
// Full transform strings, so entering and leaving words can run on the compositor. A light blur
// bridges the moment two words overlap, so the swap reads as one movement.
const MOTION: Record = {
slide: {
initial: { transform: "translateY(85%)", opacity: 0, filter: "blur(3px)" },
rest: { transform: "translateY(0%)", opacity: 1, filter: "blur(0px)" },
exit: { transform: "translateY(-85%)", opacity: 0, filter: "blur(3px)" },
},
blur: { initial: { opacity: 0, filter: "blur(8px)" }, rest: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: "blur(8px)" } },
flip: {
initial: { transform: "rotateX(-80deg)", opacity: 0 },
rest: { transform: "rotateX(0deg)", opacity: 1 },
exit: { transform: "rotateX(80deg)", opacity: 0 },
},
};
/**
* Cycles words in a sentence while the width follows, so the words around it slide to make room
* instead of jumping. Every word is measured once, so the width eases to an exact target instead of
* being scaled, which keeps the letters crisp while it moves.
*/
export function WordRotator({ words, variant = "slide", interval = 2.4, pauseOnHover = true, label, as: Tag = "span", reducedMotion = "user", className, style }: WordRotatorProps) {
const reduced = useReduced(reducedMotion);
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const sizers = useRef(null);
const [widths, setWidths] = useState([]);
useEffect(() => {
if (reduced || paused || words.length < 2) return;
const id = setInterval(() => setIndex((i) => (i + 1) % words.length), interval * 1000);
return () => clearInterval(id);
}, [reduced, paused, words.length, interval]);
useEffect(() => {
const node = sizers.current;
if (!node) return;
const measure = () => setWidths(Array.from(node.children, (child) => (child as HTMLElement).getBoundingClientRect().width));
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
document.fonts?.ready.then(measure);
return () => observer.disconnect();
}, [words, reduced]);
const spoken = label ?? words.join(", ");
if (reduced) {
return (
{spoken}{words[0]}
);
}
const m = MOTION[variant];
const width = widths[index];
const hover = pauseOnHover ? { onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false) } : {};
// Room above and below the clip so descenders (g, y, p) and accents are never cut; negative margins keep the line height unchanged.
// Each word also carries a little side padding, so italic overhangs stay inside the clip.
const clipRoom: CSSProperties = { paddingTop: "0.12em", paddingBottom: "0.28em", marginTop: "-0.12em", marginBottom: "-0.28em" };
return (
{spoken}
{words.map((word, i) => (
{word}
))}
{words[index]}
);
}
export default WordRotator;
```
Full library index: https://textlab.dev/llms.txt
---
# Caret morph
> A caret that glides between words and reshapes to fit.
- Type: Component (typing)
- Page: https://textlab.dev/components/caret-morph
- Markdown: https://textlab.dev/components/caret-morph.md
- Registry item: https://textlab.dev/r/caret-morph.json
- Source file: `registry/textlab/caret-morph/caret-morph.tsx`
- Dependencies: motion
- Variants: highlight, bar, block
- Keywords: caret, cursor, selection, highlight, editor, text cursor
## When to use
- Editor and writing-tool marketing where the caret is the product.
- Interactive pull quotes.
## When not to use
- Real text inputs; use the browser caret.
- Long paragraphs; the tour takes too long to be noticed.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/caret-morph
pnpm dlx shadcn@latest add @textlab/caret-morph
```
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/caret-morph.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 { CaretMorph } from "@/components/textlab/caret-morph/caret-morph";
export function Example() {
return ;
}
```
## API reference
### CaretMorph
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"highlight" \| "bar" \| "block"` | `"highlight"` | highlight selects the word, bar sits after it like a cursor, block inverts it. |
| `autoplay` | `boolean` | `true` | Tour the words while nobody is pointing. |
| `interval` | `number` | `1.3` | Seconds per word on the tour. |
| `color` | `string` | `var(--tl-accent)` | Highlight color. |
| `as` | `ElementType` | `"p"` | 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 sentence is focusable and named; Left and Right move the caret.
- Pointer and focus pause the tour.
## Motion
- The caret glides in 200 ms with a strong ease-out and retargets mid-move, so hopping across words stays quick.
- Position and width ride one transform, so the glide never touches layout.
## Reduced motion
The caret is hidden; the sentence is plain text.
## Performance
- One absolutely positioned element. Word boxes are measured on resize, not per frame.
## 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: highlight, bar, block.
## Related
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
## Also in typing
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
## Source
### components/textlab/caret-morph/caret-morph.tsx
```tsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ElementType, type KeyboardEvent } from "react";
import { motion } from "motion/react";
import { duration, ease } from "../lib/motion-tokens";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type CaretMorphVariant = "bar" | "block" | "highlight";
export interface CaretMorphProps {
text: string;
/** bar sits after the word like a cursor, block inverts the word, highlight selects it. */
variant?: CaretMorphVariant;
/** Tour the words on its own while nobody is pointing. */
autoplay?: boolean;
/** Seconds per word on the tour. */
interval?: number;
/** Highlight color for the highlight variant. */
color?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
type Box = { x: number; y: number; w: number; h: number };
/**
* A caret that glides between words and reshapes to fit them. Point at a word, or focus the
* sentence and use the arrow keys, and the caret springs there.
*/
export function CaretMorph({ text, variant = "highlight", autoplay = true, interval = 1.3, color = "var(--tl-accent, #3a9a5b)", as: Tag = "p", reducedMotion = "user", className, style }: CaretMorphProps) {
const reduced = useReduced(reducedMotion);
const words = splitWords(text);
const root = useRef(null);
const [boxes, setBoxes] = useState([]);
const [index, setIndex] = useState(0);
const [engaged, setEngaged] = useState(false);
useLayoutEffect(() => {
const node = root.current;
if (!node) return;
const measure = () => {
const spans = node.querySelectorAll("[data-tl-word]");
// Measure against the root's own box: offsetLeft is relative to the nearest positioned ancestor,
// which here is an inline wrapper whose origin moves when the sentence wraps.
const origin = node.getBoundingClientRect();
setBoxes(
Array.from(spans, (s) => {
const r = s.getBoundingClientRect();
return { x: r.left - origin.left, y: r.top - origin.top, w: r.width, h: r.height };
}),
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
document.fonts?.ready.then(measure);
return () => observer.disconnect();
}, [text]);
useEffect(() => {
if (!autoplay || engaged || reduced || words.length < 2) return;
const id = setInterval(() => setIndex((i) => (i + 1) % words.length), interval * 1000);
return () => clearInterval(id);
}, [autoplay, engaged, reduced, words.length, interval]);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
event.preventDefault();
setIndex((i) => (i + (event.key === "ArrowRight" ? 1 : -1) + words.length) % words.length);
};
const box = boxes[index];
// The caret is a fixed 100px-wide box moved and stretched with one transform, so the glide never
// touches layout. Its height only changes with the line height, which does not animate.
const BASE = 100;
const caret: CSSProperties = { position: "absolute", left: 0, top: 0, width: BASE, pointerEvents: "none", transformOrigin: "0 0", borderRadius: variant === "bar" ? 2 : "0.12em" };
const frame = box
? variant === "bar"
? { x: box.x + box.w + 2, y: box.y + box.h * 0.12, w: Math.max(2, box.h * 0.06), h: box.h * 0.78 }
: { x: box.x - box.h * 0.08, y: box.y + box.h * 0.04, w: box.w + box.h * 0.16, h: box.h * 0.92 }
: null;
const target = frame ? { transform: `translate(${frame.x.toFixed(1)}px, ${frame.y.toFixed(1)}px) scaleX(${(frame.w / BASE).toFixed(4)})` } : null;
return (
setEngaged(true)}
onBlur={() => setEngaged(false)}
onPointerLeave={() => setEngaged(false)}
>
{target && !reduced && (
)}
{words.map((word, i) => (
{
setEngaged(true);
setIndex(i);
}}
style={{ position: "relative", zIndex: 1 }}
>
{word.text}
{i < words.length - 1 ? " " : null}
))}
);
}
export default CaretMorph;
```
Full library index: https://textlab.dev/llms.txt
---
# Gradient text
> Color flows slowly through the letters.
- Type: Component (light)
- Page: https://textlab.dev/components/gradient-text
- Markdown: https://textlab.dev/components/gradient-text.md
- Registry item: https://textlab.dev/r/gradient-text.json
- Source file: `registry/textlab/gradient-text/gradient-text.tsx`
- Dependencies: none
- Variants: flow, conic, spectrum
- Keywords: gradient, animated gradient, rainbow, conic, background clip text
## When to use
- One accent word or short headline per screen.
- Brand moments that should feel alive but calm.
## When not to use
- Body text or long headlines; contrast varies along the gradient.
- Several gradients competing on one page.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/gradient-text
pnpm dlx shadcn@latest add @textlab/gradient-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/gradient-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 { GradientText } from "@/components/textlab/gradient-text/gradient-text";
export function Example() {
return ;
}
```
## API reference
### GradientText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"flow" \| "conic" \| "spectrum"` | `"flow"` | Drifting linear gradient, a turning color wheel, or a hue cycle. |
| `colors` | `string[]` | `spectrum tokens` | Gradient stops. |
| `speed` | `number` | `8` | Seconds per full cycle. |
| `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 text is never split; it reads, selects and wraps normally.
- Pick stops that all pass contrast against the background.
## Motion
- A full cycle takes 8 seconds by default, slow enough to read as ambient.
- Conic uses a registered angle property for a smooth rotation.
## Reduced motion
The gradient stays still.
## Performance
- Background position and a registered property; no JavaScript animation.
## 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: flow, conic, spectrum.
## Related
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
## Also in light
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
- [Ember text](https://textlab.dev/components/ember-text.md): Letters land hot and cool down to the text color.
## Source
### components/textlab/gradient-text/gradient-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type GradientTextVariant = "flow" | "conic" | "spectrum";
export interface GradientTextProps {
text: string;
/** flow drifts a linear gradient, conic turns a color wheel inside the letters, spectrum cycles hue. */
variant?: GradientTextVariant;
/** Gradient stops. Defaults to the spectrum tokens. */
colors?: string[];
/** Seconds per full cycle. */
speed?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CSS = `
@property --tl-angle { syntax: ""; inherits: false; initial-value: 0deg; }
@keyframes tl-gradient-flow { from { background-position: 0% 50%; } to { background-position: 200% 50%; } }
@keyframes tl-gradient-turn { to { --tl-angle: 360deg; } }
@keyframes tl-gradient-hue { to { filter: hue-rotate(360deg); } }
.tl-gradient { background-clip: text; -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
@media (prefers-reduced-motion: reduce) { .tl-gradient[data-motion="user"] { animation: none !important; } }
`;
const DEFAULT = ["var(--tl-fx-spectrum-1)", "var(--tl-fx-spectrum-2)", "var(--tl-fx-spectrum-3)", "var(--tl-fx-spectrum-4)"];
/**
* Color flows slowly through the letters. The text stays real text, clipped to a gradient, so it
* selects, wraps and reads like any other heading.
*/
export function GradientText({ text, variant = "flow", colors = DEFAULT, speed = 8, as: Tag = "span", reducedMotion = "user", className, style }: GradientTextProps) {
const reduced = useReduced(reducedMotion);
const loop = [...colors, colors[0]].join(", ");
const look: CSSProperties =
variant === "conic"
? { backgroundImage: `conic-gradient(from var(--tl-angle) at 50% 50%, ${loop})`, animation: `tl-gradient-turn ${speed}s linear infinite` }
: variant === "spectrum"
? { backgroundImage: `linear-gradient(90deg, ${colors.join(", ")})`, animation: `tl-gradient-hue ${speed}s linear infinite` }
: { backgroundImage: `linear-gradient(90deg, ${loop})`, backgroundSize: "200% 100%", animation: `tl-gradient-flow ${speed}s linear infinite` };
return (
{text}
);
}
export default GradientText;
```
Full library index: https://textlab.dev/llms.txt
---
# Shimmer text
> A calm light passes across the words.
- Type: Component (light)
- Page: https://textlab.dev/components/shimmer-text
- Markdown: https://textlab.dev/components/shimmer-text.md
- Registry item: https://textlab.dev/r/shimmer-text.json
- Source file: `registry/textlab/shimmer-text/shimmer-text.tsx`
- Dependencies: none
- Variants: subtle, bright, rainbow
- Keywords: shimmer, loading text, skeleton, thinking, sheen, ai loading
## When to use
- Loading, thinking and generating states.
- Placeholders that should feel alive while content arrives.
## When not to use
- Headings that are already loaded; the motion implies work in progress.
- Error or success messages.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/shimmer-text
pnpm dlx shadcn@latest add @textlab/shimmer-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/shimmer-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 { ShimmerText } from "@/components/textlab/shimmer-text/shimmer-text";
export function Example() {
return ;
}
```
## API reference
### ShimmerText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"subtle" \| "bright" \| "rainbow"` | `"subtle"` | A soft pass for status lines, a crisp sheen, or a hint of color. |
| `speed` | `number` | `2.4` | Seconds per pass, including the rest after it. |
| `spread` | `number` | `0.18` | Width of the light band as a share of the text. |
| `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
- Plain, unsplit text. Pair it with aria-busy on the region that is loading.
## Motion
- The light crosses in the first 70% of each cycle, then rests, so it never feels frantic.
## Reduced motion
The text shows at a steady, slightly muted color.
## Performance
- One CSS animation on background position.
## 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: subtle, bright, rainbow.
## Related
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
## Also in light
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
## Source
### components/textlab/shimmer-text/shimmer-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type ShimmerTextVariant = "subtle" | "bright" | "rainbow";
export interface ShimmerTextProps {
text: string;
/** subtle is a soft pass for status lines, bright is a crisp sheen, rainbow carries a hint of color. */
variant?: ShimmerTextVariant;
/** Seconds per pass, including the rest between passes. */
speed?: number;
/** Width of the light band, as a share of the text. */
spread?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CSS = `
@keyframes tl-shimmer { 0% { background-position: 100% 0; } 70%, 100% { background-position: 0% 0; } }
.tl-shimmer { background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-repeat: no-repeat; }
@media (prefers-reduced-motion: reduce) { .tl-shimmer[data-motion="user"] { animation: none !important; background-position: 50% 0 !important; } }
`;
/**
* A calm light passes across the words, then rests. Made for "thinking", "loading" and
* "generating" lines where something is happening but nothing needs attention yet.
*/
export function ShimmerText({ text, variant = "subtle", speed = 2.4, spread = 0.18, as: Tag = "span", reducedMotion = "user", className, style }: ShimmerTextProps) {
const reduced = useReduced(reducedMotion);
const half = (spread * 100) / 2;
const base = variant === "bright" ? "color-mix(in oklab, currentColor 45%, transparent)" : "color-mix(in oklab, currentColor 55%, transparent)";
const light =
variant === "rainbow"
? `var(--tl-fx-spectrum-1) ${50 - half}%, var(--tl-fx-spectrum-2) ${50 - half / 3}%, var(--tl-fx-spectrum-3) ${50 + half / 3}%, var(--tl-fx-spectrum-4) ${50 + half}%`
: `currentColor 50%`;
const image = `linear-gradient(90deg, ${base} ${50 - half}%, ${light}, ${base} ${50 + half}%)`;
return (
{text}
);
}
export default ShimmerText;
```
Full library index: https://textlab.dev/llms.txt
---
# Neon sign
> A neon sign that hums and flickers on.
- Type: Component (light)
- Page: https://textlab.dev/components/neon-text
- Markdown: https://textlab.dev/components/neon-text.md
- Registry item: https://textlab.dev/r/neon-text.json
- Source file: `registry/textlab/neon-text/neon-text.tsx`
- Dependencies: none
- Variants: flicker, steady, pulse
- Keywords: neon, glow, flicker, sign, retro, synthwave
## When to use
- Nightlife, music and retro brands on dark backgrounds.
- A single sign-like word.
## When not to use
- Light backgrounds; the glow washes out.
- Running copy.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/neon-text
pnpm dlx shadcn@latest add @textlab/neon-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/neon-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 { NeonText } from "@/components/textlab/neon-text/neon-text";
export function Example() {
return ;
}
```
## API reference
### NeonText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"flicker" \| "steady" \| "pulse"` | `"flicker"` | Powers on and stutters now and then, hums gently, or breathes its glow. |
| `color` | `string` | `hot pink` | Tube color. The core is mixed toward white. |
| `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
- Flicker stays under two flashes per second.
- The text is plain and unsplit.
## Motion
- Power-on takes 1.4 s, then a rare stutter every 7 s.
- Pulse breathes the halo over 3 s, not the letters.
## Reduced motion
The sign glows steadily with no flicker.
## Performance
- Text-shadow only; animation is CSS.
- Pulse animates text-shadow, which repaints; use it once per screen.
## 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: flicker, steady, pulse.
## Related
- [Ember text](https://textlab.dev/components/ember-text.md): Letters land hot and cool down to the text color.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Glitch text](https://textlab.dev/components/glitch-text.md): A controlled digital glitch on cue.
## Also in light
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
## Source
### components/textlab/neon-text/neon-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type NeonTextVariant = "steady" | "flicker" | "pulse";
export interface NeonTextProps {
text: string;
/** steady hums gently, flicker powers on and stutters now and then, pulse breathes its glow. */
variant?: NeonTextVariant;
/** Tube color. */
color?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
// Flicker never exceeds two flashes per second, well under the three-per-second seizure threshold.
const CSS = `
@keyframes tl-neon-on { 0% { opacity: 0.1; } 6% { opacity: 1; } 9% { opacity: 0.25; } 14% { opacity: 1; } 18% { opacity: 0.5; } 22%, 100% { opacity: 1; } }
@keyframes tl-neon-stutter { 0%, 91%, 93.5%, 100% { opacity: 1; } 92% { opacity: 0.45; } 96% { opacity: 0.85; } }
@keyframes tl-neon-hum { 0%, 100% { opacity: 1; } 50% { opacity: 0.93; } }
@keyframes tl-neon-pulse { 0%, 100% { text-shadow: var(--tl-neon-dim); } 50% { text-shadow: var(--tl-neon-full); } }
.tl-neon { color: color-mix(in oklab, var(--tl-neon) 30%, white); text-shadow: var(--tl-neon-full); }
.tl-neon[data-variant="steady"] { animation: tl-neon-hum 3.2s ease-in-out infinite; }
.tl-neon[data-variant="flicker"] { animation: tl-neon-on 1.4s steps(1) 1, tl-neon-stutter 7s steps(1) 1.4s infinite; }
.tl-neon[data-variant="pulse"] { animation: tl-neon-pulse 3s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .tl-neon[data-motion="user"] { animation: none !important; } }
`;
/** A neon sign that hums and flickers on. Layered glows give the tube a hot core and a soft halo. */
export function NeonText({ text, variant = "flicker", color = "oklch(0.72 0.24 350)", as: Tag = "span", reducedMotion = "user", className, style }: NeonTextProps) {
const reduced = useReduced(reducedMotion);
const glow = (k: number) =>
`0 0 ${0.02 * k}em #fff, 0 0 ${0.08 * k}em var(--tl-neon), 0 0 ${0.18 * k}em var(--tl-neon), 0 0 ${0.42 * k}em color-mix(in oklab, var(--tl-neon) 70%, transparent), 0 0 ${0.9 * k}em color-mix(in oklab, var(--tl-neon) 45%, transparent)`;
return (
{text}
);
}
export default NeonText;
```
Full library index: https://textlab.dev/llms.txt
---
# Aurora text
> Northern lights drift inside the letters.
- Type: Component (light)
- Page: https://textlab.dev/components/aurora-text
- Markdown: https://textlab.dev/components/aurora-text.md
- Registry item: https://textlab.dev/r/aurora-text.json
- Source file: `registry/textlab/aurora-text/aurora-text.tsx`
- Dependencies: none
- Variants: polar, ember, mint
- Keywords: aurora, northern lights, gradient, mesh gradient, ambient
## When to use
- Large display headlines on dark or very light pages.
- Atmospheric brand moments.
## When not to use
- Small text; the bands need room to read.
- Next to another gradient effect.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/aurora-text
pnpm dlx shadcn@latest add @textlab/aurora-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/aurora-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 { AuroraText } from "@/components/textlab/aurora-text/aurora-text";
export function Example() {
return ;
}
```
## API reference
### AuroraText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"polar" \| "ember" \| "mint"` | `"polar"` | Palette. |
| `colors` | `string[]` | – | Three or four custom colors; overrides the variant. |
| `speed` | `number` | `14` | Seconds for one drift cycle. |
| `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
- Plain text clipped to color. Check contrast of the darkest band against the background.
## Motion
- A 14 s ease-in-out drift; slow enough to feel like weather, not an animation.
## Reduced motion
A still aurora frame.
## Performance
- Four background layers moved by one CSS animation.
## 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: polar, ember, mint.
## Related
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
## Also in light
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
- [Ember text](https://textlab.dev/components/ember-text.md): Letters land hot and cool down to the text color.
## Source
### components/textlab/aurora-text/aurora-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type AuroraTextVariant = "polar" | "ember" | "mint";
export interface AuroraTextProps {
text: string;
/** Palette: polar greens and violets, ember oranges and pinks, mint teals and limes. */
variant?: AuroraTextVariant;
/** Custom colors, three or four. Overrides the variant. */
colors?: string[];
/** Seconds for the slowest band to drift. */
speed?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const PALETTES: Record = {
polar: ["oklch(0.78 0.17 160)", "oklch(0.7 0.15 200)", "oklch(0.62 0.2 290)", "oklch(0.8 0.14 130)"],
ember: ["oklch(0.72 0.19 40)", "oklch(0.66 0.22 15)", "oklch(0.7 0.2 350)", "oklch(0.82 0.15 75)"],
mint: ["oklch(0.82 0.13 175)", "oklch(0.86 0.17 130)", "oklch(0.74 0.12 210)", "oklch(0.9 0.1 100)"],
};
// Each band drifts on its own period, so the pattern never visibly repeats.
const CSS = `
@keyframes tl-aurora-a { 0%, 100% { background-position: 0% 50%, 100% 50%, 50% 0%, 50% 100%; } 50% { background-position: 100% 40%, 0% 60%, 40% 100%, 60% 0%; } }
.tl-aurora { background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-size: 180% 180%, 160% 160%, 200% 200%, 170% 170%; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
@media (prefers-reduced-motion: reduce) { .tl-aurora[data-motion="user"] { animation: none !important; } }
`;
/** Northern lights drift inside the letters: four soft bands, each moving on its own clock. */
export function AuroraText({ text, variant = "polar", colors, speed = 14, as: Tag = "span", reducedMotion = "user", className, style }: AuroraTextProps) {
const reduced = useReduced(reducedMotion);
const [a, b, c, d = a] = colors ?? PALETTES[variant];
const image = [
`radial-gradient(ellipse at 20% 30%, ${a}, transparent 60%)`,
`radial-gradient(ellipse at 80% 70%, ${b}, transparent 55%)`,
`radial-gradient(ellipse at 50% 10%, ${c}, transparent 60%)`,
`linear-gradient(120deg, ${d}, ${a})`,
].join(", ");
return (
{text}
);
}
export default AuroraText;
```
Full library index: https://textlab.dev/llms.txt
---
# Holographic text
> Foil that shifts color as you move across it.
- Type: Component (light)
- Page: https://textlab.dev/components/holo-text
- Markdown: https://textlab.dev/components/holo-text.md
- Registry item: https://textlab.dev/r/holo-text.json
- Source file: `registry/textlab/holo-text/holo-text.tsx`
- Dependencies: none
- Variants: holo, foil, chrome
- Keywords: holographic, foil, iridescent, chrome, metallic, premium
## When to use
- Collectibles, drops and premium tiers.
- Large display words people will hover or drag across.
## When not to use
- Small text; the pattern breaks the letterforms.
- Text that needs high, steady contrast.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/holo-text
pnpm dlx shadcn@latest add @textlab/holo-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/holo-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 { HoloText } from "@/components/textlab/holo-text/holo-text";
export function Example() {
return ;
}
```
## API reference
### HoloText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"holo" \| "foil" \| "chrome"` | `"holo"` | Rainbow hologram, warm iridescent leaf, or a cool metallic sweep. |
| `intensity` | `number` | `0.7` | How far colors travel with the pointer, 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
- Plain, unsplit text.
- Without a pointer it rests on a pleasant still frame.
## Motion
- The pattern follows the pointer with damping, like light on a tilted card.
- The sheen brightens while the pointer is over the text.
## Reduced motion
A still foil frame with no pointer tracking.
## Performance
- Pointer position is written to CSS variables once per frame, only while it is settling.
## 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: holo, foil, chrome.
## Related
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
## Also in light
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Ember text](https://textlab.dev/components/ember-text.md): Letters land hot and cool down to the text color.
## Source
### components/textlab/holo-text/holo-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { usePointerVars } from "../lib/use-pointer-vars";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type HoloTextVariant = "foil" | "holo" | "chrome";
export interface HoloTextProps {
text: string;
/** foil is warm iridescent leaf, holo is a full rainbow hologram, chrome is a cool metallic sweep. */
variant?: HoloTextVariant;
/** How strongly the colors travel with the pointer, 0 to 1. */
intensity?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const LAYERS: Record = {
foil: "repeating-linear-gradient(115deg, oklch(0.86 0.1 80) 0%, oklch(0.8 0.13 30) 8%, oklch(0.84 0.1 330) 16%, oklch(0.88 0.09 200) 24%, oklch(0.86 0.1 80) 32%)",
holo: "repeating-linear-gradient(110deg, oklch(0.8 0.18 20) 0%, oklch(0.88 0.16 90) 7%, oklch(0.84 0.17 150) 14%, oklch(0.78 0.15 230) 21%, oklch(0.74 0.2 300) 28%, oklch(0.8 0.18 20) 35%)",
chrome: "linear-gradient(180deg, oklch(0.98 0 0) 0%, oklch(0.72 0.01 250) 38%, oklch(0.32 0.02 250) 50%, oklch(0.8 0.01 250) 62%, oklch(0.96 0 0) 100%)",
};
/**
* Foil that shifts color as you move across it. The pattern slides with the pointer and a soft
* sheen follows it, the way light catches a holographic card.
*/
export function HoloText({ text, variant = "holo", intensity = 0.7, as: Tag = "span", reducedMotion = "user", className, style }: HoloTextProps) {
const ref = useRef(null);
const reduced = useReduced(reducedMotion);
usePointerVars(ref, { enabled: !reduced, rate: 8 });
const travel = `${intensity * 120}%`;
const sheen = `radial-gradient(circle at calc(var(--tl-px, 0.5) * 100%) calc(var(--tl-py, 0.5) * 100%), oklch(1 0 0 / calc(0.15 + var(--tl-in, 0) * 0.55)), transparent 45%)`;
return (
{text}
);
}
export default HoloText;
```
Full library index: https://textlab.dev/llms.txt
---
# Spotlight text
> A light follows the cursor and reveals the copy.
- Type: Component (light)
- Page: https://textlab.dev/components/spotlight-text
- Markdown: https://textlab.dev/components/spotlight-text.md
- Registry item: https://textlab.dev/r/spotlight-text.json
- Source file: `registry/textlab/spotlight-text/spotlight-text.tsx`
- Dependencies: none
- Variants: reveal, glow, outline
- Keywords: spotlight, flashlight, torch, cursor reveal, mask, hover light
## When to use
- Hero statements meant to be explored.
- Dark, cinematic sections.
## When not to use
- Instructions or anything people must read in full immediately.
- Light backgrounds with the glow variant.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/spotlight-text
pnpm dlx shadcn@latest add @textlab/spotlight-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/spotlight-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 { SpotlightText } from "@/components/textlab/spotlight-text/spotlight-text";
export function Example() {
return ;
}
```
## API reference
### SpotlightText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"reveal" \| "glow" \| "outline"` | `"reveal"` | Lift dim text, add a warm light, or fill hollow letters under the light. |
| `radius` | `number` | `2.2` | Radius of the light in em. |
| `color` | `string` | `var(--tl-fx-glow)` | Light color for the glow variant. |
| `dim` | `number` | `0.22` | Visibility outside the light, 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
- Screen readers get the text from a hidden copy.
- Outside the light the text stays legible at reduced contrast; raise dim if it must pass AA.
## Motion
- The light trails the pointer slightly, like a heavy torch.
- When the pointer leaves, the light fades rather than snapping off.
## Reduced motion
Shows the text normally with no light.
## Performance
- A radial mask driven by CSS variables written once per frame while 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: reveal, glow, outline.
## Related
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
## Also in light
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
## Source
### components/textlab/spotlight-text/spotlight-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { usePointerVars } from "../lib/use-pointer-vars";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type SpotlightTextVariant = "reveal" | "glow" | "outline";
export interface SpotlightTextProps {
text: string;
/** reveal lifts dim text to full, glow adds a warm light, outline fills hollow letters under the light. */
variant?: SpotlightTextVariant;
/** Radius of the light, in em. */
radius?: number;
/** Light color for the glow variant. */
color?: string;
/** How visible the text is outside the light, 0 to 1. */
dim?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* A light follows the cursor and reveals the copy. Works with touch too: drag a finger across it.
* Outside the light the text stays legible, just quieter.
*/
export function SpotlightText({ text, variant = "reveal", radius = 2.2, color = "var(--tl-fx-glow, #f59e0b)", dim = 0.22, as: Tag = "span", reducedMotion = "user", className, style }: SpotlightTextProps) {
const ref = useRef(null);
const reduced = useReduced(reducedMotion);
usePointerVars(ref, { enabled: !reduced, rate: 14, rest: null });
if (reduced) {
return (
{text}
);
}
const mask = `radial-gradient(circle ${radius}em at var(--tl-x, 50%) var(--tl-y, 50%), #000 0%, rgb(0 0 0 / 0.6) 45%, transparent 100%)`;
const base: CSSProperties =
variant === "outline"
? { WebkitTextFillColor: "transparent", WebkitTextStroke: "0.02em color-mix(in oklab, currentColor 55%, transparent)" }
: { opacity: dim };
return (
{text}
{text}
{text}
);
}
export default SpotlightText;
```
Full library index: https://textlab.dev/llms.txt
---
# Glass text
> Glass letters that bend the scene behind them.
- Type: Component (light)
- Page: https://textlab.dev/components/glass-text
- Markdown: https://textlab.dev/components/glass-text.md
- Registry item: https://textlab.dev/r/glass-text.json
- Source file: `registry/textlab/glass-text/glass-text.tsx`
- Dependencies: none
- Variants: clear, frost, tinted
- Keywords: glass, glassmorphism, frosted, refraction, liquid glass, translucent
## When to use
- Display words over photography or rich color fields.
- Premium, Apple-like hero moments.
## When not to use
- Plain backgrounds; there is nothing for the glass to bend.
- Small sizes; the rim and highlight need room.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/glass-text
pnpm dlx shadcn@latest add @textlab/glass-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/glass-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 { GlassText } from "@/components/textlab/glass-text/glass-text";
export function Example() {
return ;
}
```
## API reference
### GlassText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"clear" \| "frost" \| "tinted"` | `"clear"` | Strong refraction, milky soft glass, or glass tinted with the accent. |
| `backdrop` | `string` | `a soft color field` | The scene behind the glass, as a CSS background image. The letters refract it. |
| `tint` | `string` | `var(--tl-accent)` | Tint for the tinted variant. |
| `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 text is read from a hidden copy; the glass layers are decorative.
- Use a backdrop that keeps the letters distinguishable, or pick frost.
## Motion
- The refraction and highlight follow the pointer with damping.
- Nothing moves without a pointer, so it costs nothing at rest.
## Reduced motion
Glass stays still; no pointer tracking.
## Performance
- Three text layers with background clip; CSS variables update only while the pointer settles.
## 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: clear, frost, tinted.
## Related
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
## Also in light
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
## Source
### components/textlab/glass-text/glass-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { usePointerVars } from "../lib/use-pointer-vars";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type GlassTextVariant = "frost" | "clear" | "tinted";
export interface GlassTextProps {
text: string;
/** frost is milky and soft, clear bends the backdrop strongly, tinted carries the accent color. */
variant?: GlassTextVariant;
/** The scene behind the glass: any CSS background image. The letters refract it. */
backdrop?: string;
/** Tint color for the tinted variant. */
tint?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const DEFAULT_BACKDROP =
"radial-gradient(circle at 20% 30%, oklch(0.75 0.17 30), transparent 45%), radial-gradient(circle at 80% 60%, oklch(0.7 0.15 250), transparent 50%), radial-gradient(circle at 50% 100%, oklch(0.8 0.15 150), transparent 55%), linear-gradient(135deg, oklch(0.3 0.05 280), oklch(0.2 0.04 230))";
/**
* Glass letters over a scene. Each letter shows the scene behind it magnified and shifted, as thick
* glass would, with a bright rim and a highlight that follows the pointer.
*/
export function GlassText({ text, variant = "clear", backdrop = DEFAULT_BACKDROP, tint = "var(--tl-accent, #3a9a5b)", as: Tag = "span", reducedMotion = "user", className, style }: GlassTextProps) {
const ref = useRef(null);
const reduced = useReduced(reducedMotion);
usePointerVars(ref, { enabled: !reduced, rate: 9 });
const shift = variant === "clear" ? 9 : 4;
const refraction: CSSProperties = {
backgroundImage:
variant === "frost"
? `linear-gradient(oklch(1 0 0 / 0.42), oklch(1 0 0 / 0.42)), ${backdrop}`
: variant === "tinted"
? `linear-gradient(color-mix(in oklab, ${tint} 38%, transparent), color-mix(in oklab, ${tint} 38%, transparent)), ${backdrop}`
: backdrop,
// The backdrop is scaled up and nudged against the pointer, which reads as light bending through the glass.
backgroundSize: variant === "frost" ? "100% 100%, 108% 108%" : "100% 100%, 118% 118%",
backgroundPosition: `0 0, calc(50% + (0.5 - var(--tl-px, 0.5)) * ${shift}%) calc(50% + (0.5 - var(--tl-py, 0.5)) * ${shift}%)`,
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
};
if (variant === "clear") {
refraction.backgroundImage = `linear-gradient(transparent, transparent), ${backdrop}`;
}
return (
{text}
{/* Soft shadow the glass casts on the scene. */}
{text}{text}
{/* Rim light and a specular highlight that tracks the pointer. */}
{text}
);
}
export default GlassText;
```
Full library index: https://textlab.dev/llms.txt
---
# Ember text
> Letters land hot and cool down to the text color.
- Type: Component (light)
- Page: https://textlab.dev/components/ember-text
- Markdown: https://textlab.dev/components/ember-text.md
- Registry item: https://textlab.dev/r/ember-text.json
- Source file: `registry/textlab/ember-text/ember-text.tsx`
- Dependencies: motion
- Variants: ember, steel, ice
- Keywords: ember, fire, hot metal, forge, glow, cooling
## When to use
- Titles for craft, hardware and energy brands.
- Dark sections where warmth should arrive with the words.
## When not to use
- Light backgrounds with the ice variant; the frost disappears.
- Paragraphs.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/ember-text
pnpm dlx shadcn@latest add @textlab/ember-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/ember-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 { EmberText } from "@/components/textlab/ember-text/ember-text";
export function Example() {
return ;
}
```
## API reference
### EmberText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"ember" \| "steel" \| "ice"` | `"ember"` | White-hot to red, blue-white steel, or cyan frost. |
| `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. |
| `stagger` | `number` | `0.045` | Seconds between parts. |
| `duration` | `number` | `1.6` | Seconds a letter takes to cool. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
## Accessibility
- The glow is a decorative copy; the title is read once.
- The settled color is the inherited text color, so contrast is unchanged.
## Motion
- Each letter settles a short drop with a long-tailed ease-out.
- Heat fades through four stops over 1.6 s, brightest right as it lands.
## Reduced motion
The title appears in its normal color.
## Performance
- Two motion spans per letter; nothing runs after the last one cools.
## 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: ember, steel, ice.
## Related
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
## Also in light
- [Gradient text](https://textlab.dev/components/gradient-text.md): Color flows slowly through the letters.
- [Shimmer text](https://textlab.dev/components/shimmer-text.md): A calm light passes across the words.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Holographic text](https://textlab.dev/components/holo-text.md): Foil that shifts color as you move across it.
## Source
### components/textlab/ember-text/ember-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { motion } from "motion/react";
import { SrText } from "../lib/a11y";
import { splitWords } from "../lib/split";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type EmberTextVariant = "ember" | "steel" | "ice";
export interface EmberTextProps {
text: string;
/** ember glows white-hot to red, steel cools from blue-white, ice frosts from cyan. */
variant?: EmberTextVariant;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
/** Seconds between letters. */
stagger?: number;
/** Seconds a letter takes to cool. */
duration?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const HEAT: Record = {
ember: ["oklch(0.99 0.04 95)", "oklch(0.88 0.17 85)", "oklch(0.72 0.2 50)", "oklch(0.55 0.2 30)"],
steel: ["oklch(0.99 0.01 250)", "oklch(0.88 0.06 240)", "oklch(0.7 0.1 250)", "oklch(0.5 0.08 260)"],
ice: ["oklch(0.99 0.02 200)", "oklch(0.9 0.08 200)", "oklch(0.8 0.12 210)", "oklch(0.65 0.12 220)"],
};
/**
* Letters land white-hot and cool down to the text color, one after another, like metal leaving
* the forge. A glowing copy cools through the palette on top of each settled letter.
*/
export function EmberText({ text, variant = "ember", as: Tag = "span", trigger = "inView", active, once = true, delay = 0, stagger = 0.045, duration = 1.6, reducedMotion = "user", className, style }: EmberTextProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const hydrated = useHydrated();
const reduced = useReduced(reducedMotion);
const words = splitWords(text);
const heat = HEAT[variant];
if (reduced) {
return (
{text}
);
}
return (
{text}
{words.map((word, w) => (
{word.chars.map((char, c) => {
const at = delay + (word.charOffset + c) * stagger;
return (
{char}
{char}
);
})}
{w < words.length - 1 ? " " : null}
))}
);
}
export default EmberText;
```
Full library index: https://textlab.dev/llms.txt
---
# Gooey text
> Words melt into each other.
- Type: Component (distort)
- Page: https://textlab.dev/components/gooey-text
- Markdown: https://textlab.dev/components/gooey-text.md
- Registry item: https://textlab.dev/r/gooey-text.json
- Source file: `registry/textlab/gooey-text/gooey-text.tsx`
- Dependencies: motion
- Variants: blob, drip
- Keywords: gooey, goo, liquid, metaball, blob, melt
## When to use
- Playful brands and creative portfolios.
- Bold display words with simple shapes.
## When not to use
- Thin or small type; the threshold eats fine strokes.
- Long words that change length a lot.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/gooey-text
pnpm dlx shadcn@latest add @textlab/gooey-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/gooey-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 { GooeyText } from "@/components/textlab/gooey-text/gooey-text";
export function Example() {
return ;
}
```
## API reference
### GooeyText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `words` (required) | `string[]` | – | Words to melt between. |
| `variant` | `"blob" \| "drip"` | `"blob"` | Melt in place, or let the old word drip away. |
| `interval` | `number` | `2.2` | Seconds each word holds. |
| `duration` | `number` | `1.2` | Seconds the melt takes. |
| `goo` | `number` | `12` | Stickiness, 4 to 20. Higher merges letters more. |
| `label` | `string` | – | What screen readers hear. Defaults to every word. |
| `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
- Screen readers hear all words once.
- The longest word reserves the space, so layout never shifts.
## Motion
- Enter uses the enter ease; the leaving word uses the exit curve so they cross in the middle.
- The filter's alpha threshold turns the blur into liquid edges.
## Reduced motion
Shows the first word, still.
## Performance
- One SVG filter on the word. Blur and threshold are GPU-friendly in most browsers; avoid dozens on one page.
## 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: blob, drip.
## Related
- [Liquid text](https://textlab.dev/components/liquid-text.md): Ripples run through the letters under the cursor.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [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.
- [Heat haze](https://textlab.dev/components/heat-haze.md): A mirage warps the air around the type.
## Source
### components/textlab/gooey-text/gooey-text.tsx
```tsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { resolveEase } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useSvgId } from "../lib/use-svg-id";
export type GooeyTextVariant = "blob" | "drip";
export interface GooeyTextProps {
/** Words to melt between. */
words: string[];
/** blob melts one word into the next in place; drip lets the old word drip away as the new one forms. */
variant?: GooeyTextVariant;
/** Seconds each word holds. */
interval?: number;
/** Seconds the melt takes. */
duration?: number;
/** How sticky the goo is, 4 to 20. Higher merges letters more. */
goo?: number;
label?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Words melt into each other. Two words cross-blur inside an alpha-threshold filter, so their
* shapes merge like liquid instead of simply fading.
*/
export function GooeyText({ words, variant = "blob", interval = 2.2, duration = 1.2, goo = 12, label, as: Tag = "span", reducedMotion = "user", className, style }: GooeyTextProps) {
const id = useSvgId("tl-goo");
const reduced = useReduced(reducedMotion);
const [{ index, prev }, setStep] = useState<{ index: number; prev: number | null }>({ index: 0, prev: null });
const incoming = useRef(null);
const outgoing = useRef(null);
useEffect(() => {
if (reduced || words.length < 2) return;
const timer = setInterval(() => setStep(({ index: i }) => ({ index: (i + 1) % words.length, prev: i })), (interval + duration) * 1000);
return () => clearInterval(timer);
}, [reduced, words.length, interval, duration]);
// The melt runs per frame: blur grows as 1/progress, so a word stays crisp until late and then
// dissolves fast, while the alpha threshold turns the blur into liquid edges that join and split.
// A layout effect paints the first frame before the browser does, so the new word never flashes in.
useLayoutEffect(() => {
if (prev === null) return;
const inEl = incoming.current;
const outEl = outgoing.current;
if (!inEl || !outEl) return;
const curve = resolveEase("standard");
let raf = 0;
const start = performance.now();
const paint = (el: HTMLSpanElement, f: number, leaving: boolean) => {
const blur = f <= 0.001 ? 100 : Math.min(8 / f - 8, 100);
el.style.filter = `blur(${blur.toFixed(2)}px)`;
el.style.opacity = `${(Math.pow(f, 0.4) * 100).toFixed(1)}%`;
const drip = variant === "drip" ? 1 - f : 0;
el.style.transform = leaving ? `translateY(${(drip * 0.45).toFixed(3)}em) scaleY(${(1 + drip * 0.35).toFixed(3)})` : `translateY(${(-drip * 0.2).toFixed(3)}em)`;
};
const tick = (now: number) => {
const t = Math.min((now - start) / (duration * 1000), 1);
const f = cubicBezier(curve, t);
paint(inEl, f, false);
paint(outEl, 1 - f, true);
if (t < 1) raf = requestAnimationFrame(tick);
};
tick(start);
return () => cancelAnimationFrame(raf);
}, [index, prev, duration, variant]);
const spoken = label ?? words.join(", ");
if (reduced) {
return (
{spoken}{words[0]}
);
}
const longest = words.reduce((a, b) => (b.length > a.length ? b : a), "");
const layer: CSSProperties = { gridArea: "1 / 1", textAlign: "center", transformOrigin: "50% 0%", willChange: "filter, opacity" };
return (
{spoken}{longest}
{prev === null ? "" : words[prev]}
{words[index]}
);
}
/** Progress along a CSS cubic-bezier at time t, solved by bisection; plenty for one value per frame. */
function cubicBezier([x1, y1, x2, y2]: [number, number, number, number], t: number) {
const at = (a: number, b: number, s: number) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3;
let lo = 0;
let hi = 1;
let s = t;
for (let i = 0; i < 20; i++) {
s = (lo + hi) / 2;
if (at(x1, x2, s) < t) lo = s;
else hi = s;
}
return at(y1, y2, s);
}
export default GooeyText;
```
Full library index: https://textlab.dev/llms.txt
---
# 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 ;
}
```
## 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(null);
const turbulence = useRef(null);
const displace = useRef(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 (
{text}
);
}
// 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 (
{text}
{text}
{text}
);
}
export default LiquidText;
```
Full library index: https://textlab.dev/llms.txt
---
# 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 ;
}
```
## 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;
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({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const hydrated = useHydrated();
const blur = useRef(null);
const disp = useRef(null);
const matrix = useRef(null);
const layer = useRef(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 (
{text}
);
}
return (
{text}
{text}
);
}
export default InkBleed;
```
Full library index: https://textlab.dev/llms.txt
---
# 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 ;
}
```
## 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(null);
const warm = useRef(null);
const cool = useRef(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 (
{text}
);
}
const fringe = (color: string): CSSProperties => ({
position: "absolute",
inset: 0,
color,
opacity: 0.85,
pointerEvents: "none",
});
return (
event.pointerType !== "touch" && setHover(true)}
onPointerLeave={(event: ReactPointerEvent) => event.pointerType !== "touch" && setHover(false)}
>
{text}
{text}
{text}
{text}
);
}
export default ChromaticText;
```
Full library index: https://textlab.dev/llms.txt
---
# Glitch text
> A controlled digital glitch on cue.
- Type: Component (distort)
- Page: https://textlab.dev/components/glitch-text
- Markdown: https://textlab.dev/components/glitch-text.md
- Registry item: https://textlab.dev/r/glitch-text.json
- Source file: `registry/textlab/glitch-text/glitch-text.tsx`
- Dependencies: none
- Variants: slice, shift, scan
- Keywords: glitch, rgb split, distortion, cyberpunk, error, vhs
## When to use
- Gaming, music and cyber themes.
- Error and 404 pages with personality.
## When not to use
- Serious or high-trust contexts like payments.
- More than one glitch on screen.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/glitch-text
pnpm dlx shadcn@latest add @textlab/glitch-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/glitch-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 { GlitchText } from "@/components/textlab/glitch-text/glitch-text";
export function Example() {
return ;
}
```
## API reference
### GlitchText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"slice" \| "shift" \| "scan"` | `"slice"` | Tear bands sideways, jolt the word, or roll a bright scan band through it. |
| `trigger` | `"interval" \| "hover" \| "mount"` | `"interval"` | When it glitches. |
| `every` | `number` | `3.5` | Seconds between bursts for interval. |
| `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
- Bursts are 500 ms with at least 3 s between them by default, well under flash thresholds.
- The readable text only jolts by a few hundredths of an em, and only during a burst.
## Motion
- Hard steps, not tweens, so it looks digital.
- Long rests between bursts keep it intentional.
## Reduced motion
No glitching; plain text.
## Performance
- CSS keyframes on clip-path and transform; idle between bursts.
## 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: slice, shift, scan.
## Related
- [Chromatic text](https://textlab.dev/components/chromatic-text.md): The color channels split and rejoin.
- [Slice reveal](https://textlab.dev/components/slice-reveal.md): The title arrives in offset horizontal slices.
- [Scramble text](https://textlab.dev/components/scramble-text.md): Letters decode into place.
## 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/glitch-text/glitch-text.tsx
```tsx
"use client";
import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type GlitchTextVariant = "slice" | "shift" | "scan";
export interface GlitchTextProps {
text: string;
/** slice tears horizontal bands sideways, shift jolts the whole word with split channels, scan rolls a bright band through it. */
variant?: GlitchTextVariant;
/** When it glitches: on hover, on an interval, or once on mount. */
trigger?: "hover" | "interval" | "mount";
/** Seconds between bursts for trigger="interval". */
every?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
// Each burst is a double hit: a sharp tear, a beat of calm, a smaller aftershock, all hard-stepped
// over half a second, then a long rest. Never more than two bursts a second.
const CSS = `
@keyframes tl-glitch-a {
0% { clip-path: inset(10% 0 72% 0); transform: translate(-0.09em, 0) skewX(-8deg); }
8% { clip-path: inset(62% 0 20% 0); transform: translate(0.11em, 0); }
16% { clip-path: inset(34% 0 50% 0); transform: translate(-0.05em, 0) skewX(6deg); }
24% { clip-path: inset(84% 0 4% 0); transform: translate(0.07em, 0); }
32% { clip-path: inset(2% 0 88% 0); transform: translate(-0.1em, 0); }
40%, 58% { clip-path: inset(50% 0 50% 0); transform: none; }
64% { clip-path: inset(44% 0 40% 0); transform: translate(0.05em, 0); }
74% { clip-path: inset(70% 0 18% 0); transform: translate(-0.04em, 0); }
84%, 100% { clip-path: inset(50% 0 50% 0); transform: none; }
}
@keyframes tl-glitch-b {
0% { clip-path: inset(68% 0 10% 0); transform: translate(0.1em, 0); }
10% { clip-path: inset(6% 0 78% 0); transform: translate(-0.08em, 0) skewX(7deg); }
20% { clip-path: inset(42% 0 36% 0); transform: translate(0.06em, 0); }
30% { clip-path: inset(22% 0 62% 0); transform: translate(-0.11em, 0); }
40%, 60% { clip-path: inset(50% 0 50% 0); transform: none; }
68% { clip-path: inset(14% 0 72% 0); transform: translate(-0.05em, 0); }
78% { clip-path: inset(56% 0 30% 0); transform: translate(0.04em, 0); }
86%, 100% { clip-path: inset(50% 0 50% 0); transform: none; }
}
@keyframes tl-glitch-jolt {
0%, 40%, 60%, 86%, 100% { transform: none; opacity: 1; }
6% { transform: translate(-0.03em, 0.01em); opacity: 0.82; }
14% { transform: translate(0.025em, 0) skewX(-3deg); }
26% { transform: translate(-0.015em, -0.01em); opacity: 0.9; }
68% { transform: translate(0.02em, 0); opacity: 0.88; }
}
@keyframes tl-glitch-split-a {
0%, 40%, 60%, 86%, 100% { clip-path: inset(0 0 100% 0); transform: none; }
4% { clip-path: inset(0); transform: translate(-0.07em, 0.01em); }
16% { clip-path: inset(0); transform: translate(-0.04em, -0.01em); }
28% { clip-path: inset(0); transform: translate(-0.08em, 0); }
66% { clip-path: inset(0); transform: translate(-0.035em, 0); }
}
@keyframes tl-glitch-split-b {
0%, 40%, 60%, 86%, 100% { clip-path: inset(0 0 100% 0); transform: none; }
4% { clip-path: inset(0); transform: translate(0.07em, -0.01em); }
16% { clip-path: inset(0); transform: translate(0.05em, 0.01em); }
28% { clip-path: inset(0); transform: translate(0.08em, 0); }
66% { clip-path: inset(0); transform: translate(0.035em, 0); }
}
@keyframes tl-glitch-shift { 0%, 40%, 60%, 86%, 100% { transform: none; } 6% { transform: translate(-0.06em, 0.02em) skewX(-8deg); } 16% { transform: translate(0.07em, -0.01em); } 28% { transform: translate(-0.03em, 0) skewX(5deg); } 68% { transform: translate(0.03em, 0.01em); } }
@keyframes tl-glitch-scan { from { background-position: 0 -120%; } to { background-position: 0 220%; } }
.tl-glitch-layer { position: absolute; inset: 0; pointer-events: none; clip-path: inset(50% 0 50% 0); }
[data-glitching] > .tl-glitch-a { animation: tl-glitch-a 0.5s steps(1) 1; color: var(--tl-glitch-1); }
[data-glitching] > .tl-glitch-b { animation: tl-glitch-b 0.5s steps(1) 1; color: var(--tl-glitch-2); }
[data-glitching][data-variant="slice"] > .tl-glitch-main { animation: tl-glitch-jolt 0.5s steps(1) 1; }
[data-glitching][data-variant="shift"] > .tl-glitch-main { animation: tl-glitch-shift 0.5s steps(1) 1; }
[data-glitching][data-variant="shift"] > .tl-glitch-a { animation-name: tl-glitch-split-a; opacity: 0.8; }
[data-glitching][data-variant="shift"] > .tl-glitch-b { animation-name: tl-glitch-split-b; opacity: 0.8; }
[data-variant="scan"] > .tl-glitch-scan { clip-path: none; background: linear-gradient(transparent 0%, transparent 38%, var(--tl-glitch-2) 50%, transparent 62%) no-repeat; background-size: 100% 40%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-position: 0 -120%; }
[data-glitching][data-variant="scan"] > .tl-glitch-scan { animation: tl-glitch-scan 0.6s linear 1; }
[data-glitching][data-variant="scan"] > .tl-glitch-main { animation: tl-glitch-jolt 0.6s steps(1) 1; }
`;
/**
* A controlled digital glitch on cue: short, hard-stepped bursts with long rests, so it reads as
* intentional rather than broken.
*/
export function GlitchText({ text, variant = "slice", trigger = "interval", every = 3.5, as: Tag = "span", reducedMotion = "user", className, style }: GlitchTextProps) {
const reduced = useReduced(reducedMotion);
const [burst, setBurst] = useState(0);
const [glitching, setGlitching] = useState(false);
useEffect(() => {
if (reduced) return;
if (trigger === "mount") {
const id = setTimeout(() => setBurst((b) => b + 1), 300);
return () => clearTimeout(id);
}
if (trigger !== "interval") return;
const id = setInterval(() => setBurst((b) => b + 1), every * 1000);
return () => clearInterval(id);
}, [reduced, trigger, every]);
// Derive the burst window while rendering instead of syncing it in an effect.
const [seen, setSeen] = useState(0);
if (burst !== seen) {
setSeen(burst);
setGlitching(true);
}
useEffect(() => {
if (!glitching) return;
const id = setTimeout(() => setGlitching(false), 650);
return () => clearTimeout(id);
}, [glitching, burst]);
if (reduced) {
return (
{text}
);
}
return (
setBurst((b) => b + 1) : undefined}
>
{text}
{text}
{variant !== "scan" && (
<>
{text}
{text}
>
)}
{variant === "scan" && (
{text}
)}
);
}
export default GlitchText;
```
Full library index: https://textlab.dev/llms.txt
---
# Heat haze
> A mirage warps the air around the type.
- Type: Component (distort)
- Page: https://textlab.dev/components/heat-haze
- Markdown: https://textlab.dev/components/heat-haze.md
- Registry item: https://textlab.dev/r/heat-haze.json
- Source file: `registry/textlab/heat-haze/heat-haze.tsx`
- Dependencies: none
- Variants: haze, boil
- Keywords: heat, haze, mirage, shimmer, turbulence, desert
## When to use
- Travel, summer and desert themes.
- A single atmospheric headline.
## When not to use
- Text people must read closely.
- Multiple instances on one page; each runs a live filter.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/heat-haze
pnpm dlx shadcn@latest add @textlab/heat-haze
```
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/heat-haze.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 { HeatHaze } from "@/components/textlab/heat-haze/heat-haze";
export function Example() {
return ;
}
```
## API reference
### HeatHaze
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"haze" \| "boil"` | `"haze"` | A gentle shimmer, or fast rising air. |
| `strength` | `number` | `6` | Distortion in px. |
| `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 distorted layer is decorative; the text is read from a hidden copy.
- Distortion is a few pixels, so the word stays legible.
## Motion
- Noise rises continuously while its seed steps, so the shimmer never repeats exactly.
## Reduced motion
Plain, still text.
## Performance
- SVG SMIL animation on one filter; no JavaScript loop.
- Filters repaint every frame; use one per screen.
## 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: haze, boil.
## Related
- [Liquid text](https://textlab.dev/components/liquid-text.md): Ripples run through the letters under the cursor.
- [Aurora text](https://textlab.dev/components/aurora-text.md): Northern lights drift inside the letters.
- [Ink bleed](https://textlab.dev/components/ink-bleed.md): Text soaks in like ink on paper.
## Also in distort
- [Gooey text](https://textlab.dev/components/gooey-text.md): Words melt into each other.
- [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/heat-haze/heat-haze.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useSvgId } from "../lib/use-svg-id";
export type HeatHazeVariant = "haze" | "boil";
export interface HeatHazeProps {
text: string;
/** haze shimmers gently, boil rises fast like air over hot asphalt. */
variant?: HeatHazeVariant;
/** Distortion strength in px. */
strength?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* A mirage warps the type. Rising turbulence bends the letters most at the top and leaves the
* baseline nearly still, the way heat shimmers above a road.
*/
export function HeatHaze({ text, variant = "haze", strength = 6, as: Tag = "span", reducedMotion = "user", className, style }: HeatHazeProps) {
const id = useSvgId("tl-haze");
const reduced = useReduced(reducedMotion);
if (reduced) {
return (
{text}
);
}
const boil = variant === "boil";
return (
{text}
{text}
);
}
export default HeatHaze;
```
Full library index: https://textlab.dev/llms.txt
---
# Scroll fill
> Words light up as you read down the page.
- Type: Component (scroll)
- Page: https://textlab.dev/components/scroll-fill
- Markdown: https://textlab.dev/components/scroll-fill.md
- Registry item: https://textlab.dev/r/scroll-fill.json
- Source file: `registry/textlab/scroll-fill/scroll-fill.tsx`
- Dependencies: motion
- Variants: word, char, line
- Keywords: scroll, text fill, scroll reveal, highlight on scroll, apple scroll text, reading progress
## When to use
- Manifesto paragraphs and mission statements.
- A key paragraph you want people to actually read.
## When not to use
- Every paragraph on a page; the effect loses meaning.
- Text above the fold that should be readable at once.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/scroll-fill
pnpm dlx shadcn@latest add @textlab/scroll-fill
```
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/scroll-fill.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 { ScrollFill } from "@/components/textlab/scroll-fill/scroll-fill";
export function Example() {
return ;
}
```
## API reference
### ScrollFill
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"word" \| "char" \| "line"` | `"word"` | What lights up as one unit. |
| `dim` | `number` | `0.18` | Opacity of text not yet reached. |
| `container` | `RefObject` | – | Scrollable ancestor to track. Defaults to the window. |
| `offset` | `[string, string]` | `["start 0.85", "end 0.45"]` | Where filling starts and ends, in motion scroll-offset syntax. |
| `as` | `ElementType` | `"p"` | 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 full paragraph is read once from a hidden copy.
- Dim text keeps its shape, so nothing is hidden from sighted readers who scroll fast.
## Motion
- Progress is tied to scroll position, not time, so it reverses when you scroll up.
- Filling starts as the paragraph enters the lower part of the viewport.
## Reduced motion
The paragraph shows at full opacity.
## Responsive behavior
- Line mode re-measures on resize.
## Performance
- Opacity only. One derived motion value per unit, no React renders while scrolling.
## 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: word, char, line.
## Related
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
- [In-view title](https://textlab.dev/components/in-view-title.md): Bring a section title in as it scrolls into view.
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.
## Also in scroll
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
## Source
### components/textlab/scroll-fill/scroll-fill.tsx
```tsx
"use client";
import { Fragment, useRef, type CSSProperties, type ElementType, type RefObject } from "react";
import { motion, useScroll, useTransform, type MotionValue } from "motion/react";
import { SrText } from "../lib/a11y";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { groupByLine, useSplitLines } from "../lib/use-split-lines";
export type ScrollFillVariant = "word" | "char" | "line";
export interface ScrollFillProps {
text: string;
/** What lights up as one unit. */
variant?: ScrollFillVariant;
/** Opacity of text that has not been read yet. */
dim?: number;
/** Scrollable ancestor to track. Defaults to the window. */
container?: RefObject;
/** Where filling starts and ends, as motion scroll offsets. */
offset?: [string, string];
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
function Unit({ progress, range, dim, children, block }: { progress: MotionValue; range: [number, number]; dim: number; children: React.ReactNode; block?: boolean }) {
const opacity = useTransform(progress, range, [dim, 1]);
return {children};
}
/**
* Words light up as you read down the page. Progress comes straight from scroll position, so it
* follows the reader exactly, forwards and backwards.
*/
export function ScrollFill({ text, variant = "word", dim = 0.18, container, offset = ["start 0.85", "end 0.45"], as: Tag = "p", reducedMotion = "user", className, style }: ScrollFillProps) {
const ref = useRef(null);
const reduced = useReduced(reducedMotion);
// The offset strings are motion's scroll offset syntax; the cast keeps the public prop simple.
const { scrollYProgress } = useScroll({ target: ref, container, offset: offset as never });
const words = splitWords(text);
const lines = useSplitLines(ref, words.length, variant === "line" && !reduced);
if (reduced) {
return (
{text}
);
}
const total = words.reduce((n, w) => n + w.chars.length, 0);
let content: React.ReactNode;
if (variant === "char") {
content = words.map((word, w) => (
{word.chars.map((char, c) => {
const i = word.charOffset + c;
return (
{char}
);
})}
{w < words.length - 1 ? " " : null}
));
} else if (variant === "line" && lines) {
const groups = groupByLine(words, lines);
content = groups.map((group, l) => (
{group.map((word, w) => (
{word.text}
{w < group.length - 1 ? " " : null}
))}
));
} else {
content = words.map((word, w) => (
{word.text}
{w < words.length - 1 ? " " : null}
));
}
return (
{text}{content}
);
}
export default ScrollFill;
```
Full library index: https://textlab.dev/llms.txt
---
# Scroll skew
> Type leans with the speed of your scroll.
- Type: Component (scroll)
- Page: https://textlab.dev/components/scroll-skew
- Markdown: https://textlab.dev/components/scroll-skew.md
- Registry item: https://textlab.dev/r/scroll-skew.json
- Source file: `registry/textlab/scroll-skew/scroll-skew.tsx`
- Dependencies: motion
- Variants: skew, stretch
- Keywords: scroll velocity, skew on scroll, momentum, stretch, scroll effect
## When to use
- Big section titles on long, energetic pages.
- Portfolios with a lot of momentum.
## When not to use
- Reading-heavy pages; motion during scroll can distract.
- Small text.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/scroll-skew
pnpm dlx shadcn@latest add @textlab/scroll-skew
```
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/scroll-skew.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 { ScrollSkew } from "@/components/textlab/scroll-skew/scroll-skew";
export function Example() {
return ;
}
```
## API reference
### ScrollSkew
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"skew" \| "stretch"` | `"skew"` | Lean with scroll speed, or squash and stretch. |
| `max` | `number` | `12 (skew), 0.35 (stretch)` | Largest lean in degrees, or stretch above 1. |
| `container` | `RefObject` | – | Scrollable ancestor. Defaults to the window. |
| `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 text is never split; it reads normally.
- Motion only happens while the user is scrolling.
## Motion
- Velocity passes through a spring, so the lean rises and settles smoothly.
- Stretch preserves area, narrowing as it grows taller.
## Reduced motion
Upright, still text.
## Performance
- Transform only, driven by motion values; no React renders on scroll.
## 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: skew, stretch.
## Related
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
- [Scroll fill](https://textlab.dev/components/scroll-fill.md): Words light up as you read down the page.
## Also in scroll
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
## Source
### components/textlab/scroll-skew/scroll-skew.tsx
```tsx
"use client";
import type { CSSProperties, ElementType, RefObject } from "react";
import { motion, useTransform } from "motion/react";
import { clamp } from "../lib/math";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useScrollVelocity } from "../lib/use-scroll-velocity";
export type ScrollSkewVariant = "skew" | "stretch";
export interface ScrollSkewProps {
text: string;
/** skew leans the line with scroll speed; stretch squashes and stretches it vertically. */
variant?: ScrollSkewVariant;
/** Largest lean in degrees (skew) or stretch factor above 1 (stretch). */
max?: number;
container?: RefObject;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/** Type leans with the speed of your scroll and springs upright when you stop. */
export function ScrollSkew({ text, variant = "skew", max, container, as: Tag = "span", reducedMotion = "user", className, style }: ScrollSkewProps) {
const reduced = useReduced(reducedMotion);
const velocity = useScrollVelocity(container);
const limit = max ?? (variant === "skew" ? 12 : 0.35);
const skewY = useTransform(velocity, (v) => (variant === "skew" ? clamp(v / 140, -limit, limit) : 0));
const scaleY = useTransform(velocity, (v) => (variant === "stretch" ? 1 + clamp(Math.abs(v) / 4000, 0, limit) : 1));
const scaleX = useTransform(scaleY, (s) => 1 / Math.sqrt(s));
if (reduced) {
return (
{text}
);
}
return (
{text}
);
}
export default ScrollSkew;
```
Full library index: https://textlab.dev/llms.txt
---
# Velocity marquee
> A marquee that speeds up, slows and reverses with scroll.
- Type: Component (scroll)
- Page: https://textlab.dev/components/velocity-marquee
- Markdown: https://textlab.dev/components/velocity-marquee.md
- Registry item: https://textlab.dev/r/velocity-marquee.json
- Source file: `registry/textlab/velocity-marquee/velocity-marquee.tsx`
- Dependencies: motion
- Variants: single, double, outline
- Keywords: marquee, ticker, scroll velocity, infinite scroll text, banner
## When to use
- Bold bands between sections on marketing pages.
- Announcements and logos-as-text strips.
## When not to use
- Essential information; moving text is hard to read.
- More than two marquees on one page.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/velocity-marquee
pnpm dlx shadcn@latest add @textlab/velocity-marquee
```
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/velocity-marquee.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 { VelocityMarquee } from "@/components/textlab/velocity-marquee/velocity-marquee";
export function Example() {
return ;
}
```
## API reference
### VelocityMarquee
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"single" \| "double" \| "outline"` | `"single"` | One row, two rows in opposite directions, or alternating solid and hollow copies. |
| `speed` | `number` | `6` | Resting speed in percent of one copy per second. |
| `boost` | `number` | `4` | How much scroll speed adds. |
| `separator` | `string` | `"✦"` | Mark between copies. |
| `pauseOnHover` | `boolean` | `true` | Pause while hovered or focused so it can be read. |
| `container` | `RefObject` | – | Scrollable ancestor. Defaults to the window. |
| `as` | `ElementType` | `"div"` | Rendered element. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
## Accessibility
- Screen readers read the text once.
- Hover and focus pause it, meeting WCAG 2.2.2 for moving content.
## Motion
- Direction follows the last scroll direction and stays there.
- Speed eases back to rest on a spring when scrolling stops.
## Reduced motion
A single, still line.
## Performance
- One transform per row, updated in a frame loop that pauses offscreen.
## 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: single, double, outline.
## Related
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.
- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
## Also in scroll
- [Scroll fill](https://textlab.dev/components/scroll-fill.md): Words light up as you read down the page.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
## Source
### components/textlab/velocity-marquee/velocity-marquee.tsx
```tsx
"use client";
import { useRef, useState, type CSSProperties, type ElementType, type RefObject } from "react";
import { motion, useMotionValue, useTransform, type MotionValue } from "motion/react";
import { srOnly } from "../lib/a11y";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useScrollVelocity } from "../lib/use-scroll-velocity";
export type VelocityMarqueeVariant = "single" | "double" | "outline";
export interface VelocityMarqueeProps {
text: string;
/** single is one row, double adds a second row running the other way, outline alternates solid and hollow copies. */
variant?: VelocityMarqueeVariant;
/** Resting speed in percent of one copy per second. */
speed?: number;
/** How much scroll speed boosts the marquee. */
boost?: number;
/** Separator between copies. */
separator?: string;
/** Pause while hovered or focused. Recommended so people can read it. */
pauseOnHover?: boolean;
container?: RefObject;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const COPIES = 4;
const wrap = (min: number, max: number, v: number) => {
const range = max - min;
return ((((v - min) % range) + range) % range) + min;
};
function Row({ text, separator, outline, x }: { text: string; separator: string; outline: boolean; x: MotionValue }) {
return (
{Array.from({ length: COPIES }, (_, i) => (
{text}
{separator}
))}
);
}
/**
* A marquee that speeds up, slows down and reverses with your scroll. Scroll down and it races
* forward; scroll up and it turns around; stop and it eases back to its resting pace.
*/
export function VelocityMarquee({
text,
variant = "single",
speed = 6,
boost = 4,
separator = "✦",
pauseOnHover = true,
container,
as: Tag = "div",
reducedMotion = "user",
className,
style,
}: VelocityMarqueeProps) {
const root = useRef(null);
const reduced = useReduced(reducedMotion);
const velocity = useScrollVelocity(container, { stiffness: 300, damping: 50 });
const base = useMotionValue(0);
const direction = useRef(1);
const [paused, setPaused] = useState(false);
// Each copy is 1/COPIES of the row; wrapping by one copy makes the loop seamless.
const x = useTransform(base, (v) => `${wrap(-100 / COPIES, 0, v)}%`);
const xReverse = useTransform(base, (v) => `${wrap(-100 / COPIES, 0, -v - 100 / COPIES / 2)}%`);
useFrame(
(_, dt) => {
const v = velocity.get();
if (v < -5) direction.current = -1;
else if (v > 5) direction.current = 1;
const factor = 1 + Math.min(Math.abs(v) / 1000, 6) * boost;
const step = (paused ? 0 : 1) * direction.current * (speed / COPIES) * factor * dt;
base.set(base.get() - step);
},
{ ref: root, enabled: !reduced },
);
const hover = pauseOnHover ? { onPointerEnter: () => setPaused(true), onPointerLeave: () => setPaused(false), onFocus: () => setPaused(true), onBlur: () => setPaused(false) } : {};
if (reduced) {
return (
{text}
);
}
return (
{text}
{variant === "double" && }
);
}
export default VelocityMarquee;
```
Full library index: https://textlab.dev/llms.txt
---
# Scroll stretch
> Letters stretch tall as you scroll fast.
- Type: Component (scroll)
- Page: https://textlab.dev/components/scroll-stretch
- Markdown: https://textlab.dev/components/scroll-stretch.md
- Registry item: https://textlab.dev/r/scroll-stretch.json
- Source file: `registry/textlab/scroll-stretch/scroll-stretch.tsx`
- Dependencies: motion
- Variants: vertical, horizontal
- Keywords: stretch, scroll velocity, taffy, elastic, squash
## When to use
- Short, heavy display words on playful pages.
- Section dividers that react to momentum.
## When not to use
- Long titles; per-letter springs add up.
- Serious, text-first pages.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/scroll-stretch
pnpm dlx shadcn@latest add @textlab/scroll-stretch
```
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/scroll-stretch.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 { ScrollStretch } from "@/components/textlab/scroll-stretch/scroll-stretch";
export function Example() {
return ;
}
```
## API reference
### ScrollStretch
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"vertical" \| "horizontal"` | `"vertical"` | Pull letters tall, or smear them sideways. |
| `max` | `number` | `0.6` | Largest stretch above 1. |
| `container` | `RefObject` | – | Scrollable ancestor. Defaults to the window. |
| `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
- Letters are aria-hidden; the word is read once.
- Moves only during fast scrolling.
## Motion
- Each letter has its own spring, slightly softer along the word, so the stretch ripples.
- Volume is preserved: taller letters get narrower.
## Reduced motion
Still, normal letters.
## Performance
- One spring per letter. Keep it to short words.
## 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: vertical, horizontal.
## Related
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
## Also in scroll
- [Scroll fill](https://textlab.dev/components/scroll-fill.md): Words light up as you read down the page.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
## Source
### components/textlab/scroll-stretch/scroll-stretch.tsx
```tsx
"use client";
import type { CSSProperties, ElementType, RefObject } from "react";
import { motion, useSpring, useTransform, type MotionValue } from "motion/react";
import { SrText } from "../lib/a11y";
import { clamp } from "../lib/math";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useScrollVelocity } from "../lib/use-scroll-velocity";
export type ScrollStretchVariant = "vertical" | "horizontal";
export interface ScrollStretchProps {
text: string;
/** vertical pulls letters tall like taffy; horizontal smears them sideways. */
variant?: ScrollStretchVariant;
/** Largest stretch above 1. */
max?: number;
container?: RefObject;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
function Letter({ char, amount, index, total, variant }: { char: string; amount: MotionValue; index: number; total: number; variant: ScrollStretchVariant }) {
// Letters further along respond a little later, so the stretch travels through the word like a wave.
const lagged = useSpring(amount, { stiffness: 520 - (index / Math.max(1, total)) * 320, damping: 26 });
const scale = useTransform(lagged, (v) => 1 + v);
const counter = useTransform(lagged, (v) => 1 / Math.sqrt(1 + v));
return (
{char}
);
}
/** Letters stretch as you scroll fast and snap back when you stop, each one a beat after the last. */
export function ScrollStretch({ text, variant = "vertical", max = 0.6, container, as: Tag = "span", reducedMotion = "user", className, style }: ScrollStretchProps) {
const reduced = useReduced(reducedMotion);
const velocity = useScrollVelocity(container);
const amount = useTransform(velocity, (v) => clamp(Math.abs(v) / 3000, 0, max));
const chars = graphemes(text);
if (reduced) {
return (
{text}
);
}
return (
{text}
{chars.map((char, i) => (
))}
);
}
export default ScrollStretch;
```
Full library index: https://textlab.dev/llms.txt
---
# Depth layers
> Outline copies drift apart in depth as you scroll.
- Type: Component (scroll)
- Page: https://textlab.dev/components/depth-layers
- Markdown: https://textlab.dev/components/depth-layers.md
- Registry item: https://textlab.dev/r/depth-layers.json
- Source file: `registry/textlab/depth-layers/depth-layers.tsx`
- Dependencies: motion
- Variants: stack, fan
- Keywords: parallax, depth, layers, outline, stack, scroll
## When to use
- Big, bold section titles on scroll-led pages.
- Posters and editorial covers.
## When not to use
- Thin or small type; outlines disappear.
- Tight layouts; layers extend below the text.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/depth-layers
pnpm dlx shadcn@latest add @textlab/depth-layers
```
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/depth-layers.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 { DepthLayers } from "@/components/textlab/depth-layers/depth-layers";
export function Example() {
return ;
}
```
## API reference
### DepthLayers
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"stack" \| "fan"` | `"stack"` | Separate vertically like layered prints, or fan open around a corner. |
| `layers` | `number` | `5` | Outline copies behind the solid one. |
| `spread` | `number` | `0.12em (stack), 4° (fan)` | Largest gap between neighbouring copies. |
| `container` | `RefObject` | – | Scrollable ancestor. Defaults to the window. |
| `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
- Copies are aria-hidden; the title is read once.
- The solid copy never moves, so the title stays readable.
## Motion
- Widest at the middle of the viewport, closed at the edges.
- A spring smooths scroll jitter.
## Reduced motion
Only the solid title, no layers.
## Performance
- N transformed copies; transform and opacity 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: stack, fan.
## Related
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
- [Scroll skew](https://textlab.dev/components/scroll-skew.md): Type leans with the speed of your scroll.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
## Also in scroll
- [Scroll fill](https://textlab.dev/components/scroll-fill.md): Words light up as you read down the page.
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
## Source
### components/textlab/depth-layers/depth-layers.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType, type RefObject } from "react";
import { motion, useScroll, useSpring, useTransform, type MotionValue } from "motion/react";
import { srOnly } from "../lib/a11y";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type DepthLayersVariant = "stack" | "fan";
export interface DepthLayersProps {
text: string;
/** stack separates copies vertically like layered prints; fan swings them open around a corner. */
variant?: DepthLayersVariant;
/** Number of outline copies behind the solid one. */
layers?: number;
/** Largest gap between neighbouring copies, in em (stack) or degrees (fan). */
spread?: number;
container?: RefObject;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
function Layer({ text, index, layers, progress, variant, spread }: { text: string; index: number; layers: number; progress: MotionValue; variant: DepthLayersVariant; spread: number }) {
const depth = index + 1;
const y = useTransform(progress, (p) => (variant === "stack" ? `${p * depth * spread}em` : "0em"));
const rotate = useTransform(progress, (p) => (variant === "fan" ? p * depth * spread : 0));
return (
{text}
);
}
/**
* Stacked outline copies drift apart in depth as the title moves through the viewport, widest at
* the center of the screen and closing again as it leaves.
*/
export function DepthLayers({ text, variant = "stack", layers = 5, spread, container, as: Tag = "span", reducedMotion = "user", className, style }: DepthLayersProps) {
const ref = useRef(null);
const reduced = useReduced(reducedMotion);
const { scrollYProgress } = useScroll({ target: ref, container, offset: ["start end", "end start"] });
// 0 at the edges of the viewport, 1 at its center.
const open = useTransform(scrollYProgress, [0, 0.5, 1], [0, 1, 0]);
const progress = useSpring(open, { stiffness: 180, damping: 30 });
const gap = spread ?? (variant === "stack" ? 0.12 : 4);
if (reduced) {
return (
{text}
);
}
return (
{text}
{Array.from({ length: layers }, (_, i) => (
))}
{text}
);
}
export default DepthLayers;
```
Full library index: https://textlab.dev/llms.txt
---
# Outline draw
> The outline draws itself, then fills.
- Type: Component (draw)
- Page: https://textlab.dev/components/outline-draw
- Markdown: https://textlab.dev/components/outline-draw.md
- Registry item: https://textlab.dev/r/outline-draw.json
- Source file: `registry/textlab/outline-draw/outline-draw.tsx`
- Dependencies: motion
- Variants: draw-fill, draw-only, fill-sweep
- Keywords: outline, stroke, draw, svg text, line drawing, dashoffset
## When to use
- Big, single-line display words: names, product marks, numbers.
- Architectural and blueprint aesthetics.
## When not to use
- Text that wraps; the SVG draws a single line.
- Small sizes where the outline is thinner than a pixel.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/outline-draw
pnpm dlx shadcn@latest add @textlab/outline-draw
```
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/outline-draw.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 { OutlineDraw } from "@/components/textlab/outline-draw/outline-draw";
export function Example() {
return ;
}
```
## API reference
### OutlineDraw
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"draw-fill" \| "draw-only" \| "fill-sweep"` | `"draw-fill"` | Trace then flood, keep the outline, or trace then fill left to right. |
| `stroke` | `number` | `0.018` | Outline width in em. |
| `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.6` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"standard"` | Easing token or cubic bezier. |
| `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 SVG is decorative; the word is read from a hidden copy.
- An invisible copy holds the exact size so nothing shifts.
## Motion
- All contours trace at once and finish together.
- The fill starts at 75% of the trace so the two overlap.
## Reduced motion
Shows the final state: filled, or outlined for draw-only.
## Responsive behavior
- Keep it on one line; size it with a fluid font size.
## Performance
- One SVG text element; stroke-dashoffset and fill-opacity 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: draw-fill, draw-only, fill-sweep.
## Related
- [Signature](https://textlab.dev/components/signature.md): A handwritten signature writes itself.
- [Wipe reveal](https://textlab.dev/components/wipe-reveal.md): A soft edge uncovers the line.
- [Ink bleed](https://textlab.dev/components/ink-bleed.md): Text soaks in like ink on paper.
## Also in draw
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
- [Underline draw](https://textlab.dev/components/underline-draw.md): A hand-drawn underline sketches in.
- [Strike-through](https://textlab.dev/components/strike-through.md): Cross out a word and write the new one.
- [Circle annotate](https://textlab.dev/components/circle-annotate.md): A pen circles the word that matters.
## Source
### components/textlab/outline-draw/outline-draw.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { motion } from "motion/react";
import { srOnly } from "../lib/a11y";
import { resolveEase, type EaseInput } from "../lib/motion-tokens";
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 OutlineDrawVariant = "draw-fill" | "draw-only" | "fill-sweep";
export interface OutlineDrawProps {
text: string;
/** draw-fill traces then floods with ink, draw-only leaves the outline, fill-sweep traces then fills left to right. */
variant?: OutlineDrawVariant;
/** Outline width in em. */
stroke?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
/** Seconds the trace takes. The fill follows. */
duration?: number;
ease?: EaseInput;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
onComplete?: () => void;
}
/**
* The outline draws itself, then fills. Every glyph contour is traced at once with a dash that
* runs out, so even long words finish together.
*/
export function OutlineDraw({ text, variant = "draw-fill", stroke = 0.018, as: Tag = "span", trigger = "inView", active, once = true, delay = 0, duration = 1.6, ease = "standard", reducedMotion = "user", className, style, onComplete }: OutlineDrawProps) {
const id = useSvgId("tl-outline");
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const curve = resolveEase(ease);
if (reduced) {
return (
{variant === "draw-only" ? {text} : text}
);
}
const fillAt = delay + duration * 0.75;
return (
{text}
{text}
);
}
export default OutlineDraw;
```
Full library index: https://textlab.dev/llms.txt
---
# Signature
> A handwritten signature writes itself.
- Type: Component (draw)
- Page: https://textlab.dev/components/signature
- Markdown: https://textlab.dev/components/signature.md
- Registry item: https://textlab.dev/r/signature.json
- Source file: `registry/textlab/signature/signature.tsx`
- Dependencies: motion
- Variants: pen, brush
- Keywords: signature, handwriting, script, autograph, write on, pen
## When to use
- Founder letters, about pages and certificates.
- Sign-offs at the end of a story.
## When not to use
- Without a script font loaded; system cursive fonts vary a lot.
- Anything longer than a name.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/signature
pnpm dlx shadcn@latest add @textlab/signature
```
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/signature.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 { Signature } from "@/components/textlab/signature/signature";
export function Example() {
return ;
}
```
## API reference
### Signature
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The name to sign. |
| `variant` | `"pen" \| "brush"` | `"pen"` | A fine nib, or a fuller brush where ink pools sooner. |
| `font` | `string` | `var(--tl-font-script)` | Script font family. Load one with next/font, for example Mrs Saint Delafield. |
| `color` | `string` | `currentColor` | Ink color. |
| `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` | `2.2` | 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 name is read once from a hidden copy.
- Script fonts are hard to read; keep the plain name nearby if it matters.
## Motion
- Writes left to right on a gentle in-out curve, like a hand gaining then losing speed.
- Ink fills in behind the nib, never ahead of it.
## Reduced motion
Shows the signature in full.
## Performance
- One SVG text and one animated mask rectangle.
## 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: pen, brush.
## Related
- [Outline draw](https://textlab.dev/components/outline-draw.md): The outline draws itself, then fills.
- [Ink bleed](https://textlab.dev/components/ink-bleed.md): Text soaks in like ink on paper.
- [Typewriter](https://textlab.dev/components/typewriter.md): Types, deletes and retypes with a caret.
## Also in draw
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
- [Underline draw](https://textlab.dev/components/underline-draw.md): A hand-drawn underline sketches in.
- [Strike-through](https://textlab.dev/components/strike-through.md): Cross out a word and write the new one.
- [Circle annotate](https://textlab.dev/components/circle-annotate.md): A pen circles the word that matters.
## Source
### components/textlab/signature/signature.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { motion } from "motion/react";
import { srOnly } from "../lib/a11y";
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 SignatureVariant = "pen" | "brush";
export interface SignatureProps {
/** The name to sign. */
text: string;
/** pen is a fine nib that fills in behind itself, brush is a fuller stroke with ink that pools. */
variant?: SignatureVariant;
/** Script font. Defaults to the --tl-font-script token. */
font?: string;
/** Pen color. */
color?: string;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
/** Seconds to sign the whole name. */
duration?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
onComplete?: () => void;
}
/**
* A handwritten signature writes itself, left to right. The nib traces the letters while a soft edge
* moves along with it, so ink only appears where the pen has already been.
*/
export function Signature({ text, variant = "pen", font = "var(--tl-font-script, 'Snell Roundhand', 'Brush Script MT', cursive)", color = "currentColor", as: Tag = "span", trigger = "inView", active, once = true, delay = 0, duration = 2.2, reducedMotion = "user", className, style, onComplete }: SignatureProps) {
const id = useSvgId("tl-sign");
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const fontStyle: CSSProperties = { fontFamily: font, ...style };
if (reduced) {
return (
{text}
);
}
const brush = variant === "brush";
const writing = { duration, delay, ease: [0.45, 0.05, 0.35, 1] as [number, number, number, number] };
return (
{text}
{text}
);
}
export default Signature;
```
Full library index: https://textlab.dev/llms.txt
---
# Marker highlight
> A highlighter swipes behind the words.
- Type: Component (draw)
- Page: https://textlab.dev/components/marker-highlight
- Markdown: https://textlab.dev/components/marker-highlight.md
- Registry item: https://textlab.dev/r/marker-highlight.json
- Source file: `registry/textlab/marker-highlight/marker-highlight.tsx`
- Dependencies: none
- Variants: marker, underline, box
- Keywords: highlight, marker, highlighter, mark, emphasis, text highlight
## When to use
- Key phrases inside paragraphs and testimonials.
- Phrases that wrap across lines.
## When not to use
- Whole paragraphs.
- As the only way to convey meaning; color alone is not enough.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/marker-highlight
pnpm dlx shadcn@latest add @textlab/marker-highlight
```
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/marker-highlight.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 { MarkerHighlight } from "@/components/textlab/marker-highlight/marker-highlight";
export function Example() {
return
Textlab is free and open source.
;
}
```
## API reference
### MarkerHighlight
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | – | The words to highlight. |
| `variant` | `"marker" \| "underline" \| "box"` | `"marker"` | Full highlighter stroke, thick low band, or soft block. |
| `color` | `string` | `var(--tl-fx-highlight)` | Highlight color. |
| `as` | `ElementType` | `"mark"` | 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` | `0.7` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"standard"` | Easing token or cubic bezier. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
## Accessibility
- Renders , which assistive tech can announce as highlighted.
- Text color is inherited and stays on top of the band.
## Motion
- The band grows left to right with the standard ease, like a real marker stroke.
## Reduced motion
The highlight is already there.
## Responsive behavior
- Each wrapped line gets its own highlight thanks to box-decoration-break: clone.
## Performance
- One CSS transition on background-size; no JavaScript animation.
## 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: marker, underline, box.
## Related
- [Underline draw](https://textlab.dev/components/underline-draw.md): A hand-drawn underline sketches in.
- [Circle annotate](https://textlab.dev/components/circle-annotate.md): A pen circles the word that matters.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
## Also in draw
- [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.
- [Strike-through](https://textlab.dev/components/strike-through.md): Cross out a word and write the new one.
## Source
### components/textlab/marker-highlight/marker-highlight.tsx
```tsx
"use client";
import type { CSSProperties, ElementType, ReactNode } from "react";
import { cssEase, type EaseInput } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type MarkerHighlightVariant = "marker" | "underline" | "box";
export interface MarkerHighlightProps {
children: ReactNode;
/** marker is a full highlighter stroke, underline a thick low band, box a soft rounded block. */
variant?: MarkerHighlightVariant;
/** Highlight color. Defaults to the highlight token, which adapts to dark mode. */
color?: string;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
duration?: number;
ease?: EaseInput;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const BANDS: Record = {
marker: { height: "78%", position: "0 70%", radius: "0.15em 0.35em 0.2em 0.3em" },
underline: { height: "32%", position: "0 92%", radius: "0.1em" },
box: { height: "100%", position: "0 50%", radius: "0.25em" },
};
/**
* A highlighter swipes behind the words. Works across line breaks: every line fragment gets its
* own stroke, because the background is cloned per line box. Renders a real by default.
*/
export function MarkerHighlight({
children,
variant = "marker",
color = "var(--tl-fx-highlight, oklch(0.92 0.15 100 / 0.75))",
as: Tag = "mark",
trigger = "inView",
active,
once = true,
delay = 0,
duration = 0.7,
ease = "standard",
reducedMotion = "user",
className,
style,
}: MarkerHighlightProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const band = BANDS[variant];
const shown = on || reduced;
return (
{children}
);
}
export default MarkerHighlight;
```
Full library index: https://textlab.dev/llms.txt
---
# Underline draw
> A hand-drawn underline sketches in.
- Type: Component (draw)
- Page: https://textlab.dev/components/underline-draw
- Markdown: https://textlab.dev/components/underline-draw.md
- Registry item: https://textlab.dev/r/underline-draw.json
- Source file: `registry/textlab/underline-draw/underline-draw.tsx`
- Dependencies: motion
- Variants: line, wave, scribble
- Keywords: underline, hand drawn, annotation, squiggle, rough notation, emphasis
## When to use
- Emphasising one word or phrase inside a headline.
- Friendly, human brands.
## When not to use
- Links; people expect a real underline there.
- Several underlines in one paragraph.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/underline-draw
pnpm dlx shadcn@latest add @textlab/underline-draw
```
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/underline-draw.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 { UnderlineDraw } from "@/components/textlab/underline-draw/underline-draw";
export function Example() {
return
Ship today, not someday
;
}
```
## API reference
### UnderlineDraw
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | – | The words to underline. |
| `variant` | `"line" \| "wave" \| "scribble"` | `"line"` | A confident curve, a squiggle, or a quick double pass. |
| `color` | `string` | `var(--tl-accent)` | Pen color. |
| `thickness` | `number` | `3` | Stroke width in px, constant at any size. |
| `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` | `0.6 (0.9 scribble)` | Seconds each part takes. |
| `ease` | `EaseName \| [number, number, number, number]` | `"standard"` | Easing token or cubic bezier. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
## Accessibility
- The underline is decorative SVG; the words are real text.
- Emphasis is visual only; add or if it matters semantically.
## Motion
- Draws with the standard ease so the pen speeds up and settles like a real stroke.
## Reduced motion
The underline is drawn from the start.
## Performance
- One SVG path animated with pathLength.
## 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: line, wave, scribble.
## Related
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
- [Circle annotate](https://textlab.dev/components/circle-annotate.md): A pen circles the word that matters.
- [Strike-through](https://textlab.dev/components/strike-through.md): Cross out a word and write the new one.
## Also in draw
- [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.
## Source
### components/textlab/underline-draw/underline-draw.tsx
```tsx
"use client";
import type { CSSProperties, ElementType, ReactNode } from "react";
import { motion } from "motion/react";
import { drawProps } from "../lib/draw";
import { hashString } from "../lib/math";
import type { EaseInput } from "../lib/motion-tokens";
import { roughScribble, roughUnderline, roughWave } from "../lib/rough";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type UnderlineDrawVariant = "line" | "wave" | "scribble";
export interface UnderlineDrawProps {
/** The words to underline. Can include other inline elements. */
children: ReactNode;
/** line is a confident curve, wave is a squiggle, scribble is a quick double pass. */
variant?: UnderlineDrawVariant;
/** Pen color. Defaults to the accent token. */
color?: string;
/** Stroke width in px. */
thickness?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
duration?: number;
ease?: EaseInput;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const PATHS = { line: roughUnderline, wave: roughWave, scribble: roughScribble };
/**
* A hand-drawn underline sketches in under the words. The stroke keeps its width at any size, and
* the jitter is seeded from the text so every underline is unique but stable.
*/
export function UnderlineDraw({ children, variant = "line", color = "var(--tl-accent, #3a9a5b)", thickness = 3, as: Tag = "span", trigger = "inView", active, once = true, delay = 0, duration, ease, reducedMotion = "user", className, style }: UnderlineDrawProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const seed = hashString(typeof children === "string" ? children : variant);
const d = PATHS[variant](seed);
const draw = drawProps(on || reduced, { duration: duration ?? (variant === "scribble" ? 0.9 : 0.6), delay, ease });
return (
{children}
);
}
export default UnderlineDraw;
```
Full library index: https://textlab.dev/llms.txt
---
# Strike-through
> Cross out a word and write the new one.
- Type: Component (draw)
- Page: https://textlab.dev/components/strike-through
- Markdown: https://textlab.dev/components/strike-through.md
- Registry item: https://textlab.dev/r/strike-through.json
- Source file: `registry/textlab/strike-through/strike-through.tsx`
- Dependencies: motion
- Variants: replace, line, scribble
- Keywords: strikethrough, cross out, correction, hand drawn, edit
## When to use
- Witty headlines that correct themselves.
- Pricing before and after, changelog wording.
## When not to use
- Legal or factual corrections; use and styled plainly.
- More than one per line.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/strike-through
pnpm dlx shadcn@latest add @textlab/strike-through
```
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/strike-through.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 { StrikeThrough } from "@/components/textlab/strike-through/strike-through";
export function Example() {
return
Meetings that could have been an
;
}
```
## API reference
### StrikeThrough
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"replace" \| "line" \| "scribble"` | `"line"` | Cross out and write a correction, a single line, or a scratch-out. |
| `replacement` | `string` | – | The correction written above, for replace. |
| `color` | `string` | `var(--tl-accent)` | Pen color. |
| `thickness` | `number` | `3` | Stroke width in px. |
| `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` | `0.5` | 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. |
## Accessibility
- Screen readers hear the meaning: the replacement and what it replaces, or that the word is struck out.
- The pen line and correction are decorative.
## Motion
- The old word dims just after the pen passes, the correction arrives last.
## Reduced motion
Shown already struck, with the correction in place.
## Performance
- One path and one small text element.
## 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: replace, line, scribble.
## Related
- [Underline draw](https://textlab.dev/components/underline-draw.md): A hand-drawn underline sketches in.
- [Circle annotate](https://textlab.dev/components/circle-annotate.md): A pen circles the word that matters.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
## Also in draw
- [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.
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
## Source
### components/textlab/strike-through/strike-through.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { srOnly } from "../lib/a11y";
import { drawProps } from "../lib/draw";
import { hashString } from "../lib/math";
import { ease } from "../lib/motion-tokens";
import { roughStrike, roughStrikeScribble } from "../lib/rough";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type StrikeThroughVariant = "line" | "scribble" | "replace";
export interface StrikeThroughProps {
text: string;
/** line crosses it out, scribble scratches it out, replace crosses it out and writes `replacement` above. */
variant?: StrikeThroughVariant;
/** The correction written above, for the replace variant. */
replacement?: string;
color?: string;
thickness?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
duration?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Cross out a word and, if you like, write the new one above it. The old word dims as the pen
* passes, so the correction reads in the right order.
*/
export function StrikeThrough({ text, variant = "line", replacement, color = "var(--tl-accent, #3a9a5b)", thickness = 3, as: Tag = "span", trigger = "inView", active, once = true, delay = 0, duration = 0.5, reducedMotion = "user", className, style }: StrikeThroughProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const struck = on || reduced;
const d = (variant === "scribble" ? roughStrikeScribble : roughStrike)(hashString(text));
const draw = drawProps(struck, { duration: variant === "scribble" ? duration * 1.6 : duration, delay });
const showReplacement = variant === "replace" && replacement && struck;
// An emoji correction pops in upright and larger; a word is handwritten small and slanted.
const isEmoji = !!replacement && /^[\p{Extended_Pictographic}\p{Emoji_Component}\u200d\ufe0f\s]+$/u.test(replacement);
const spoken = variant === "replace" && replacement ? `${replacement} (replaces ${text})` : `${text} (struck out)`;
return (
{spoken}
{text}
{showReplacement && (
{replacement}
)}
);
}
export default StrikeThrough;
```
Full library index: https://textlab.dev/llms.txt
---
# Circle annotate
> A pen circles the word that matters.
- Type: Component (draw)
- Page: https://textlab.dev/components/circle-annotate
- Markdown: https://textlab.dev/components/circle-annotate.md
- Registry item: https://textlab.dev/r/circle-annotate.json
- Source file: `registry/textlab/circle-annotate/circle-annotate.tsx`
- Dependencies: motion
- Variants: circle, box, bracket
- Keywords: circle, annotation, hand drawn, highlight word, rough notation, box
## When to use
- Pointing at the one word that sells the sentence.
- Pricing tiers, comparisons and testimonials.
## When not to use
- Several marks in one view; each one weakens the others.
- Buttons and links.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/circle-annotate
pnpm dlx shadcn@latest add @textlab/circle-annotate
```
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/circle-annotate.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 { CircleAnnotate } from "@/components/textlab/circle-annotate/circle-annotate";
export function Example() {
return
The only plan that scales with you
;
}
```
## API reference
### CircleAnnotate
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | – | The word or phrase to mark. |
| `variant` | `"circle" \| "box" \| "bracket"` | `"circle"` | Loop around it, frame it, or bracket it. |
| `color` | `string` | `var(--tl-accent)` | Pen color. |
| `thickness` | `number` | `2.5` | Stroke width in px. |
| `padding` | `number` | `0.25` | Room around the word in em. |
| `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` | `0.9` | 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. |
## Accessibility
- Decorative SVG; the text is untouched.
- Add or if the emphasis must be conveyed to assistive tech.
## Motion
- One continuous stroke with an overshoot, drawn with the standard ease.
## Reduced motion
The mark is drawn from the start.
## Performance
- One SVG path animated with pathLength.
## 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: circle, box, bracket.
## Related
- [Underline draw](https://textlab.dev/components/underline-draw.md): A hand-drawn underline sketches in.
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
- [Strike-through](https://textlab.dev/components/strike-through.md): Cross out a word and write the new one.
## Also in draw
- [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.
## Source
### components/textlab/circle-annotate/circle-annotate.tsx
```tsx
"use client";
import type { CSSProperties, ElementType, ReactNode } from "react";
import { motion } from "motion/react";
import { drawProps } from "../lib/draw";
import { hashString } from "../lib/math";
import { roughBox, roughBrackets, roughEllipse } from "../lib/rough";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type CircleAnnotateVariant = "circle" | "box" | "bracket";
export interface CircleAnnotateProps {
children: ReactNode;
/** circle loops around the word, box frames it, bracket sets it off at both ends. */
variant?: CircleAnnotateVariant;
color?: string;
thickness?: number;
/** Extra room around the word, in em. */
padding?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
delay?: number;
duration?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const PATHS = { circle: roughEllipse, box: roughBox, bracket: roughBrackets };
/** A pen circles the word that matters, in one continuous stroke that overshoots its start. */
export function CircleAnnotate({ children, variant = "circle", color = "var(--tl-accent, #3a9a5b)", thickness = 2.5, padding = 0.25, as: Tag = "span", trigger = "inView", active, once = true, delay = 0, duration = 0.9, reducedMotion = "user", className, style }: CircleAnnotateProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const reduced = useReduced(reducedMotion);
const d = PATHS[variant](hashString(typeof children === "string" ? children : variant));
const draw = drawProps(on, { duration, delay, ease: "standard" });
const pad = `${padding}em`;
// An ellipse drawn inside the word's box would cut its corners, so the circle's box grows past the
// word on every side until the corners of the letters sit inside the curve.
const frame: CSSProperties =
variant === "circle"
? { left: `calc(-12.5% - ${pad})`, top: `calc(-17.5% - 0.5 * ${pad})`, width: `calc(125% + 2 * ${pad})`, height: `calc(135% + ${pad})` }
: { left: `calc(-1 * ${pad})`, top: `calc(-0.6 * ${pad})`, width: `calc(100% + 2 * ${pad})`, height: `calc(100% + 1.2 * ${pad})` };
return (
{children}
);
}
export default CircleAnnotate;
```
Full library index: https://textlab.dev/llms.txt
---
# Magnetic text
> Letters lean toward the cursor and spring back.
- Type: Component (cursor)
- Page: https://textlab.dev/components/magnetic-text
- Markdown: https://textlab.dev/components/magnetic-text.md
- Registry item: https://textlab.dev/r/magnetic-text.json
- Source file: `registry/textlab/magnetic-text/magnetic-text.tsx`
- Dependencies: none
- Variants: attract, repel
- Keywords: magnetic, cursor, hover, attract, repel, interactive text
## When to use
- Hero words and footers that reward exploring with the mouse.
- Playful portfolios.
## When not to use
- Touch-only layouts; nothing happens without a mouse or pen.
- Long text; every letter is tracked.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/magnetic-text
pnpm dlx shadcn@latest add @textlab/magnetic-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/magnetic-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 { MagneticText } from "@/components/textlab/magnetic-text/magnetic-text";
export function Example() {
return ;
}
```
## API reference
### MagneticText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"attract" \| "repel"` | `"attract"` | Lean toward the cursor, or shy away from it. |
| `radius` | `number` | `2.5` | Reach of the field in em. |
| `strength` | `number` | `0.28` | Largest offset 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
- Letters are aria-hidden; the text is read once.
- Offsets are small, so words stay readable while they move.
## Motion
- Pull follows a smoothstep falloff and each letter eases toward its target.
- Letters glide home when the pointer leaves.
## Reduced motion
Still, plain text.
## Performance
- Letter centers are measured once and on resize; the frame loop only writes transforms.
## 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: attract, repel.
## Related
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
## Also in cursor
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
## Source
### components/textlab/magnetic-text/magnetic-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { damp } from "../lib/math";
import { splitWords } from "../lib/split";
import { useFrame } from "../lib/use-frame";
import { useLetterBoxes } from "../lib/use-letter-boxes";
import { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type MagneticTextVariant = "attract" | "repel";
export interface MagneticTextProps {
text: string;
/** attract leans letters toward the cursor, repel pushes them away. */
variant?: MagneticTextVariant;
/** Reach of the field, in em. */
radius?: number;
/** Largest offset, in em. */
strength?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Letters lean toward the cursor and spring back when it leaves. The pull fades smoothly with
* distance, so only the letters near the pointer move.
*/
export function MagneticText({ text, variant = "attract", radius = 2.5, strength = 0.28, as: Tag = "span", reducedMotion = "user", className, style }: MagneticTextProps) {
const root = useRef(null);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const { boxes, em: emRef } = useLetterBoxes(root, [text]);
const offsets = useRef<{ x: number; y: number }[]>([]);
const words = splitWords(text);
useFrame(
(_, dt) => {
const node = root.current;
if (!node) return;
const em = emRef.current;
const reach = radius * em;
const max = strength * em;
const p = pointer.current;
const sign = variant === "attract" ? 1 : -1;
boxes.current.forEach((box, i) => {
const o = (offsets.current[i] ??= { x: 0, y: 0 });
let tx = 0;
let ty = 0;
if (p.active) {
const dx = p.x - box.cx;
const dy = p.y - box.cy;
const dist = Math.hypot(dx, dy);
if (dist < reach) {
// Smoothstep falloff: full pull at the center, none at the edge of the field.
const t = 1 - dist / reach;
const pull = t * t * (3 - 2 * t) * max * sign;
tx = (dx / (dist || 1)) * pull;
ty = (dy / (dist || 1)) * pull;
}
}
o.x = damp(o.x, tx, 14, dt);
o.y = damp(o.y, ty, 14, dt);
box.el.style.transform = `translate(${o.x.toFixed(2)}px, ${o.y.toFixed(2)}px)`;
});
},
{ ref: root, enabled: !reduced },
);
if (reduced) {
return (
{text}
);
}
return (
{text}
{words.map((word, w) => (
{word.chars.map((char, c) => (
{char}
))}
{w < words.length - 1 ? " " : null}
))}
);
}
export default MagneticText;
```
Full library index: https://textlab.dev/llms.txt
---
# Variable proximity
> Weight and width swell near the cursor.
- Type: Component (cursor)
- Page: https://textlab.dev/components/variable-proximity
- Markdown: https://textlab.dev/components/variable-proximity.md
- Registry item: https://textlab.dev/r/variable-proximity.json
- Source file: `registry/textlab/variable-proximity/variable-proximity.tsx`
- Dependencies: none
- Variants: weight, width, slant
- Keywords: variable font, font weight, proximity, cursor, wght, wdth, roboto flex
## When to use
- Type foundries, design tools and editorial heroes.
- Showing off a variable font.
## When not to use
- Fonts without the axis; nothing will change.
- Layouts that cannot tolerate letters changing width.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/variable-proximity
pnpm dlx shadcn@latest add @textlab/variable-proximity
```
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/variable-proximity.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 { VariableProximity } from "@/components/textlab/variable-proximity/variable-proximity";
export function Example() {
return ;
}
```
## API reference
### VariableProximity
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"weight" \| "width" \| "slant"` | `"weight"` | Which variable axis responds. |
| `from` | `number` | `axis default` | Axis value far from the cursor. |
| `to` | `number` | `axis default` | Axis value under the cursor. |
| `radius` | `number` | `3` | Reach in em. |
| `font` | `string` | `var(--tl-font-flex)` | A variable font with the axis. Roboto Flex has weight, width and slant. |
| `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
- Letters are aria-hidden; the text is read once.
- Weight and width change the letters' footprint, so neighbours move slightly; keep it to headlines.
## Motion
- Each letter eases toward its own target with damping.
- Updates stop once every letter has settled.
## Reduced motion
Static text at the resting axis value.
## Performance
- Writes font-variation-settings only while values change; text reflow is limited to the line.
## 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: weight, width, slant.
## Related
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
## Also in cursor
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
## Source
### components/textlab/variable-proximity/variable-proximity.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { damp } from "../lib/math";
import { splitWords } from "../lib/split";
import { useFrame } from "../lib/use-frame";
import { useLetterBoxes } from "../lib/use-letter-boxes";
import { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type VariableProximityVariant = "weight" | "width" | "slant";
export interface VariableProximityProps {
text: string;
/** Which axis swells near the cursor. */
variant?: VariableProximityVariant;
/** Axis value far from the cursor and right under it. */
from?: number;
to?: number;
/** Reach in em. */
radius?: number;
/** Variable font family. Needs the axis you animate. Defaults to the --tl-font-flex token. */
font?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const AXIS: Record = {
weight: { tag: "wght", from: 300, to: 900 },
width: { tag: "wdth", from: 75, to: 151 },
slant: { tag: "slnt", from: 0, to: -10 },
};
/**
* Weight, width or slant swell as the cursor comes near. Each letter reads its own distance, so a
* bulge of heavier type follows the pointer across the word.
*/
export function VariableProximity({ text, variant = "weight", from, to, radius = 3, font = "var(--tl-font-flex)", as: Tag = "span", reducedMotion = "user", className, style }: VariableProximityProps) {
const root = useRef(null);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const { boxes, em } = useLetterBoxes(root, [text, variant]);
const values = useRef([]);
const axis = AXIS[variant];
const low = from ?? axis.from;
const high = to ?? axis.to;
const words = splitWords(text);
useFrame(
(_, dt) => {
const p = pointer.current;
const reach = radius * em.current;
boxes.current.forEach((box, i) => {
let target = low;
if (p.active) {
const dist = Math.hypot(p.x - box.cx, p.y - box.cy);
const t = Math.max(0, 1 - dist / reach);
target = low + (high - low) * t * t * (3 - 2 * t);
}
const prev = values.current[i] ?? low;
const next = damp(prev, target, 12, dt);
if (Math.abs(next - prev) < 0.05 && Math.abs(next - target) < 0.05) return;
values.current[i] = next;
box.el.style.fontVariationSettings = `"${axis.tag}" ${next.toFixed(1)}`;
});
},
{ ref: root, enabled: !reduced },
);
const base: CSSProperties = { fontFamily: font, fontVariationSettings: `"${axis.tag}" ${low}`, ...style };
if (reduced) {
return (
{text}
);
}
return (
{text}
{words.map((word, w) => (
{word.chars.map((char, c) => (
{char}
))}
{w < words.length - 1 ? " " : null}
))}
);
}
export default VariableProximity;
```
Full library index: https://textlab.dev/llms.txt
---
# Cursor trail
> A word trails behind the cursor letter by letter.
- Type: Component (cursor)
- Page: https://textlab.dev/components/cursor-trail
- Markdown: https://textlab.dev/components/cursor-trail.md
- Registry item: https://textlab.dev/r/cursor-trail.json
- Source file: `registry/textlab/cursor-trail/cursor-trail.tsx`
- Dependencies: none
- Variants: chain, scatter
- Keywords: cursor trail, mouse trail, follow cursor, snake, interactive
## When to use
- Playful hero areas and 404 pages.
- Portfolio sections that invite exploring.
## When not to use
- Over content people need to read or click; the trail sits on top.
- Touch-only experiences.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/cursor-trail
pnpm dlx shadcn@latest add @textlab/cursor-trail
```
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/cursor-trail.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 { CursorTrail } from "@/components/textlab/cursor-trail/cursor-trail";
export function Example() {
return
Move your mouse here
;
}
```
## API reference
### CursorTrail
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"chain" \| "scatter"` | `"chain"` | String letters along your path, or let each chase the cursor. |
| `children` | `ReactNode` | – | Content of the area the trail lives in. |
| `spacing` | `number` | `0.62` | Distance between letters on the chain, in em. |
| `trailClassName` | `string` | – | Class for the trailing letters. |
| `as` | `ElementType` | `"div"` | 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 trail is decorative and aria-hidden; it never blocks clicks.
- Content inside the area works normally.
## Motion
- Chain letters hold a fixed distance and turn to follow the path.
- Letters fade in and out with the pointer instead of popping.
## Reduced motion
No trail; the area renders its content only.
## Performance
- One frame loop that writes transforms; pauses when offscreen.
## 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: chain, scatter.
## Related
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
- [Path text](https://textlab.dev/components/path-text.md): Text travels along a wave, circle or spiral.
- [Spotlight text](https://textlab.dev/components/spotlight-text.md): A light follows the cursor and reveals the copy.
## Also in cursor
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
## Source
### components/textlab/cursor-trail/cursor-trail.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType, type ReactNode } from "react";
import { damp, hashString, seeded } from "../lib/math";
import { graphemes } from "../lib/split";
import { useFrame } from "../lib/use-frame";
import { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type CursorTrailVariant = "chain" | "scatter";
export interface CursorTrailProps {
/** The word that follows the cursor. */
text: string;
/** chain strings the letters along your path; scatter lets each letter chase the cursor on its own. */
variant?: CursorTrailVariant;
/** The area that the trail lives in. */
children?: ReactNode;
/** Letter spacing along the chain, in em. */
spacing?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
/** Class for the trailing letters, for example a font or color. */
trailClassName?: string;
}
/**
* A word trails behind the cursor letter by letter. In chain mode each letter follows the one in
* front of it, so the word bends along the path you draw.
*/
export function CursorTrail({ text, variant = "chain", children, spacing = 0.62, as: Tag = "div", reducedMotion = "user", className, style, trailClassName }: CursorTrailProps) {
const root = useRef(null);
const letters = useRef<(HTMLSpanElement | null)[]>([]);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const chars = graphemes(text);
const state = useRef(chars.map(() => ({ x: 0, y: 0, a: 0, o: 0 })));
const rand = seeded(hashString(text));
const emRef = useRef(0);
const jitter = useRef(chars.map(() => ({ x: (rand() - 0.5) * 2, y: (rand() - 0.5) * 2, r: (rand() - 0.5) * 50, rate: 5 + rand() * 7 })));
// Smoothed pointer velocity in px/s: scatter flings the letters apart in proportion to it.
const motionState = useRef({ x: 0, y: 0, vx: 0, vy: 0, seen: false });
useFrame(
(_, dt) => {
const p = pointer.current;
const node = root.current;
if (!node) return;
// Measure the letter height once; reading layout every frame would force style recalcs.
if (!emRef.current) emRef.current = letters.current[0]?.offsetHeight || 16;
const em = emRef.current;
const show = p.active && p.inside ? 1 : 0;
const m = motionState.current;
if (!show) {
m.seen = false;
m.vx = m.vy = 0;
} else if (dt > 0 && m.seen) {
m.vx = damp(m.vx, (p.x - m.x) / dt, 10, dt);
m.vy = damp(m.vy, (p.y - m.y) / dt, 10, dt);
}
m.x = p.x;
m.y = p.y;
if (show) m.seen = true;
// 0 when the cursor rests, 1 when it moves fast.
const fling = Math.min(Math.hypot(m.vx, m.vy) / 1200, 1);
state.current.forEach((s, i) => {
const el = letters.current[i];
if (!el) return;
// A hidden letter jumps to the pointer, so the trail never flies in from a corner.
if (s.o < 0.02 && show) {
s.x = p.x;
s.y = p.y;
}
let tx = p.x;
let ty = p.y;
let ta = 0;
if (variant === "chain") {
if (i > 0) {
const prev = state.current[i - 1];
const dx = s.x - prev.x;
const dy = s.y - prev.y;
const d = Math.hypot(dx, dy) || 1;
// Hold a fixed distance behind the letter in front, pointing along the path.
tx = prev.x + (dx / d) * spacing * em * 0.9;
ty = prev.y + (dy / d) * spacing * em * 0.9;
ta = (Math.atan2(-dy, -dx) * 180) / Math.PI;
} else {
tx = p.x + spacing * em * 0.6;
}
s.x = i === 0 ? damp(s.x, tx, 18, dt) : damp(s.x, tx, 30, dt);
s.y = i === 0 ? damp(s.y, ty, 18, dt) : damp(s.y, ty, 30, dt);
s.a = damp(s.a, Math.abs(ta) > 90 ? ta - 180 * Math.sign(ta) : ta, 10, dt);
} else {
const j = jitter.current[i];
// At rest the letters settle into the word beside the cursor; moving scatters them, each
// drifting by its own amount and catching up at its own pace, then they regroup.
tx = p.x + (i + 0.9) * spacing * em + j.x * em * 1.6 * fling;
ty = p.y + j.y * em * 1.6 * fling;
s.x = damp(s.x, tx, j.rate, dt);
s.y = damp(s.y, ty, j.rate, dt);
s.a = damp(s.a, j.r * fling, 6, dt);
}
s.o = damp(s.o, show, 8, dt);
el.style.transform = `translate(${s.x.toFixed(1)}px, ${s.y.toFixed(1)}px) translate(-50%, -50%) rotate(${s.a.toFixed(1)}deg)`;
el.style.opacity = s.o.toFixed(3);
});
},
{ ref: root, enabled: !reduced },
);
return (
{children}
{!reduced && (
{chars.map((char, i) => (
{
letters.current[i] = el;
}}
className={trailClassName}
style={{ position: "absolute", left: 0, top: 0, opacity: 0, whiteSpace: "pre", willChange: "transform" }}
>
{char}
))}
)}
);
}
export default CursorTrail;
```
Full library index: https://textlab.dev/llms.txt
---
# Flip roll
> Hover and the label rolls up into a fresh copy.
- Type: Component (cursor)
- Page: https://textlab.dev/components/flip-roll
- Markdown: https://textlab.dev/components/flip-roll.md
- Registry item: https://textlab.dev/r/flip-roll.json
- Source file: `registry/textlab/flip-roll/flip-roll.tsx`
- Dependencies: none
- Variants: stagger, up, down
- Keywords: roll, flip, hover, button text, nav link, text swap
## When to use
- Navigation links, buttons and footer links.
- Any short label that deserves a tactile hover.
## When not to use
- Long labels; the stagger tail drags.
- Inline links in body copy.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/flip-roll
pnpm dlx shadcn@latest add @textlab/flip-roll
```
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/flip-roll.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 { FlipRoll } from "@/components/textlab/flip-roll/flip-roll";
export function Example() {
return ;
}
```
## API reference
### FlipRoll
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"stagger" \| "up" \| "down"` | `"stagger"` | Roll letter by letter, or the whole label up or down. |
| `duration` | `number` | `0.6` | Seconds per roll. |
| `stagger` | `number` | `0.02` | Seconds between letters. |
| `ease` | `EaseName \| [number, number, number, number]` | `"emphasized"` | Easing token or cubic bezier. |
| `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
- Keyboard focus inside triggers it, through focus capture.
- Screen readers read the label once.
## Motion
- A long-tailed ease over 0.6s with a cross-fade, so it starts quick and lands soft.
- Leaving reverses immediately, interrupting cleanly.
## Reduced motion
Hover changes nothing.
## Performance
- CSS transitions on transform 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: stagger, up, down.
## Related
- [Hover scramble](https://textlab.dev/components/hover-scramble.md): Scrambles on hover, settles on leave.
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
## Also in cursor
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
## Source
### components/textlab/flip-roll/flip-roll.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { cssEase, type EaseInput } from "../lib/motion-tokens";
import { graphemes } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type FlipRollVariant = "up" | "down" | "stagger";
export interface FlipRollProps {
text: string;
/** up rolls the label up, down rolls it down, stagger rolls letter by letter. */
variant?: FlipRollVariant;
/** Seconds per roll. */
duration?: number;
/** Seconds between letters for the stagger variant. */
stagger?: number;
ease?: EaseInput;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Hover and the label rolls up while a fresh copy rolls in behind it, like a drum turning one
* notch. Also follows keyboard focus inside the element.
*/
export function FlipRoll({ text, variant = "stagger", duration = 0.6, stagger = 0.02, ease = "emphasized", as: Tag = "span", reducedMotion = "user", className, style }: FlipRollProps) {
const { ref, on, bind } = useTrigger({ trigger: "hover" });
const reduced = useReduced(reducedMotion);
const chars = variant === "stagger" ? graphemes(text) : [text];
const dir = variant === "down" ? -1 : 1;
const flipped = on && !reduced;
return (
{text}
{/* The clip gets a little room above and below so descenders never touch its edge mid-roll. */}
{chars.map((char, i) => {
const delay = variant === "stagger" ? i * stagger : 0;
// Opacity eases a touch faster than the roll, so each copy is mostly gone before it reaches the edge.
const t = `transform ${duration}s ${cssEase(ease)} ${delay}s, opacity ${duration * 0.7}s ${cssEase(ease)} ${delay}s`;
const shift = (sign: number) => `translateY(calc(${sign * 100}% + ${sign * 0.2}em))`;
return (
{char}{char}
);
})}
);
}
export default FlipRoll;
```
Full library index: https://textlab.dev/llms.txt
---
# Tilt text
> Extruded type tilts toward the pointer.
- Type: Component (cursor)
- Page: https://textlab.dev/components/tilt-text
- Markdown: https://textlab.dev/components/tilt-text.md
- Registry item: https://textlab.dev/r/tilt-text.json
- Source file: `registry/textlab/tilt-text/tilt-text.tsx`
- Dependencies: none
- Variants: extrude, float, shadow
- Keywords: 3d text, tilt, extrude, perspective, parallax, hover
## When to use
- Bold single words in hero sections.
- Product names and numbers with a physical feel.
## When not to use
- Long or wrapping text.
- Dense layouts; the tilt needs breathing room.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/tilt-text
pnpm dlx shadcn@latest add @textlab/tilt-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/tilt-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 { TiltText } from "@/components/textlab/tilt-text/tilt-text";
export function Example() {
return ;
}
```
## API reference
### TiltText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"extrude" \| "float" \| "shadow"` | `"extrude"` | Solid depth, floating above a ground shadow, or a long pointer-driven shadow. |
| `tilt` | `number` | `18` | Largest tilt in degrees. |
| `depth` | `number` | `12` | Extrusion layers. |
| `sideColor` | `string` | `a darker mix of the text color` | Color of the extruded sides. |
| `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
- Depth layers are aria-hidden copies; the word is read once.
- At rest the text faces forward and reads normally.
## Motion
- The tilt eases toward the pointer and back to center when it leaves.
## Reduced motion
Still, front-facing text with its depth intact.
## Performance
- Transforms on one 3D group; layers are static inside it.
## 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: extrude, float, shadow.
## Related
- [Extrude text](https://textlab.dev/components/extrude-text.md): Depth grows from the letters on hover.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
- [Glass text](https://textlab.dev/components/glass-text.md): Glass letters that bend the scene behind them.
## Also in cursor
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
## Source
### components/textlab/tilt-text/tilt-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { srOnly } from "../lib/a11y";
import { damp } from "../lib/math";
import { useFrame } from "../lib/use-frame";
import { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type TiltTextVariant = "extrude" | "float" | "shadow";
export interface TiltTextProps {
text: string;
/** extrude gives the letters solid depth, float lifts them off a soft ground shadow, shadow casts a long light-driven shadow. */
variant?: TiltTextVariant;
/** Largest tilt in degrees. */
tilt?: number;
/** Extrusion layers for the extrude variant. */
depth?: number;
/** Side color of the extrusion. */
sideColor?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
// Rest slightly turned, so the depth reads even before anyone points at it.
const REST = { x: 0.7, y: 0.3 };
/**
* Extruded type tilts toward the pointer. The extrusion is built from stacked copies pushed back in
* real 3D space, so the sides turn with the face like a solid object.
*/
export function TiltText({ text, variant = "extrude", tilt = 18, depth = 12, sideColor = "color-mix(in oklab, currentColor 45%, var(--tl-background, #000))", as: Tag = "span", reducedMotion = "user", className, style }: TiltTextProps) {
const root = useRef(null);
const group = useRef(null);
const shadow = useRef(null);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const pos = useRef({ ...REST });
const rotate = (x: number, y: number) => `rotateX(${((y - 0.5) * -tilt).toFixed(2)}deg) rotateY(${((x - 0.5) * tilt * 1.4).toFixed(2)}deg)`;
useFrame(
(_, dt) => {
const p = pointer.current;
const tx = p.active && p.inside ? p.nx : REST.x;
const ty = p.active && p.inside ? p.ny : REST.y;
const before = pos.current;
if (Math.abs(before.x - tx) < 0.0005 && Math.abs(before.y - ty) < 0.0005) return;
pos.current = { x: damp(before.x, tx, 7, dt), y: damp(before.y, ty, 7, dt) };
// Transforms are written to the elements themselves, never through inherited variables.
if (group.current) group.current.style.transform = rotate(pos.current.x, pos.current.y);
if (shadow.current) shadow.current.style.transform = `translate(${((0.5 - pos.current.x) * 0.4).toFixed(3)}em, ${((0.5 - pos.current.y) * 0.4 + 0.06).toFixed(3)}em) translateZ(-0.3em)`;
},
{ ref: root, enabled: !reduced },
);
return (
{text}
{variant === "extrude" &&
Array.from({ length: depth }, (_, i) => (
{text}
))}
{variant === "float" && (
{text}
)}
{variant === "shadow" && (
{text}
)}
{text}
);
}
export default TiltText;
```
Full library index: https://textlab.dev/llms.txt
---
# Extrude text
> Depth grows from the letters on hover.
- Type: Component (cursor)
- Page: https://textlab.dev/components/extrude-text
- Markdown: https://textlab.dev/components/extrude-text.md
- Registry item: https://textlab.dev/r/extrude-text.json
- Source file: `registry/textlab/extrude-text/extrude-text.tsx`
- Dependencies: none
- Variants: block, long-shadow, stack
- Keywords: 3d text, extrude, long shadow, retro, text shadow, block letters
## When to use
- Retro, poster and toy-like brands.
- Big words that should feel pressable.
## When not to use
- Small text; the depth smears it.
- Minimal, quiet interfaces.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/extrude-text
pnpm dlx shadcn@latest add @textlab/extrude-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/extrude-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 { ExtrudeText } from "@/components/textlab/extrude-text/extrude-text";
export function Example() {
return ;
}
```
## API reference
### ExtrudeText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"block" \| "long-shadow" \| "stack"` | `"block"` | A solid slab, a fading diagonal shadow, or stacked outlines. |
| `depth` | `number` | `14` | Depth steps at full extrusion. |
| `color` | `string` | `var(--tl-accent)` | Depth color. |
| `hover` | `boolean` | `true` | Grow on hover from a shallow rest; false keeps it fully extruded. |
| `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
- Plain text with shadows; nothing is split.
- Focus-visible grows it too when the element is focusable.
## Motion
- The face lifts up and left as depth grows, so it feels like it rises off the page.
- 260 ms with the enter ease; hover only counts on devices with a fine pointer, so taps never leave it stuck raised.
## Reduced motion
Fully extruded and still.
## Performance
- Pure CSS: a registered property interpolates the shadow stack.
## 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: block, long-shadow, stack.
## Related
- [Tilt text](https://textlab.dev/components/tilt-text.md): Extruded type tilts toward the pointer.
- [Depth layers](https://textlab.dev/components/depth-layers.md): Outline copies drift apart in depth as you scroll.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
## Also in cursor
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Flip roll](https://textlab.dev/components/flip-roll.md): Hover and the label rolls up into a fresh copy.
## Source
### components/textlab/extrude-text/extrude-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type ExtrudeTextVariant = "block" | "long-shadow" | "stack";
export interface ExtrudeTextProps {
text: string;
/** block builds a solid slab, long-shadow casts a fading diagonal, stack offsets outlined copies. */
variant?: ExtrudeTextVariant;
/** Number of depth steps at full extrusion. */
depth?: number;
/** Depth color. */
color?: string;
/** Hover grows the depth from flat; false keeps it extruded. */
hover?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CSS = `
@property --tl-depth { syntax: ""; inherits: true; initial-value: 0; }
.tl-extrude { --tl-depth: var(--tl-depth-rest); transition: --tl-depth 260ms var(--tl-ease-enter, cubic-bezier(0.23, 1, 0.32, 1)), transform 260ms var(--tl-ease-enter, cubic-bezier(0.23, 1, 0.32, 1)); }
.tl-extrude[data-hover]:focus-visible { --tl-depth: 1; }
@media (hover: hover) and (pointer: fine) { .tl-extrude[data-hover]:hover { --tl-depth: 1; } }
@media (prefers-reduced-motion: reduce) { .tl-extrude[data-motion="user"] { transition: none; } }
`;
function layers(variant: ExtrudeTextVariant, depth: number, color: string) {
return Array.from({ length: depth }, (_, i) => {
const n = i + 1;
const off = `calc(var(--tl-depth) * ${(n * 0.012).toFixed(3)}em)`;
if (variant === "long-shadow") {
return `${off} ${off} 0 color-mix(in oklab, ${color} ${Math.round(100 - (n / depth) * 85)}%, transparent)`;
}
if (variant === "stack") {
// Every third step draws a ring, which reads as stacked outlines.
return n % 3 === 0 ? `${off} ${off} 0 ${color}` : `${off} ${off} 0 transparent`;
}
return `${off} ${off} 0 ${color}`;
}).join(", ");
}
/**
* Depth grows from the letters on hover. The extrusion is a registered CSS property, so the browser
* interpolates it smoothly with no JavaScript at all.
*/
export function ExtrudeText({ text, variant = "block", depth = 14, color = "var(--tl-accent, #3a9a5b)", hover = true, as: Tag = "span", reducedMotion = "user", className, style }: ExtrudeTextProps) {
const reduced = useReduced(reducedMotion);
const rest = hover && !reduced ? 0.25 : 1;
return (
{text}
);
}
export default ExtrudeText;
```
Full library index: https://textlab.dev/llms.txt
---
# Letter drop
> Letters fall, bounce and settle on the floor.
- Type: Component (physics)
- Page: https://textlab.dev/components/letter-drop
- Markdown: https://textlab.dev/components/letter-drop.md
- Registry item: https://textlab.dev/r/letter-drop.json
- Source file: `registry/textlab/letter-drop/letter-drop.tsx`
- Dependencies: none
- Variants: drop, pile, toss
- Keywords: physics, gravity, bounce, drop, falling letters, toss
## When to use
- Playful launches, games and kids' products.
- A single word that should land with weight.
## When not to use
- Serious contexts.
- Long sentences; each letter is a body.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/letter-drop
pnpm dlx shadcn@latest add @textlab/letter-drop
```
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/letter-drop.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 { LetterDrop } from "@/components/textlab/letter-drop/letter-drop";
export function Example() {
return ;
}
```
## API reference
### LetterDrop
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"drop" \| "pile" \| "toss"` | `"drop"` | Fall straight and bounce, tumble and land askew, or also fling up on click or tap. |
| `height` | `number` | `2.2` | Fall height in em. |
| `restitution` | `number` | `0.45` | Bounciness, 0 to 0.9. |
| `as` | `ElementType` | `"span"` | Rendered element. |
| `trigger` | `"mount" \| "inView" \| "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. |
| `stagger` | `number` | `0.06` | Seconds between parts. |
| `reducedMotion` | `"user" \| "always" \| "never"` | `"user"` | user follows the OS setting; always shows the still version. |
| `className` | `string` | – | Merged onto the root. |
## Accessibility
- Letters are aria-hidden; the word is read once.
- Toss works with click and tap; it is a bonus, never needed to read the word.
## Motion
- Real gravity with energy lost on every bounce.
- Pile letters spin as they fall and settle up to 8 degrees off true.
## Reduced motion
The word sits on the baseline from the start.
## Performance
- One frame loop that stops once every letter has settled.
## 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: drop, pile, toss.
## Related
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Character reveal](https://textlab.dev/components/char-reveal.md): Letters arrive one at a time.
## Also in physics
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
## Source
### components/textlab/letter-drop/letter-drop.tsx
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { hashString, seeded } from "../lib/math";
import { graphemes } from "../lib/split";
import { useFrame } from "../lib/use-frame";
import { useHydrated } from "../lib/use-hydrated";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger, type Trigger } from "../lib/use-trigger";
export type LetterDropVariant = "drop" | "pile" | "toss";
export interface LetterDropProps {
text: string;
/** drop falls straight and bounces, pile tumbles and lands askew, toss also flings letters up on click or tap. */
variant?: LetterDropVariant;
/** Fall height in em. */
height?: number;
/** Bounciness, 0 to 0.9. */
restitution?: number;
as?: ElementType;
trigger?: Exclude;
active?: boolean;
once?: boolean;
/** Seconds between letters starting to fall. */
stagger?: number;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
type Body = { y: number; v: number; r: number; vr: number; start: number; restR: number; settled: boolean };
/**
* Letters fall, bounce and settle on the baseline under real gravity. Each letter is its own body
* with a bounce that loses energy, and pile mode lets them tumble and land a little crooked.
*/
export function LetterDrop({ text, variant = "drop", height = 2.2, restitution = 0.45, as: Tag = "span", trigger = "inView", active, once = true, stagger = 0.06, reducedMotion = "user", className, style }: LetterDropProps) {
const { ref, on, bind } = useTrigger({ trigger, active, once });
const hydrated = useHydrated();
const reduced = useReduced(reducedMotion);
const count = graphemes(text.replace(/\s+/g, "")).length;
const bodies = useRef([]);
const els = useRef([]);
const clock = useRef(0);
const running = useRef(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
els.current = Array.from(node.querySelectorAll("[data-tl-char]"));
}, [ref, text]);
useEffect(() => {
if (reduced) return;
const rand = seeded(hashString(text));
bodies.current = Array.from({ length: count }, (_, i) => ({
y: -height,
v: 0,
r: variant === "pile" ? (rand() - 0.5) * 120 : 0,
vr: 0,
start: i * stagger + rand() * stagger,
restR: variant === "pile" ? (rand() - 0.5) * 16 : 0,
settled: false,
}));
clock.current = 0;
running.current = on;
}, [on, reduced, text, count, height, stagger, variant]);
const toss = () => {
if (variant !== "toss" || reduced) return;
const rand = seeded(Date.now() >>> 0);
bodies.current.forEach((b) => {
b.v = -(6 + rand() * 5);
b.vr = (rand() - 0.5) * 900;
b.settled = false;
b.start = 0;
});
clock.current = 0;
running.current = true;
};
useFrame(
(_, dt) => {
if (!running.current) return;
clock.current += dt;
const gravity = 38;
let moving = false;
bodies.current.forEach((b, i) => {
const el = els.current[i];
if (!el) return;
if (clock.current < b.start || b.settled) {
if (!b.settled) moving = true;
el.style.transform = `translateY(${b.y}em) rotate(${b.r}deg)`;
el.style.opacity = clock.current < b.start && b.y <= -height ? "0" : "1";
return;
}
moving = true;
b.v += gravity * dt;
b.y += b.v * dt;
b.r += b.vr * dt;
if (b.y >= 0) {
b.y = 0;
b.v = -b.v * restitution;
b.vr = (b.restR - b.r) * 4 + b.vr * -0.3;
if (Math.abs(b.v) < 0.8) {
b.v = 0;
b.settled = true;
b.r = b.restR;
}
}
el.style.opacity = "1";
el.style.transform = `translateY(${b.y.toFixed(3)}em) rotate(${b.r.toFixed(1)}deg)`;
});
if (!moving) running.current = false;
},
{ ref, enabled: !reduced },
);
if (reduced) {
return (
{text}
);
}
return (
{text}
);
}
export default LetterDrop;
```
Full library index: https://textlab.dev/llms.txt
---
# Elastic text
> Drag a letter and the word stretches like rubber.
- Type: Component (physics)
- Page: https://textlab.dev/components/elastic-text
- Markdown: https://textlab.dev/components/elastic-text.md
- Registry item: https://textlab.dev/r/elastic-text.json
- Source file: `registry/textlab/elastic-text/elastic-text.tsx`
- Dependencies: none
- Variants: band, sling
- Keywords: elastic, rubber band, drag, stretch, spring, physics
## When to use
- Toy-like hero moments and interactive posters.
- Touch devices, where dragging feels natural.
## When not to use
- Text inside scrolling lists; dragging captures the pointer.
- Anything people should select and copy.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/elastic-text
pnpm dlx shadcn@latest add @textlab/elastic-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/elastic-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 { ElasticText } from "@/components/textlab/elastic-text/elastic-text";
export function Example() {
return ;
}
```
## API reference
### ElasticText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"band" \| "sling"` | `"band"` | Stretch and ease back, or snap back hard and overshoot. |
| `reach` | `number` | `3` | How many neighbours follow a pulled letter. |
| `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
- Letters are aria-hidden; the word is read once.
- Dragging is decorative; nothing depends on it.
## Motion
- Pull resistance grows with distance, like rubber.
- Neighbours follow with exponential falloff; release springs them home.
## Reduced motion
Plain text; dragging does nothing.
## Performance
- Springs update only while something 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: band, sling.
## Related
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Scroll stretch](https://textlab.dev/components/scroll-stretch.md): Letters stretch tall as you scroll fast.
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
## Also in physics
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
## Source
### components/textlab/elastic-text/elastic-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType, type PointerEvent } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { atRest, stepSpring, type Spring1D } from "../lib/physics";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type ElasticTextVariant = "band" | "sling";
export interface ElasticTextProps {
text: string;
/** band stretches and eases back; sling snaps back hard and overshoots like a released slingshot. */
variant?: ElasticTextVariant;
/** How many neighbours come along when you pull a letter. */
reach?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Drag a letter and the word stretches like rubber. Neighbours follow with falloff, and letting go
* springs everything home. Works with mouse, pen and touch.
*/
export function ElasticText({ text, variant = "band", reach = 3, as: Tag = "span", reducedMotion = "user", className, style }: ElasticTextProps) {
const root = useRef(null);
const reduced = useReduced(reducedMotion);
const springs = useRef<{ x: Spring1D; y: Spring1D }[]>([]);
const drag = useRef<{ index: number; startX: number; startY: number; dx: number; dy: number } | null>(null);
const live = useRef(false);
const elements = () => Array.from(root.current?.querySelectorAll("[data-tl-char]") ?? []);
const onDown = (event: PointerEvent, index: number) => {
if (reduced) return;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
const count = elements().length;
for (let i = 0; i < count; i++) springs.current[i] ??= { x: { x: 0, v: 0 }, y: { x: 0, v: 0 } };
drag.current = { index, startX: event.clientX, startY: event.clientY, dx: 0, dy: 0 };
live.current = true;
};
const onMove = (event: PointerEvent) => {
const d = drag.current;
if (!d) return;
// Resistance grows with distance, like rubber that gets harder to pull.
const rawX = event.clientX - d.startX;
const rawY = event.clientY - d.startY;
const dist = Math.hypot(rawX, rawY) || 1;
const eased = 140 * Math.tanh(dist / 140);
d.dx = (rawX / dist) * eased;
d.dy = (rawY / dist) * eased;
};
const onUp = () => {
const d = drag.current;
if (!d) return;
if (variant === "sling") {
springs.current.forEach((s) => {
s.x.v += -s.x.x * 6;
s.y.v += -s.y.x * 6;
});
}
drag.current = null;
};
useFrame(
(_, dt) => {
if (!live.current) return;
const els = elements();
const d = drag.current;
let moving = !!d;
springs.current.forEach((s, i) => {
if (!s) return;
const fall = d ? Math.exp(-Math.abs(i - d.index) / Math.max(0.5, reach / 2)) : 0;
const tx = d ? d.dx * fall : 0;
const ty = d ? d.dy * fall : 0;
const stiffness = d ? 900 : variant === "sling" ? 520 : 300;
const damping = d ? 60 : variant === "sling" ? 9 : 16;
stepSpring(s.x, tx, stiffness, damping, dt);
stepSpring(s.y, ty, stiffness, damping, dt);
if (!atRest(s.x, tx, 0.05) || !atRest(s.y, ty, 0.05)) moving = true;
const el = els[i];
if (!el) return;
// Letters stretch along the pull, which sells the rubber.
const len = Math.hypot(s.x.x, s.y.x);
const angle = Math.atan2(s.y.x, s.x.x);
const stretch = 1 + Math.min(len / 220, 0.45);
el.style.transform = `translate(${s.x.x.toFixed(2)}px, ${s.y.x.toFixed(2)}px) rotate(${angle}rad) scaleX(${stretch.toFixed(3)}) rotate(${-angle}rad)`;
});
live.current = moving;
},
{ ref: root, enabled: !reduced },
);
if (reduced) {
return (
{text}
);
}
return (
{text} (
onDown(e, i)} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}>
{char}
)}
/>
);
}
export default ElasticText;
```
Full library index: https://textlab.dev/llms.txt
---
# Jelly text
> Letters wobble like jelly when touched.
- Type: Component (physics)
- Page: https://textlab.dev/components/jelly-text
- Markdown: https://textlab.dev/components/jelly-text.md
- Registry item: https://textlab.dev/r/jelly-text.json
- Source file: `registry/textlab/jelly-text/jelly-text.tsx`
- Dependencies: none
- Variants: squash, wobble
- Keywords: jelly, wobble, squash and stretch, springy, hover, bouncy
## When to use
- Playful brands, games and empty states.
- Words that invite poking, on mouse or touch.
## When not to use
- Interactive controls; letters are not buttons.
- Body text.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/jelly-text
pnpm dlx shadcn@latest add @textlab/jelly-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/jelly-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 { JellyText } from "@/components/textlab/jelly-text/jelly-text";
export function Example() {
return ;
}
```
## API reference
### JellyText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"squash" \| "wobble"` | `"squash"` | Squish flat and spring tall, or rock side to side. |
| `strength` | `number` | `1` | How hard a touch kicks the letter. |
| `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
- Letters are aria-hidden; the word is read once.
- Works with touch through pointer down.
## Motion
- Underdamped springs ring out over about a second.
- Neighbours get a smaller kick so the wobble spreads.
## Reduced motion
Touch does nothing; plain text.
## Performance
- The frame loop runs only while a spring 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: squash, wobble.
## Related
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Magnetic text](https://textlab.dev/components/magnetic-text.md): Letters lean toward the cursor and spring back.
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
## Also in physics
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
## Source
### components/textlab/jelly-text/jelly-text.tsx
```tsx
"use client";
import { useRef, type CSSProperties, type ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { atRest, stepSpring, type Spring1D } from "../lib/physics";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type JellyTextVariant = "wobble" | "squash";
export interface JellyTextProps {
text: string;
/** wobble rocks letters side to side, squash squishes them flat then springs them tall. */
variant?: JellyTextVariant;
/** How hard a touch kicks the letter. */
strength?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Letters wobble like jelly when touched. Every letter is an underdamped spring; brushing one
* kicks it, and its neighbours feel a smaller kick.
*/
export function JellyText({ text, variant = "squash", strength = 1, as: Tag = "span", reducedMotion = "user", className, style }: JellyTextProps) {
const root = useRef(null);
const reduced = useReduced(reducedMotion);
const springs = useRef([]);
const live = useRef(false);
const kick = (index: number) => {
if (reduced) return;
const els = root.current?.querySelectorAll("[data-tl-char]").length ?? 0;
for (let i = 0; i < els; i++) springs.current[i] ??= { x: 0, v: 0 };
[-1, 0, 1].forEach((d) => {
const s = springs.current[index + d];
if (s) s.v += (d === 0 ? 9 : 4) * strength * (variant === "wobble" ? 1.4 : 1);
});
live.current = true;
};
useFrame(
(_, dt) => {
if (!live.current) return;
const els = root.current?.querySelectorAll("[data-tl-char]");
if (!els) return;
let moving = false;
springs.current.forEach((s, i) => {
if (!s) return;
stepSpring(s, 0, 260, 7, dt);
if (!atRest(s, 0, 0.002)) moving = true;
const el = els[i];
if (!el) return;
el.style.transform =
variant === "wobble" ? `rotate(${(s.x * 18).toFixed(2)}deg)` : `scale(${(1 + s.x * 0.35).toFixed(3)}, ${(1 - s.x * 0.35).toFixed(3)})`;
});
live.current = moving;
},
{ ref: root, enabled: !reduced },
);
if (reduced) {
return (
{text}
);
}
return (
{text} (
kick(i)} onPointerDown={() => kick(i)}>
{char}
)}
/>
);
}
export default JellyText;
```
Full library index: https://textlab.dev/llms.txt
---
# Wave text
> A sine wave runs through the line.
- Type: Component (physics)
- Page: https://textlab.dev/components/wave-text
- Markdown: https://textlab.dev/components/wave-text.md
- Registry item: https://textlab.dev/r/wave-text.json
- Source file: `registry/textlab/wave-text/wave-text.tsx`
- Dependencies: none
- Variants: wave, bounce, float
- Keywords: wave, bounce, wavy text, sine, float, loop
## When to use
- Loading states, celebrations and playful labels.
- Short words that should feel alive.
## When not to use
- Anything people must read carefully.
- Several looping effects on the same screen.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/wave-text
pnpm dlx shadcn@latest add @textlab/wave-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/wave-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 { WaveText } from "@/components/textlab/wave-text/wave-text";
export function Example() {
return ;
}
```
## API reference
### WaveText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"wave" \| "bounce" \| "float"` | `"wave"` | A smooth sine, hops off the baseline, or a gentle drift and tilt. |
| `amplitude` | `number` | `0.2` | Wave height in em. |
| `speed` | `number` | `1.6` | Seconds per cycle. |
| `spread` | `number` | `0.08` | Seconds between neighbouring letters. |
| `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
- Letters are aria-hidden; the text is read once.
- It never stops on its own, so keep it short and decorative.
## Motion
- Negative delays start every letter mid-cycle, so the wave is already in motion on load.
## Reduced motion
Still, plain text.
## Performance
- Pure CSS keyframes on transform; no JavaScript after render.
## 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: wave, bounce, float.
## Related
- [Breathing text](https://textlab.dev/components/breathing-text.md): Weight breathes in and out over time.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
- [Path text](https://textlab.dev/components/path-text.md): Text travels along a wave, circle or spiral.
## Also in physics
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
## Source
### components/textlab/wave-text/wave-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type WaveTextVariant = "wave" | "bounce" | "float";
export interface WaveTextProps {
text: string;
/** wave rolls a smooth sine, bounce hops each letter off the baseline, float drifts and tilts gently. */
variant?: WaveTextVariant;
/** Height of the wave in em. */
amplitude?: number;
/** Seconds per cycle. */
speed?: number;
/** Seconds between neighbouring letters. Smaller makes a longer wave. */
spread?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CSS = `
@keyframes tl-wave { 0%, 100% { transform: translateY(calc(var(--tl-amp) * 0.5)); } 50% { transform: translateY(calc(var(--tl-amp) * -0.5)); } }
@keyframes tl-bounce { 0%, 100% { transform: translateY(0); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } 50% { transform: translateY(calc(var(--tl-amp) * -1)); animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); } }
@keyframes tl-float { 0%, 100% { transform: translateY(0) rotate(-3deg); } 50% { transform: translateY(calc(var(--tl-amp) * -0.6)) rotate(3deg); } }
@media (prefers-reduced-motion: reduce) { [data-tl-wave="user"] [data-tl-char] { animation: none !important; } }
`;
/** A sine wave runs through the line. Pure CSS: one keyframe, offset per letter. */
export function WaveText({ text, variant = "wave", amplitude = 0.2, speed = 1.6, spread = 0.08, as: Tag = "span", reducedMotion = "user", className, style }: WaveTextProps) {
const reduced = useReduced(reducedMotion);
if (reduced) {
return (
{text}
);
}
const name = variant === "bounce" ? "tl-bounce" : variant === "float" ? "tl-float" : "tl-wave";
return (
{text} ({
animation: `${name} ${speed}s ease-in-out infinite`,
// Negative delays start every letter mid-cycle, so the wave is already moving on first paint.
animationDelay: `${-(10 - ((i * spread) % 10))}s`,
transformOrigin: "50% 80%",
})}
/>
);
}
export default WaveText;
```
Full library index: https://textlab.dev/llms.txt
---
# Breathing text
> Weight breathes in and out over time.
- Type: Component (physics)
- Page: https://textlab.dev/components/breathing-text
- Markdown: https://textlab.dev/components/breathing-text.md
- Registry item: https://textlab.dev/r/breathing-text.json
- Source file: `registry/textlab/breathing-text/breathing-text.tsx`
- Dependencies: none
- Variants: breathe, heartbeat
- Keywords: variable font, breathing, weight, pulse, heartbeat, calm
## When to use
- Meditation, health and calm brands.
- Idle and waiting states that should feel alive, not busy.
## When not to use
- Fonts without a weight axis.
- Layouts where width changes would push neighbours.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/breathing-text
pnpm dlx shadcn@latest add @textlab/breathing-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/breathing-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 { BreathingText } from "@/components/textlab/breathing-text/breathing-text";
export function Example() {
return ;
}
```
## API reference
### BreathingText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"breathe" \| "heartbeat"` | `"breathe"` | A slow swell through the word, or a double thump and rest. |
| `from` | `number` | `250` | Weight at rest. |
| `to` | `number` | `800` | Weight at the peak. |
| `speed` | `number` | `3.6` | Seconds per breath. |
| `font` | `string` | `var(--tl-font-flex)` | A variable font with a weight axis. |
| `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
- Letters are aria-hidden; the text is read once.
- Slow and continuous, with no flashing.
## Motion
- The breath travels through the word 90 ms per letter.
- Heartbeat thumps twice in the first 40% of each cycle, then rests.
## Reduced motion
A still word at a medium weight.
## Performance
- A registered CSS property drives font-variation-settings; text reflows within the line each frame, so use it on short words.
## 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: breathe, heartbeat.
## Related
- [Variable proximity](https://textlab.dev/components/variable-proximity.md): Weight and width swell near the cursor.
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
- [Neon sign](https://textlab.dev/components/neon-text.md): A neon sign that hums and flickers on.
## Also in physics
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
- [Elastic text](https://textlab.dev/components/elastic-text.md): Drag a letter and the word stretches like rubber.
- [Jelly text](https://textlab.dev/components/jelly-text.md): Letters wobble like jelly when touched.
## Source
### components/textlab/breathing-text/breathing-text.tsx
```tsx
"use client";
import type { CSSProperties, ElementType } from "react";
import { SrText } from "../lib/a11y";
import { Letters } from "../lib/letters";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type BreathingTextVariant = "breathe" | "heartbeat";
export interface BreathingTextProps {
text: string;
/** breathe swells slowly through the word, heartbeat thumps twice and rests. */
variant?: BreathingTextVariant;
/** Weight at rest and at the peak. */
from?: number;
to?: number;
/** Seconds per breath. */
speed?: number;
/** Variable font with a weight axis. */
font?: string;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CSS = `
@property --tl-wght { syntax: ""; inherits: true; initial-value: 400; }
@keyframes tl-breathe { 0%, 100% { --tl-wght: var(--tl-from); } 50% { --tl-wght: var(--tl-to); } }
@keyframes tl-heartbeat { 0%, 40%, 100% { --tl-wght: var(--tl-from); } 10%, 26% { --tl-wght: var(--tl-to); } 18% { --tl-wght: calc((var(--tl-from) + var(--tl-to)) / 2); } }
.tl-breath [data-tl-char] { font-variation-settings: "wght" var(--tl-wght); }
@media (prefers-reduced-motion: reduce) { .tl-breath[data-motion="user"] [data-tl-char] { animation: none !important; } }
`;
/**
* Weight breathes in and out, travelling through the word like a slow inhale. The weight is a
* registered CSS property, so the browser interpolates it with no JavaScript.
*/
export function BreathingText({ text, variant = "breathe", from = 250, to = 800, speed = 3.6, font = "var(--tl-font-flex)", as: Tag = "span", reducedMotion = "user", className, style }: BreathingTextProps) {
const reduced = useReduced(reducedMotion);
if (reduced) {
return (
{text}
);
}
const name = variant === "heartbeat" ? "tl-heartbeat" : "tl-breathe";
return (
{text} ({
animation: `${name} ${speed}s ${variant === "heartbeat" ? "ease-out" : "ease-in-out"} infinite`,
animationDelay: `${variant === "heartbeat" ? 0 : -(speed * 2) + i * 0.09}s`,
})}
/>
);
}
export default BreathingText;
```
Full library index: https://textlab.dev/llms.txt
---
# Path text
> Text travels along a wave, circle or spiral.
- Type: Component (path)
- Page: https://textlab.dev/components/path-text
- Markdown: https://textlab.dev/components/path-text.md
- Registry item: https://textlab.dev/r/path-text.json
- Source file: `registry/textlab/path-text/path-text.tsx`
- Dependencies: none
- Variants: wave, circle, spiral
- Keywords: text on path, circular text, badge, spiral, svg textpath, rotating text
## When to use
- Badges, stamps and seals.
- Decorative bands between sections.
## When not to use
- Essential copy; curved text is slower to read.
- Long sentences on the spiral.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/path-text
pnpm dlx shadcn@latest add @textlab/path-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/path-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 { PathText } from "@/components/textlab/path-text/path-text";
export function Example() {
return ;
}
```
## API reference
### PathText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"wave" \| "circle" \| "spiral"` | `"wave"` | Flow along a sine, turn as a badge, or wind inward. |
| `speed` | `number` | `60` | Path units per second. Negative reverses. |
| `separator` | `string` | `"·"` | Mark between repeats. |
| `fontSize` | `number` | – | Size in path units. Defaults per variant. |
| `as` | `ElementType` | `"div"` | 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 SVG is decorative; the text is read once from a hidden copy.
## Motion
- The loop wraps at a measured seam, so it never visibly jumps.
- The circle badge turns once every 15 seconds at the default speed.
## Reduced motion
Text sits still on its path.
## Responsive behavior
- Scales with its width; set a width class.
## Performance
- Wave and spiral update one attribute per frame; circle is a CSS rotation.
## 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: wave, circle, spiral.
## Related
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
- [Cursor trail](https://textlab.dev/components/cursor-trail.md): A word trails behind the cursor letter by letter.
- [Wave text](https://textlab.dev/components/wave-text.md): A sine wave runs through the line.
## Source
### components/textlab/path-text/path-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 { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useSvgId } from "../lib/use-svg-id";
export type PathTextVariant = "wave" | "circle" | "spiral";
export interface PathTextProps {
text: string;
/** wave flows along a sine, circle turns as a badge, spiral winds the words inward. */
variant?: PathTextVariant;
/** Speed in path units per second. Negative reverses. */
speed?: number;
/** Mark between repeats. */
separator?: string;
/** Font size inside the SVG, in path units. */
fontSize?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const PATHS: Record = {
wave: { d: "M -1000 100 C -875 20, -625 20, -500 100 S -125 180, 0 100 S 375 20, 500 100 S 875 180, 1000 100 S 1375 20, 1500 100 S 1875 180, 2000 100", viewBox: "0 0 1000 200" },
circle: { d: "M 200 200 m -150 0 a 150 150 0 1 1 300 0 a 150 150 0 1 1 -300 0", viewBox: "0 0 400 400" },
spiral: {
d: (() => {
const pts: string[] = [];
for (let i = 0; i <= 720; i += 6) {
const t = (i * Math.PI) / 180;
const r = 190 - i * 0.22;
pts.push(`${(200 + Math.cos(t) * r).toFixed(1)} ${(200 + Math.sin(t) * r).toFixed(1)}`);
}
return `M ${pts.join(" L ")}`;
})(),
viewBox: "0 0 400 400",
},
};
/**
* Text travels along a wave, a circle or a spiral. The words loop seamlessly: the repeat length is
* measured from the real glyphs, so the seam never jumps.
*/
export function PathText({ text, variant = "wave", speed = 60, separator = "·", fontSize, as: Tag = "div", reducedMotion = "user", className, style }: PathTextProps) {
const id = useSvgId("tl-path");
const root = useRef(null);
const textPath = useRef(null);
const unit = useRef(null);
const offset = useRef(0);
const reduced = useReduced(reducedMotion);
const { d, viewBox } = PATHS[variant];
const size = fontSize ?? (variant === "wave" ? 44 : variant === "circle" ? 30 : 22);
const chunk = `${text} ${separator} `;
const repeats = variant === "circle" ? Math.max(1, Math.round(942 / (chunk.length * size * 0.55))) : 14;
useFrame(
(_, dt) => {
const tp = textPath.current;
const one = unit.current?.getComputedTextLength() ?? 0;
if (!tp || !one || variant === "circle") return;
// Wrap by exactly one repeat; the repeats are identical, so the jump is invisible.
offset.current = (offset.current - speed * dt) % one;
tp.setAttribute("startOffset", String(offset.current));
},
{ ref: root, enabled: !reduced && variant !== "circle" },
);
return (
{text}
);
}
export default PathText;
```
Full library index: https://textlab.dev/llms.txt
---
# ASCII text
> The title rendered in ASCII glyphs that ripple.
- Type: Component (pixel)
- Page: https://textlab.dev/components/ascii-text
- Markdown: https://textlab.dev/components/ascii-text.md
- Registry item: https://textlab.dev/r/ascii-text.json
- Source file: `registry/textlab/ascii-text/ascii-text.tsx`
- Dependencies: none
- Variants: classic, blocks, braille
- Keywords: ascii, ascii art, braille, terminal, canvas text, retro
## When to use
- Developer tools, retro computing and terminal aesthetics.
- Large, heavy display words; thin type has too little to sample.
## When not to use
- Small text; cells need room.
- When the word must be crisp; it is deliberately coarse.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/ascii-text
pnpm dlx shadcn@latest add @textlab/ascii-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/ascii-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 { AsciiText } from "@/components/textlab/ascii-text/ascii-text";
export function Example() {
return ;
}
```
## API reference
### AsciiText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"classic" \| "blocks" \| "braille"` | `"classic"` | Character ramp: .:-=+*#%@, shade blocks, or braille dots for fine detail. |
| `density` | `number` | `0.1` | Cell size as a share of the font size. |
| `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; screen readers read the hidden text.
- A hidden copy sizes the box, so layout matches the real text.
## Motion
- Glyphs resolve in a scattered order over 0.9 s when the title scrolls in.
- The pointer sends a decaying ripple through the character ramp.
## Reduced motion
Shows the plain text instead of the canvas.
## Performance
- Rasterises once per resize; draws only while revealing or while the pointer is over it.
- Device pixel ratio capped at 2.
## 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: classic, blocks, braille.
## Related
- [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.
- [Matrix decode](https://textlab.dev/components/matrix-decode.md): Glyph rain resolves into the word.
## Also in pixel
- [Particle text](https://textlab.dev/components/particle-text.md): Particles gather into letters and scatter on touch.
- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.
- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
## Source
### components/textlab/ascii-text/ascii-text.tsx
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { CanvasText } from "../lib/canvas-text";
import { hashString, seeded } 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";
export type AsciiTextVariant = "classic" | "blocks" | "braille";
export interface AsciiTextProps {
text: string;
/** classic uses .:-=+*#%@, blocks uses shade blocks, braille packs 2×4 dots per cell for fine detail. */
variant?: AsciiTextVariant;
/** Cell size as a share of the font size. Smaller is finer and costlier. */
density?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const RAMPS = { classic: " .:-=+*#%@", blocks: " ░▒▓█" };
type Grid = { cols: number; rows: number; cell: number; bleed: number; cover: Float32Array; dots: Uint8Array; order: Float32Array };
/**
* The title rendered in ASCII glyphs. Move the pointer across it and a ripple runs through the
* characters; scroll it into view and the glyphs resolve in a scattered order.
*/
export function AsciiText({ text, variant = "classic", density = 0.1, as, reducedMotion = "user", className, style }: AsciiTextProps) {
const { ref: root, on } = useTrigger({ trigger: "inView" });
const canvas = useRef(null);
const grid = useRef(null);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const born = useRef(null);
const clock = useRef(0);
const draw = (time: number) => {
const c = canvas.current;
const g = grid.current;
const node = root.current;
if (!c || !g || !node) return;
const ctx = c.getContext("2d");
if (!ctx) return;
const dpr = Number(c.dataset.dpr || 1);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = getComputedStyle(node).color;
ctx.font = `${g.cell * (variant === "braille" ? 1.15 : 1.25)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const p = pointer.current;
const px = p.x + g.bleed;
const py = p.y + g.bleed;
const reveal = born.current === null ? 0 : Math.min(1, (time - born.current) / 0.9);
const ramp = variant === "blocks" ? RAMPS.blocks : RAMPS.classic;
for (let r = 0; r < g.rows; r++) {
for (let q = 0; q < g.cols; q++) {
const i = r * g.cols + q;
let level = g.cover[i];
if (level < 0.03 || g.order[i] > reveal) continue;
const x = q * g.cell + g.cell / 2;
const y = r * g.cell + g.cell / 2;
if (p.active) {
const d = Math.hypot(px - x, py - y);
level += Math.sin(d / (g.cell * 1.6) - time * 9) * Math.exp(-d / (g.cell * 7)) * 0.6;
}
if (variant === "braille") {
let bits = g.dots[i];
if (p.active && level < g.cover[i] - 0.15) bits &= 0b01010101;
if (!bits) continue;
ctx.fillText(String.fromCharCode(0x2800 + bits), x, y);
} else {
const k = Math.max(1, Math.min(ramp.length - 1, Math.round(level * (ramp.length - 1))));
ctx.fillText(ramp[k], x, y);
}
}
}
};
useEffect(() => {
const node = root.current;
const c = canvas.current;
if (!node || !c || reduced) return;
const build = () => {
const { width, height, dpr } = fitCanvas(c);
const raster = rasterize(node, text, width, height, { dpr: 1 });
const font = parseFloat(getComputedStyle(node).fontSize) || 16;
const cell = Math.max(3, font * density);
const cols = Math.ceil(width / cell);
const rows = Math.ceil(height / cell);
const cover = new Float32Array(cols * rows);
const dots = new Uint8Array(cols * rows);
const order = new Float32Array(cols * rows);
const rand = seeded(hashString(text));
const alpha = (x: number, y: number) => {
const xi = Math.min(raster.width - 1, Math.max(0, Math.round(x)));
const yi = Math.min(raster.height - 1, Math.max(0, Math.round(y)));
return raster.data[(yi * raster.width + xi) * 4 + 3] / 255;
};
for (let r = 0; r < rows; r++) {
for (let q = 0; q < cols; q++) {
const i = r * cols + q;
let sum = 0;
let bits = 0;
// Eight sub-samples per cell: enough for coverage, and exactly the braille dot grid.
const map = [0, 3, 1, 4, 2, 5, 6, 7];
for (let sy = 0; sy < 4; sy++) {
for (let sx = 0; sx < 2; sx++) {
const a = alpha(q * cell + (sx + 0.5) * (cell / 2), r * cell + (sy + 0.5) * (cell / 4));
sum += a;
if (a > 0.45) bits |= 1 << map[sy * 2 + sx];
}
}
cover[i] = sum / 8;
dots[i] = bits;
order[i] = rand();
}
}
const bleed = (width - node.getBoundingClientRect().width) / 2;
grid.current = { cols, rows, cell, bleed, cover, dots, order };
c.dataset.dpr = String(dpr);
draw(0);
};
build();
const observer = new ResizeObserver(build);
observer.observe(node);
document.fonts?.ready.then(build);
return () => observer.disconnect();
// draw reads refs only and is recreated every render; it is not a real dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, density, reduced]);
useFrame(
(_, dt) => {
clock.current += dt;
if (on && born.current === null) born.current = clock.current;
const revealing = born.current !== null && clock.current - born.current < 1;
if (revealing || pointer.current.active) draw(clock.current);
},
{ ref: root, enabled: !reduced },
);
return ;
}
export default AsciiText;
```
Full library index: https://textlab.dev/llms.txt
---
# Dither text
> Ordered dither and halftone dots form the type.
- Type: Component (pixel)
- Page: https://textlab.dev/components/dither-text
- Markdown: https://textlab.dev/components/dither-text.md
- Registry item: https://textlab.dev/r/dither-text.json
- Source file: `registry/textlab/dither-text/dither-text.tsx`
- Dependencies: none
- Variants: dither, halftone
- Keywords: dither, halftone, bayer, print, pixel, risograph
## When to use
- Editorial, zine and print-inspired brands.
- Heavy display type with room to show texture.
## When not to use
- Small or thin text.
- Busy photographic backgrounds behind it.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/dither-text
pnpm dlx shadcn@latest add @textlab/dither-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/dither-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 { DitherText } from "@/components/textlab/dither-text/dither-text";
export function Example() {
return ;
}
```
## API reference
### DitherText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"dither" \| "halftone"` | `"dither"` | Square Bayer pixels, or dots on an offset screen. |
| `pitch` | `number` | `0.035` | Pixel or dot pitch as a share of the font size. |
| `shade` | `number` | `0.55` | How much lighter the bottom of the letters gets, 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.
## Motion
- Dithers in over 1.1 s as it enters the viewport, in Bayer order.
- The pointer adds a soft spot of extra ink.
## Reduced motion
Plain text instead of the canvas.
## Performance
- Rasterises on resize; draws only during the reveal and while the pointer moves.
## 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: dither, halftone.
## Related
- [ASCII text](https://textlab.dev/components/ascii-text.md): The title rendered in ASCII glyphs that ripple.
- [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.
## Also in pixel
- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.
- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
## Source
### components/textlab/dither-text/dither-text.tsx
```tsx
"use client";
import { useEffect, useRef, 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 { usePointer } from "../lib/use-pointer";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type DitherTextVariant = "dither" | "halftone";
export interface DitherTextProps {
text: string;
/** dither uses an ordered Bayer pattern of square pixels, halftone uses dots on an angled screen. */
variant?: DitherTextVariant;
/** Pixel or dot pitch as a share of the font size. */
pitch?: number;
/** How far the shading falls from top to bottom of the letters, 0 to 1. */
shade?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((v) => (v + 0.5) / 16);
/**
* Ordered dither and halftone dots form the type, shaded top to bottom like a print. It dithers in
* as it enters the viewport, and the pointer lifts a soft spot of extra ink.
*/
export function DitherText({ text, variant = "dither", pitch = 0.035, shade = 0.55, as, reducedMotion = "user", className, style }: DitherTextProps) {
const { ref: root, on } = useTrigger({ trigger: "inView" });
const canvas = useRef(null);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const field = useRef<{ cols: number; rows: number; cell: number; bleed: number; value: Float32Array; dpr: number } | null>(null);
const clock = useRef(0);
const born = useRef(null);
const last = useRef({ x: -1, y: -1 });
const draw = (time: number) => {
const c = canvas.current;
const f = field.current;
const node = root.current;
if (!c || !f || !node) return;
const ctx = c.getContext("2d");
if (!ctx) return;
ctx.setTransform(f.dpr, 0, 0, f.dpr, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
ctx.fillStyle = getComputedStyle(node).color;
const reveal = born.current === null ? 0 : Math.min(1, (time - born.current) / 1.1);
const p = pointer.current;
const px = p.x + f.bleed;
const py = p.y + f.bleed;
const spot = f.cell * 22;
ctx.beginPath();
for (let r = 0; r < f.rows; r++) {
for (let q = 0; q < f.cols; q++) {
let v = f.value[r * f.cols + q];
if (v <= 0.01) continue;
const x = q * f.cell;
const y = r * f.cell;
if (p.active) v = Math.min(1, v + Math.max(0, 1 - Math.hypot(px - x, py - y) / spot) * 0.5);
v *= reveal;
if (variant === "halftone") {
const radius = Math.sqrt(v) * f.cell * 0.72;
if (radius < 0.3) continue;
// Every other row shifts half a cell, which reads as a classic angled screen.
const ox = r % 2 ? f.cell / 2 : 0;
ctx.moveTo(x + ox + f.cell / 2 + radius, y + f.cell / 2);
ctx.arc(x + ox + f.cell / 2, y + f.cell / 2, radius, 0, Math.PI * 2);
} else if (v > BAYER[(r % 4) * 4 + (q % 4)]) {
ctx.rect(x, y, f.cell, f.cell);
}
}
}
ctx.fill();
};
useEffect(() => {
const node = root.current;
const c = canvas.current;
if (!node || !c || reduced) return;
const build = () => {
const { width, height, dpr } = fitCanvas(c);
const raster = rasterize(node, text, width, height, { dpr: 1 });
const font = parseFloat(getComputedStyle(node).fontSize) || 16;
const cell = Math.max(2, font * pitch);
const cols = Math.ceil(width / cell);
const rows = Math.ceil(height / cell);
const value = new Float32Array(cols * rows);
// Shade relative to the glyph band, not the whole canvas.
let top = rows;
let bottom = 0;
const alphaAt = (q: number, r: number) => {
const x = Math.min(raster.width - 1, Math.round(q * cell + cell / 2));
const y = Math.min(raster.height - 1, Math.round(r * cell + cell / 2));
return raster.data[(y * raster.width + x) * 4 + 3] / 255;
};
for (let r = 0; r < rows; r++) for (let q = 0; q < cols; q++) if (alphaAt(q, r) > 0.1) { top = Math.min(top, r); bottom = Math.max(bottom, r); }
for (let r = 0; r < rows; r++) {
const t = bottom > top ? (r - top) / (bottom - top) : 0;
for (let q = 0; q < cols; q++) value[r * cols + q] = alphaAt(q, r) * (1 - t * shade);
}
field.current = { cols, rows, cell, bleed: (width - node.getBoundingClientRect().width) / 2, value, dpr };
draw(clock.current);
};
build();
const observer = new ResizeObserver(build);
observer.observe(node);
document.fonts?.ready.then(build);
return () => observer.disconnect();
// draw reads refs only and is recreated every render; it is not a real dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, pitch, shade, reduced]);
useFrame(
(_, dt) => {
clock.current += dt;
if (on && born.current === null) born.current = clock.current;
const revealing = born.current !== null && clock.current - born.current < 1.2;
const p = pointer.current;
const moved = p.active && (p.x !== last.current.x || p.y !== last.current.y);
last.current = { x: p.x, y: p.y };
if (revealing || moved) draw(clock.current);
},
{ ref: root, enabled: !reduced },
);
return ;
}
export default DitherText;
```
Full library index: https://textlab.dev/llms.txt
---
# Pixel sort
> Rows sort into place like a glitch artwork.
- Type: Component (pixel)
- Page: https://textlab.dev/components/pixel-sort
- Markdown: https://textlab.dev/components/pixel-sort.md
- Registry item: https://textlab.dev/r/pixel-sort.json
- Source file: `registry/textlab/pixel-sort/pixel-sort.tsx`
- Dependencies: none
- Variants: smear, sort
- Keywords: pixel sort, glitch art, datamosh, smear, canvas, experimental
## When to use
- Music, art and experimental brands.
- Heavy display words on dark or light flat backgrounds.
## When not to use
- Small text; rows are too few to read as bands.
- Gradients behind the text; the canvas is flat color.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/pixel-sort
pnpm dlx shadcn@latest add @textlab/pixel-sort
```
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/pixel-sort.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 { PixelSort } from "@/components/textlab/pixel-sort/pixel-sort";
export function Example() {
return ;
}
```
## API reference
### PixelSort
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"smear" \| "sort"` | `"smear"` | Retracting streaks, or bands of rows sliding into register. |
| `duration` | `number` | `1.4` | Seconds each part takes. |
| `hover` | `boolean` | `true` | Tear the image again briefly on hover. |
| `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.
- Settles into clean type; nothing keeps flashing.
## Motion
- Settles from full glitch to clean in about 1.4 s after it enters view.
- Hover re-tears to 45% and settles again.
## Reduced motion
Plain text instead of the canvas.
## Performance
- Per-pixel work runs only while settling; device pixel ratio capped at 1.5 for this effect.
## 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: smear, sort.
## Related
- [Glitch text](https://textlab.dev/components/glitch-text.md): A controlled digital glitch on cue.
- [Dither text](https://textlab.dev/components/dither-text.md): Ordered dither and halftone dots form the type.
- [Slice reveal](https://textlab.dev/components/slice-reveal.md): The title arrives in offset horizontal slices.
## Also in pixel
- [ASCII text](https://textlab.dev/components/ascii-text.md): The title rendered in ASCII glyphs that ripple.
- [Particle text](https://textlab.dev/components/particle-text.md): Particles gather into letters and scatter on touch.
- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.
- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
## Source
### components/textlab/pixel-sort/pixel-sort.tsx
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { CanvasText } from "../lib/canvas-text";
import { hashString, seeded } from "../lib/math";
import { noise2 } from "../lib/noise";
import { fitCanvas, rasterize, type Raster } from "../lib/raster";
import { useFrame } from "../lib/use-frame";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type PixelSortVariant = "sort" | "smear";
export interface PixelSortProps {
text: string;
/** sort slides bands of rows into register, smear pulls long streaks out of each row and retracts them. */
variant?: PixelSortVariant;
/** Seconds the image takes to settle. */
duration?: number;
/** Re-glitch briefly on hover. */
hover?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Rows sort into place like a glitch artwork. The title starts torn into sliding bands or streaks
* and settles into clean type; hover tears it again for a moment.
*/
export function PixelSort({ text, variant = "smear", duration = 1.4, hover = true, as, reducedMotion = "user", className, style }: PixelSortProps) {
const { ref: root, on } = useTrigger({ trigger: "inView" });
const canvas = useRef(null);
const reduced = useReduced(reducedMotion);
const src = useRef<{ raster: Raster; rows: Float32Array; out: ImageData } | null>(null);
const amount = useRef(1);
const target = useRef(1);
const idle = useRef(false);
const draw = (time: number) => {
const c = canvas.current;
const s = src.current;
if (!c || !s) return;
const ctx = c.getContext("2d");
if (!ctx) return;
const { width: w, height: h, data } = s.raster;
const out = s.out.data;
out.fill(0);
const k = amount.current;
for (let y = 0; y < h; y++) {
const row = y * w * 4;
if (variant === "sort") {
// Bands of rows share one offset, quantised so they move in visible steps.
const band = Math.floor(y / Math.max(2, h / 28));
const n = noise2(band * 0.7, time * 0.6) - 0.5;
const shift = Math.round(n * w * 0.35 * k * k);
for (let x = 0; x < w; x++) {
const sx = x - shift;
if (sx < 0 || sx >= w) continue;
const si = row + sx * 4;
const di = row + x * 4;
out[di] = data[si];
out[di + 1] = data[si + 1];
out[di + 2] = data[si + 2];
out[di + 3] = data[si + 3];
}
} else {
const len = s.rows[y] * w * 0.6 * k;
let lastX = -1e9;
let lastA = 0;
for (let x = 0; x < w; x++) {
const i = row + x * 4;
const a = data[i + 3];
if (a > 40) {
lastX = x;
lastA = a;
out[i] = data[i];
out[i + 1] = data[i + 1];
out[i + 2] = data[i + 2];
out[i + 3] = a;
} else if (len > 0 && x - lastX < len) {
out[i] = data[i];
out[i + 1] = data[i + 1];
out[i + 2] = data[i + 2];
out[i + 3] = Math.max(a, lastA * (1 - (x - lastX) / len));
}
}
}
}
ctx.putImageData(s.out, 0, 0);
};
useEffect(() => {
const node = root.current;
const c = canvas.current;
if (!node || !c || reduced) return;
const build = () => {
const { width, height, dpr } = fitCanvas(c, Math.min(1.5, window.devicePixelRatio || 1));
const raster = rasterize(node, text, width, height, { dpr, color: getComputedStyle(node).color });
const rand = seeded(hashString(text));
const rows = new Float32Array(raster.height);
// Streaks come in clusters of rows, which reads as sorted bands rather than noise.
let current = 0;
for (let y = 0; y < raster.height; y++) {
if (y % 6 === 0) current = rand() < 0.45 ? rand() : rand() * 0.15;
rows[y] = current;
}
src.current = { raster, rows, out: new ImageData(raster.width, raster.height) };
draw(0);
};
build();
const observer = new ResizeObserver(build);
observer.observe(node);
// The pixels carry the text color, so a theme switch needs a fresh raster.
const theme = new MutationObserver(build);
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] });
document.fonts?.ready.then(build);
return () => {
observer.disconnect();
theme.disconnect();
};
// draw reads refs only and is recreated every render; it is not a real dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, variant, reduced]);
useEffect(() => {
if (on) target.current = 0;
}, [on]);
const clock = useRef(0);
useFrame(
(_, dt) => {
clock.current += dt;
const before = amount.current;
// Ease toward the target; the rate is set so a full settle takes about `duration`.
amount.current += (target.current - amount.current) * Math.min(1, dt * (4 / duration));
if (Math.abs(amount.current - target.current) < 0.002) amount.current = target.current;
const changed = Math.abs(before - amount.current) > 0.0005 || (variant === "sort" && amount.current > 0.01);
if (changed) {
idle.current = false;
draw(clock.current);
} else if (!idle.current) {
idle.current = true;
draw(clock.current);
}
},
{ ref: root, enabled: !reduced },
);
return (
{hover && !reduced && (
{
amount.current = Math.max(amount.current, 0.45);
target.current = 0;
}}
/>
)}
);
}
export default PixelSort;
```
Full library index: https://textlab.dev/llms.txt
---
# Particle text
> Particles gather into letters and scatter on touch.
- Type: Component (pixel)
- Page: https://textlab.dev/components/particle-text
- Markdown: https://textlab.dev/components/particle-text.md
- Registry item: https://textlab.dev/r/particle-text.json
- Source file: `registry/textlab/particle-text/particle-text.tsx`
- Dependencies: none
- Variants: dust, sand, stars
- Keywords: particles, particle text, canvas, interactive, stardust, sand
## When to use
- Launch pages and immersive heroes.
- One large word per screen.
## When not to use
- Several instances; each one simulates thousands of particles.
- Low-power contexts where battery matters.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/particle-text
pnpm dlx shadcn@latest add @textlab/particle-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/particle-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 { ParticleText } from "@/components/textlab/particle-text/particle-text";
export function Example() {
return ;
}
```
## API reference
### ParticleText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"dust" \| "sand" \| "stars"` | `"dust"` | Fine quick dust, heavy warm sand that sags under the pointer, or twinkling stars. |
| `gap` | `number` | `3` | Sampling gap in px; smaller means more particles. |
| `radius` | `number` | `1.2` | Pointer push radius 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
- Decorative canvas; the text is read from a hidden copy.
- Particles settle into readable letters within a second or two.
## Motion
- Particles gather from a wide cloud when the word scrolls in.
- The pointer pushes them away; springs pull them home.
## Reduced motion
Plain text instead of the canvas.
## Performance
- Capped at 9,000 particles; the loop sleeps once they settle (except stars, which twinkle).
- Typed arrays, one draw call per frame for dust.
## 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: dust, sand, stars.
## Related
- [Dither text](https://textlab.dev/components/dither-text.md): Ordered dither and halftone dots form the type.
- [Noise dissolve](https://textlab.dev/components/noise-dissolve.md): The title forms from noise and dissolves back into it.
- [Letter drop](https://textlab.dev/components/letter-drop.md): Letters fall, bounce and settle on the floor.
## Also in pixel
- [ASCII text](https://textlab.dev/components/ascii-text.md): The title rendered in ASCII glyphs that ripple.
- [Pixel sort](https://textlab.dev/components/pixel-sort.md): Rows sort into place like a glitch artwork.
- [Shader ripple](https://textlab.dev/components/shader-ripple.md): A WebGL ripple bends the title under the pointer.
## Source
### components/textlab/particle-text/particle-text.tsx
```tsx
"use client";
import { useEffect, useRef, type CSSProperties, type ElementType } from "react";
import { CanvasText } from "../lib/canvas-text";
import { hashString, seeded } 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";
export type ParticleTextVariant = "dust" | "sand" | "stars";
export interface ParticleTextProps {
text: string;
/** dust is fine and quick, sand is heavier and warm, stars twinkle and glow. */
variant?: ParticleTextVariant;
/** Sampling gap in px between particles. Smaller means more particles. */
gap?: number;
/** Pointer push radius, in em. */
radius?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
type Swarm = { hx: Float32Array; hy: Float32Array; x: Float32Array; y: Float32Array; vx: Float32Array; vy: Float32Array; tint: Float32Array; count: number; bleed: number; em: number; dpr: number };
/**
* Particles gather into letters and scatter where you touch. Each particle is sprung to its home
* in the glyph; the pointer pushes them aside and they flow back when it leaves.
*/
export function ParticleText({ text, variant = "dust", gap = 3, radius = 1.2, as, reducedMotion = "user", className, style }: ParticleTextProps) {
const { ref: root, on } = useTrigger({ trigger: "inView" });
const canvas = useRef(null);
const swarm = useRef(null);
const reduced = useReduced(reducedMotion);
const pointer = usePointer(root, { enabled: !reduced });
const settled = useRef(false);
const time = useRef(0);
useEffect(() => {
const node = root.current;
const c = canvas.current;
if (!node || !c || reduced) return;
const build = () => {
const { width, height, dpr } = fitCanvas(c);
const raster = rasterize(node, text, width, height, { dpr: 1 });
const rand = seeded(hashString(text));
const hx: number[] = [];
const hy: number[] = [];
const step = Math.max(1.5, gap);
for (let y = 0; y < raster.height; y += step) {
for (let x = 0; x < raster.width; x += step) {
if (raster.data[(Math.round(y) * raster.width + Math.round(x)) * 4 + 3] > 128) {
hx.push(x + (rand() - 0.5) * step * 0.6);
hy.push(y + (rand() - 0.5) * step * 0.6);
}
}
}
const count = Math.min(hx.length, 9000);
const s: Swarm = {
hx: Float32Array.from(hx.slice(0, count)),
hy: Float32Array.from(hy.slice(0, count)),
x: new Float32Array(count),
y: new Float32Array(count),
vx: new Float32Array(count),
vy: new Float32Array(count),
tint: new Float32Array(count),
count,
bleed: (width - node.getBoundingClientRect().width) / 2,
em: parseFloat(getComputedStyle(node).fontSize) || 16,
dpr,
};
for (let i = 0; i < count; i++) {
// Start scattered in a wide cloud; the intro gathers them home.
const a = rand() * Math.PI * 2;
const r = (0.4 + rand()) * width * 0.6;
s.x[i] = width / 2 + Math.cos(a) * r;
s.y[i] = height / 2 + Math.sin(a) * r * 0.5;
s.tint[i] = rand();
}
swarm.current = s;
settled.current = false;
};
build();
const observer = new ResizeObserver(build);
observer.observe(node);
document.fonts?.ready.then(build);
return () => observer.disconnect();
}, [text, gap, reduced, root]);
useFrame(
(_, dt) => {
const s = swarm.current;
const c = canvas.current;
const node = root.current;
if (!s || !c || !node) return;
time.current += dt;
const p = pointer.current;
if (settled.current && !p.active && variant !== "stars") return;
const ctx = c.getContext("2d");
if (!ctx) return;
const px = p.x + s.bleed;
const py = p.y + s.bleed;
const reach = radius * s.em;
const heavy = variant === "sand";
const stiffness = on ? (heavy ? 40 : 70) : 0;
const damping = heavy ? 7 : 9;
let energy = 0;
for (let i = 0; i < s.count; i++) {
let ax = (s.hx[i] - s.x[i]) * stiffness - s.vx[i] * damping;
let ay = (s.hy[i] - s.y[i]) * stiffness - s.vy[i] * damping;
if (p.active) {
const dx = s.x[i] - px;
const dy = s.y[i] - py;
const d2 = dx * dx + dy * dy;
if (d2 < reach * reach) {
const d = Math.sqrt(d2) || 1;
const push = (1 - d / reach) * (heavy ? 5200 : 8000);
ax += (dx / d) * push;
ay += (dy / d) * push + (heavy ? 900 : 0);
}
}
s.vx[i] += ax * dt;
s.vy[i] += ay * dt;
s.x[i] += s.vx[i] * dt;
s.y[i] += s.vy[i] * dt;
energy += Math.abs(s.vx[i]) + Math.abs(s.vy[i]);
}
settled.current = on && energy / s.count < 0.05;
ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
ctx.clearRect(0, 0, c.width, c.height);
const color = getComputedStyle(node).color;
ctx.fillStyle = color;
const size = variant === "dust" ? 1.4 : variant === "sand" ? 1.8 : 1.6;
if (variant === "stars") {
for (let i = 0; i < s.count; i++) {
ctx.globalAlpha = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(time.current * (1.5 + s.tint[i] * 3) + s.tint[i] * 40));
ctx.fillRect(s.x[i] - size / 2, s.y[i] - size / 2, size, size);
}
ctx.globalAlpha = 1;
} else if (heavy) {
for (let i = 0; i < s.count; i++) {
ctx.globalAlpha = 0.55 + s.tint[i] * 0.45;
ctx.fillRect(s.x[i], s.y[i], size, size);
}
ctx.globalAlpha = 1;
} else {
ctx.beginPath();
for (let i = 0; i < s.count; i++) ctx.rect(s.x[i], s.y[i], size, size);
ctx.fill();
}
},
{ ref: root, enabled: !reduced },
);
return (
);
}
export default ParticleText;
```
Full library index: https://textlab.dev/llms.txt
---
# 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 ;
}
```
## 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({ trigger: "inView", amount: 0.1 });
const canvas = useRef(null);
const shader = useRef(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 (
{!ready && !reduced && (
{text}
)}
);
}
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
---
# 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 ;
}
```
## 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({ trigger: "inView", amount: 0.2 });
const canvas = useRef(null);
const shader = useRef(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 (
{!ready && !fallback && (
// Until the shader arrives, a CSS fade keeps the title from popping in.
{text}
)}
{hover && ready && (
{
target.current = 0.35;
}}
onPointerLeave={() => {
target.current = 0;
}}
/>
)}
);
}
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
---
# Kinetic beat
> Words hit on a beat.
- Type: Component (kinetic)
- Page: https://textlab.dev/components/kinetic-beat
- Markdown: https://textlab.dev/components/kinetic-beat.md
- Registry item: https://textlab.dev/r/kinetic-beat.json
- Source file: `registry/textlab/kinetic-beat/kinetic-beat.tsx`
- Dependencies: motion
- Variants: pop, slam, bounce
- Keywords: kinetic typography, beat, bpm, lyric, slogan, music
## When to use
- Music, events and launch teasers.
- Short punchy slogans of three to six words.
## When not to use
- Information people need to read at their own pace.
- Long sentences.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/kinetic-beat
pnpm dlx shadcn@latest add @textlab/kinetic-beat
```
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/kinetic-beat.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 { KineticBeat } from "@/components/textlab/kinetic-beat/kinetic-beat";
export function Example() {
return ;
}
```
## API reference
### KineticBeat
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | Words to hit, one per beat. |
| `variant` | `"pop" \| "slam" \| "bounce"` | `"pop"` | Scale up with overshoot, crash down from large, or hop in. |
| `bpm` | `number` | `120` | Beats per minute. |
| `loop` | `boolean` | `true` | Start over after the last word. |
| `rest` | `number` | `2` | Silent beats at the end of each loop. |
| `as` | `ElementType` | `"div"` | 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 whole sentence is read once from a hidden copy.
- Pauses when scrolled out of view.
## Motion
- One word per beat; at 120 BPM that is two words a second.
- Exits are quick so each hit lands clean.
## Reduced motion
The sentence appears whole and still.
## Performance
- One interval and one animated word at a time.
## 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: pop, slam, bounce.
## Related
- [Word sequence](https://textlab.dev/components/word-sequence.md): A sentence choreographed word by word.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Velocity marquee](https://textlab.dev/components/velocity-marquee.md): A marquee that speeds up, slows and reverses with scroll.
## Also in kinetic
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
- [Echo text](https://textlab.dev/components/echo-text.md): Every change leaves fading echoes behind.
## Source
### components/textlab/kinetic-beat/kinetic-beat.tsx
```tsx
"use client";
import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion, type TargetAndTransition, type Transition } from "motion/react";
import { srOnly } from "../lib/a11y";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type KineticBeatVariant = "pop" | "slam" | "bounce";
export interface KineticBeatProps {
/** Words to hit, one per beat. */
text: string;
/** pop scales up with a little overshoot, slam crashes down from large and blurred, bounce hops in. */
variant?: KineticBeatVariant;
/** Beats per minute. */
bpm?: number;
/** Start over after the last word. */
loop?: boolean;
/** Beats of silence at the end of each loop. */
rest?: number;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const MOVES: Record = {
pop: { initial: { scale: 0.4, opacity: 0 }, transition: { type: "spring", visualDuration: 0.22, bounce: 0.3 }, exit: { scale: 1.15, opacity: 0, transition: { duration: 0.12 } } },
// Slam is an impact: it accelerates into the landing on purpose, so it uses an ease-in curve.
slam: { initial: { scale: 2.6, opacity: 0, filter: "blur(10px)" }, transition: { duration: 0.16, ease: [0.5, 0, 1, 0.5] }, exit: { opacity: 0, transition: { duration: 0.08 } } },
bounce: { initial: { y: "-0.8em", opacity: 0 }, transition: { type: "spring", visualDuration: 0.28, bounce: 0.3 }, exit: { y: "0.4em", opacity: 0, transition: { duration: 0.12 } } },
};
/**
* Words hit on a beat, one per count, like a lyric video. Timing comes from the tempo, so it can be
* synced to music by setting the same BPM.
*/
export function KineticBeat({ text, variant = "pop", bpm = 120, loop = true, rest = 2, as: Tag = "div", reducedMotion = "user", className, style }: KineticBeatProps) {
const { ref, on } = useTrigger({ trigger: "inView", once: false });
const reduced = useReduced(reducedMotion);
const words = splitWords(text).map((w) => w.text);
const [beat, setBeat] = useState(0);
const cycle = words.length + rest;
useEffect(() => {
if (!on || reduced) return;
const id = setInterval(() => setBeat((b) => (loop ? (b + 1) % cycle : Math.min(b + 1, words.length - 1))), 60000 / bpm);
return () => clearInterval(id);
}, [on, reduced, bpm, loop, cycle, words.length]);
if (reduced) {
return (
{text}
);
}
const word = beat < words.length ? words[beat] : null;
const move = MOVES[variant];
return (
{text}
{/* The longest word reserves the stage so the layout never jumps between beats. */}
{words.reduce((a, b) => (b.length > a.length ? b : a), "")}
{word && (
{word}
)}
);
}
export default KineticBeat;
```
Full library index: https://textlab.dev/llms.txt
---
# Word sequence
> A sentence choreographed word by word.
- Type: Component (kinetic)
- Page: https://textlab.dev/components/word-sequence
- Markdown: https://textlab.dev/components/word-sequence.md
- Registry item: https://textlab.dev/r/word-sequence.json
- Source file: `registry/textlab/word-sequence/word-sequence.tsx`
- Dependencies: motion
- Variants: stack, stage, cut
- Keywords: kinetic typography, sequence, title card, storytelling, word by word
## When to use
- Manifestos and intro sequences.
- Title cards for video-like landing pages.
## When not to use
- Navigation or interface copy.
- Sentences over about ten words.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/word-sequence
pnpm dlx shadcn@latest add @textlab/word-sequence
```
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/word-sequence.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 { WordSequence } from "@/components/textlab/word-sequence/word-sequence";
export function Example() {
return ;
}
```
## API reference
### WordSequence
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"stack" \| "stage" \| "cut"` | `"stack"` | Build the sentence, spotlight each word, or hard-cut between scales. |
| `interval` | `number` | `0.45` | Seconds per word. |
| `hold` | `number` | `2` | Seconds the finished sentence holds before looping. |
| `loop` | `boolean` | `true` | Replay after the hold. |
| `as` | `ElementType` | `"div"` | 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 whole sentence is read once from a hidden copy.
- Every sequence ends on the full sentence, readable at rest.
## Motion
- Stack eases each word up into place; stage blurs words in and out; cut uses hard cuts with no tween.
## Reduced motion
The sentence appears whole and still.
## Performance
- One timer; one or a handful of animated spans.
## 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: stack, stage, cut.
## Related
- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
- [Word reveal](https://textlab.dev/components/word-reveal.md): Words rise out of a clip.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
## Also in kinetic
- [Echo text](https://textlab.dev/components/echo-text.md): Every change leaves fading echoes behind.
## Source
### components/textlab/word-sequence/word-sequence.tsx
```tsx
"use client";
import { useEffect, useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { srOnly } from "../lib/a11y";
import { ease } from "../lib/motion-tokens";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type WordSequenceVariant = "stage" | "stack" | "cut";
export interface WordSequenceProps {
text: string;
/** stage gives each word the spotlight in turn, stack builds the sentence word by word, cut jumps between scales like film titles. */
variant?: WordSequenceVariant;
/** Seconds per word. */
interval?: number;
/** Seconds to hold the finished sentence before looping. */
hold?: number;
loop?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
const CUT_SCALES = [1, 0.55, 1.35, 0.8, 1.15];
/**
* A sentence choreographed word by word, then shown whole. Each variant is a different director:
* a spotlight, a builder, and a film editor.
*/
export function WordSequence({ text, variant = "stack", interval = 0.45, hold = 2, loop = true, as: Tag = "div", reducedMotion = "user", className, style }: WordSequenceProps) {
const { ref, on } = useTrigger({ trigger: "inView" });
const reduced = useReduced(reducedMotion);
const words = splitWords(text).map((w) => w.text);
const [step, setStep] = useState(0);
useEffect(() => {
if (!on || reduced) return;
const done = step >= words.length;
if (done && !loop) return;
const id = setTimeout(() => setStep((s) => (s >= words.length ? 0 : s + 1)), (done ? hold : interval) * 1000);
return () => clearTimeout(id);
}, [on, reduced, step, words.length, interval, hold, loop]);
if (reduced) {
return (
{text}
);
}
const finished = step >= words.length;
const current = Math.min(step, words.length - 1);
return (
{text}
{variant === "stack" ? (
{words.map((word, i) => (
{word}
))}
) : (
<>
{/* The full sentence holds the space and appears at the end of the sequence. */}
{text}
{!finished && (
{words[current]}
)}
>
)}
);
}
export default WordSequence;
```
Full library index: https://textlab.dev/llms.txt
---
# Read along
> A highlight follows the reading, word by word.
- Type: Component (kinetic)
- Page: https://textlab.dev/components/read-along
- Markdown: https://textlab.dev/components/read-along.md
- Registry item: https://textlab.dev/r/read-along.json
- Source file: `registry/textlab/read-along/read-along.tsx`
- Dependencies: motion
- Variants: highlight, karaoke
- Keywords: karaoke, read along, highlight, text to speech, captions, follow along
## When to use
- Audio and text-to-speech products.
- Language learning, lyrics and accessibility demos.
## When not to use
- Long articles; the pace is fixed.
- As a substitute for real synced captions.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/read-along
pnpm dlx shadcn@latest add @textlab/read-along
```
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/read-along.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 { ReadAlong } from "@/components/textlab/read-along/read-along";
export function Example() {
return ;
}
```
## API reference
### ReadAlong
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"highlight" \| "karaoke"` | `"highlight"` | A selection box that hops word to word, or a color fill through each word. |
| `wpm` | `number` | `220` | Reading pace in words per minute. |
| `color` | `string` | `var(--tl-accent)` | Highlight or fill color. |
| `loop` | `boolean` | `true` | Start over after a pause at the end. |
| `as` | `ElementType` | `"p"` | 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 paragraph is read once from a hidden copy.
- The highlight pauses when the paragraph leaves the viewport.
## Motion
- The selection box glides between words in 200 ms with a strong ease-out, moved and stretched by one transform.
- Longer words and punctuation get extra time, like natural reading.
## Reduced motion
Plain paragraph, no highlight.
## Performance
- One timer and one moving box; word boxes are read once per step.
## 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: highlight, karaoke.
## Related
- [Scroll fill](https://textlab.dev/components/scroll-fill.md): Words light up as you read down the page.
- [Caret morph](https://textlab.dev/components/caret-morph.md): A caret that glides between words and reshapes to fit.
- [Marker highlight](https://textlab.dev/components/marker-highlight.md): A highlighter swipes behind the words.
## Also in kinetic
- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
- [Word sequence](https://textlab.dev/components/word-sequence.md): A sentence choreographed word by word.
- [Echo text](https://textlab.dev/components/echo-text.md): Every change leaves fading echoes behind.
## Source
### components/textlab/read-along/read-along.tsx
```tsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ElementType } from "react";
import { motion } from "motion/react";
import { SrText } from "../lib/a11y";
import { ease } from "../lib/motion-tokens";
import { splitWords } from "../lib/split";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
import { useTrigger } from "../lib/use-trigger";
export type ReadAlongVariant = "highlight" | "karaoke";
export interface ReadAlongProps {
text: string;
/** highlight slides a selection box from word to word, karaoke fills each word with color as it is read. */
variant?: ReadAlongVariant;
/** Reading pace in words per minute. */
wpm?: number;
/** Highlight or fill color. */
color?: string;
loop?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* A selection-like highlight follows the reading, word by word, at a natural pace. Longer words get
* a little more time, and punctuation earns a pause.
*/
export function ReadAlong({ text, variant = "highlight", wpm = 220, color = "var(--tl-accent, #3a9a5b)", loop = true, as: Tag = "p", reducedMotion = "user", className, style }: ReadAlongProps) {
const { ref, on } = useTrigger({ trigger: "inView", once: false });
const reduced = useReduced(reducedMotion);
const words = splitWords(text);
const [index, setIndex] = useState(-1);
const [box, setBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const spans = useRef<(HTMLSpanElement | null)[]>([]);
useEffect(() => {
if (!on || reduced) return;
const word = words[index]?.text ?? "";
const base = 60000 / wpm;
const pause = /[.,;:!?]$/.test(word) ? base * 0.9 : 0;
const wait = index < 0 ? 400 : base * (0.7 + Math.min(word.length, 12) * 0.05) + pause;
const id = setTimeout(() => setIndex((i) => (i + 1 >= words.length ? (loop ? -1 : i) : i + 1)), index === words.length - 1 ? wait + 1400 : wait);
return () => clearTimeout(id);
}, [on, reduced, index, words, wpm, loop]);
useLayoutEffect(() => {
const root = ref.current;
const el = spans.current[index];
if (!el || !root) {
setBox(null);
return;
}
// Measure against the root's box: offsetLeft would be relative to whichever ancestor is positioned.
const measure = () => {
const origin = root.getBoundingClientRect();
const r = el.getBoundingClientRect();
setBox({ x: r.left - origin.left - root.clientLeft, y: r.top - origin.top - root.clientTop, w: r.width, h: r.height });
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [index, ref]);
if (reduced) {
return (
{text}
);
}
return (
{text}
{variant === "highlight" && box && (
)}
{words.map((word, i) => (
{
spans.current[i] = el;
}}
style={
variant === "karaoke"
? {
backgroundImage: `linear-gradient(90deg, ${color} 50%, currentColor 50%)`,
backgroundSize: "200% 100%",
backgroundPosition: i < index ? "0% 0" : i === index ? "0% 0" : "100% 0",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
transition: i === index ? `background-position ${(60 / wpm) * 0.9}s linear` : "none",
}
: undefined
}
>
{word.text}
{i < words.length - 1 ? " " : null}
))}
);
}
export default ReadAlong;
```
Full library index: https://textlab.dev/llms.txt
---
# Echo text
> Every change leaves fading echoes behind.
- Type: Component (kinetic)
- Page: https://textlab.dev/components/echo-text
- Markdown: https://textlab.dev/components/echo-text.md
- Registry item: https://textlab.dev/r/echo-text.json
- Source file: `registry/textlab/echo-text/echo-text.tsx`
- Dependencies: motion
- Variants: trail, ghost
- Keywords: echo, ghost, trail, afterimage, text change, transition
## When to use
- Status words, scores and mode labels where the change matters.
- Expressive headlines that cycle.
## When not to use
- Values that change many times a second; echoes pile up.
- Dense UI where trailing copies would overlap neighbours.
## Installation
### CLI
```bash
npx shadcn@latest add @textlab/echo-text
pnpm dlx shadcn@latest add @textlab/echo-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/echo-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 { EchoText } from "@/components/textlab/echo-text/echo-text";
export function Example() {
return ;
}
```
## API reference
### EchoText
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` (required) | `string` | – | The copy. |
| `variant` | `"trail" \| "ghost"` | `"trail"` | A row of offset copies that fade in sequence, or a single bloom outward. |
| `echoes` | `number` | `4` | Copies per change, for trail. |
| `live` | `boolean` | `true` | Announce changes politely. |
| `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 new text is announced politely; echoes are aria-hidden.
- Echoes never block pointer events.
## Motion
- Echoes drift down and right, each a beat behind the last, and fade over about 0.7 s.
- Only the last three changes keep echoes, so rapid updates stay clean.
## Reduced motion
The text swaps in place with no echoes.
## Performance
- A few short-lived motion spans per change, removed when they finish.
## 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: trail, ghost.
## Related
- [Text morph](https://textlab.dev/components/text-morph.md): One word turns into the next, letter by letter.
- [Word rotator](https://textlab.dev/components/word-rotator.md): Cycles words while the width follows.
- [Chromatic text](https://textlab.dev/components/chromatic-text.md): The color channels split and rejoin.
## Also in kinetic
- [Kinetic beat](https://textlab.dev/components/kinetic-beat.md): Words hit on a beat.
- [Word sequence](https://textlab.dev/components/word-sequence.md): A sentence choreographed word by word.
- [Read along](https://textlab.dev/components/read-along.md): A highlight follows the reading, word by word.
## Source
### components/textlab/echo-text/echo-text.tsx
```tsx
"use client";
import { useState, type CSSProperties, type ElementType } from "react";
import { AnimatePresence, motion } from "motion/react";
import { srOnly } from "../lib/a11y";
import { ease } from "../lib/motion-tokens";
import { useReduced, type ReducedMotion } from "../lib/use-reduced";
export type EchoTextVariant = "trail" | "ghost";
export interface EchoTextProps {
/** The current text. Each change leaves echoes of the old one. */
text: string;
/** trail drops a row of offset copies that fade in sequence; ghost blooms the old text outward as it fades. */
variant?: EchoTextVariant;
/** Number of echo copies per change. */
echoes?: number;
/** Announce changes to screen readers. */
live?: boolean;
as?: ElementType;
reducedMotion?: ReducedMotion;
className?: string;
style?: CSSProperties;
}
/**
* Every change leaves fading echoes behind. The new text arrives crisp while copies of the old one
* drift off and dissolve, so the change itself is visible for a moment.
*/
export function EchoText({ text, variant = "trail", echoes = 4, live = true, as: Tag = "span", reducedMotion = "user", className, style }: EchoTextProps) {
const reduced = useReduced(reducedMotion);
const [history, setHistory] = useState<{ id: number; text: string }[]>([]);
const [previous, setPrevious] = useState(text);
const [count, setCount] = useState(0);
// Track changes while rendering, the way React recommends for state that follows a prop.
if (text !== previous) {
setPrevious(text);
setCount((c) => c + 1);
setHistory((h) => [...h.slice(-2), { id: count + 1, text: previous }]);
}
if (reduced) {
return (
{text}
);
}
return (
{text}
{history.map((entry) =>
Array.from({ length: variant === "trail" ? echoes : 1 }, (_, k) => (
{
if (k === 0) setHistory((h) => h.filter((e) => e.id !== entry.id));
}}
>
{entry.text}
)),
)}
{text}
);
}
export default EchoText;
```
Full library index: https://textlab.dev/llms.txt