# Text Animate (/ui/components/react/text-animate)



<Playground component="text-animate" />

## Usage [#usage]

```tsx
import { TextAnimate } from '@appica/ui-react/text-animate'
```

```tsx
<TextAnimate effect="typewriter">Ship beautiful interfaces, fast.</TextAnimate>
```

`TextAnimate` splits a string into **units** — characters, words, or lines — and drives every unit
from a single `progress` value (`0 → 1`). One small engine therefore covers many effects: it ships
seven presets (`typewriter`, `scramble`, `rise`, `highlight`, `wave`, `flip`, `shimmer`) and accepts
a custom function for anything else.

It is deliberately **headless and font-agnostic**: it animates only `transform`, `opacity`, and the
displayed glyphs — never `font-size`, `font-weight`, `line-height`, or `letter-spacing`. So it
inherits whatever typography you set, and multi-line text wraps and breaks naturally.

### Two ways to drive it [#two-ways-to-drive-it]

The component never listens to scroll, viewport, or page load on its own — that keeps it composable.
You either let its built-in clock play the animation, or you own the timeline:

```tsx
<TextAnimate effect="scramble">Plays itself on mount</TextAnimate>

<TextAnimate effect="highlight" autoPlay={false} progress={scrollProgress}>
  You drive this from scroll, a slider, or any state
</TextAnimate>
```

When you pass `progress`, the internal clock is disabled and the component is fully controlled — wire
it to a scroll listener, an `IntersectionObserver`, a Motion `MotionValue`, or a slider. When you
omit `progress`, an opt-in `requestAnimationFrame` clock plays it once (`duration`, `delay`) or
forever (`loop`).

## Examples [#examples]

### Typewriter [#typewriter]

The classic effect: characters reveal one-by-one with a blinking caret on the typing edge. Hidden
characters take no space, so the line grows as it types.

```tsx
'use client'

import { useState } from 'react'
import { Button } from '@appica/ui-react/button'
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateDefault() {
  const [run, setRun] = useState(0)

  return (
    <div className="flex flex-col items-center gap-6">
      <TextAnimate
        key={run}
        effect="typewriter"
        duration={2}
        className="text-foreground-intense text-3xl font-semibold"
      >
        Ship beautiful interfaces, fast.
      </TextAnimate>
      <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
        Replay
      </Button>
    </div>
  )
}
```

### Scramble [#scramble]

Each character cycles through random glyphs before locking onto its target — the "decode" look.
Whitespace is left intact so word boundaries stay stable while the text resolves. Pairs nicely with a
monospace font.

```tsx
'use client'

import { useState } from 'react'
import { Button } from '@appica/ui-react/button'
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateScramble() {
  const [run, setRun] = useState(0)

  return (
    <div className="flex flex-col items-center gap-6">
      <TextAnimate
        key={run}
        effect="scramble"
        duration={1.6}
        className="text-foreground-intense font-mono text-3xl font-semibold"
      >
        Decoding the signal
      </TextAnimate>
      <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
        Replay
      </Button>
    </div>
  )
}
```

### Rise [#rise]

Each unit slides up **from behind a clip edge**, so the text wipes into view from its own baseline
rather than just translating — a masked reveal that's tedious to wire up by hand. It defaults to a
per-character cascade; switch to `by="word"` or `by="line"` for a chunkier reveal.

```tsx
'use client'

import { useState } from 'react'
import { Button } from '@appica/ui-react/button'
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateRise() {
  const [run, setRun] = useState(0)

  return (
    <div className="flex flex-col items-center gap-6">
      <TextAnimate key={run} effect="rise" duration={1.6} className="text-foreground-intense text-3xl font-semibold">
        Rising into view
      </TextAnimate>
      <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
        Replay
      </Button>
    </div>
  )
}
```

### Reveal on scroll into view [#reveal-on-scroll-into-view]

`highlight` ramps each **word** from dimmed to full. Here an `IntersectionObserver` plays the reveal
each time the paragraph scrolls into the panel (and resets it on the way out) — the observer is the
trigger, the component just animates. No animation library required.

```tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { ScrollArea } from '@appica/ui-react/scroll-area'
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateHighlight() {
  const viewportRef = useRef<HTMLDivElement>(null)
  const textRef = useRef<HTMLDivElement>(null)
  const [play, setPlay] = useState(0)

  useEffect(() => {
    const root = viewportRef.current
    const target = textRef.current
    if (!root || !target) return
    const observer = new IntersectionObserver(([entry]) => setPlay((n) => (entry?.isIntersecting ? n + 1 : 0)), {
      root,
      threshold: 0.8,
    })
    observer.observe(target)
    return () => observer.disconnect()
  }, [])

  return (
    <ScrollArea className="border-border h-72 w-full max-w-md rounded-lg border" viewportProps={{ ref: viewportRef }}>
      <div className="p-6">
        <div className="h-72" />
        <div ref={textRef}>
          <TextAnimate
            key={play}
            effect="highlight"
            duration={1.4}
            className="text-foreground-intense text-xl leading-relaxed font-medium"
            {...(play > 0 ? {} : { autoPlay: false, progress: 0 })}
          >
            Scroll this paragraph into view and it reveals itself, word by word.
          </TextAnimate>
        </div>
        <div className="h-44" />
      </div>
    </ScrollArea>
  )
}
```

### Drive it yourself [#drive-it-yourself]

`progress` accepts any `0 → 1` value, so you can scrub the animation from a slider, a gesture, or app
state — wire it to a scroll position for a true scrub-as-you-scroll effect, or to any source you like.

```tsx
'use client'

import { useState } from 'react'
import { Slider } from '@appica/ui-react/slider'
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateControlled() {
  const [progress, setProgress] = useState(0.5)

  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-8">
      <TextAnimate
        effect="flip"
        autoPlay={false}
        progress={progress}
        className="text-foreground-intense text-4xl font-semibold"
      >
        Scrub me
      </TextAnimate>
      <Slider
        className="w-full"
        value={progress}
        onValueChange={(value) => setProgress(value as number)}
        min={0}
        max={1}
        step={0.01}
        tooltipVisibility="never"
        thumbAriaLabel="Animation progress"
      />
    </div>
  )
}
```

### Continuous effects [#continuous-effects]

`wave` and `shimmer` are continuous: their presets loop the clock automatically, so they animate on
mount with no driver wiring. `wave` ripples each character vertically (amplitude in `em`, so it
scales with the font); `shimmer` sweeps a bright band along the string.

```tsx
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateKinetic() {
  return (
    <div className="text-foreground-intense flex flex-col items-center gap-6 text-3xl font-semibold">
      <TextAnimate effect="wave">Riding the wave</TextAnimate>
      <TextAnimate effect="shimmer">Shimmering headline</TextAnimate>
    </div>
  )
}
```

### Custom effects [#custom-effects]

Pass a function instead of a preset name. It receives each unit's **local** progress and a context
object, and returns inline `style` (and/or replacement `content`). Everything the presets do, you can
do — here a per-character blur-and-rise entrance.

```tsx
'use client'

import { useState } from 'react'
import { Button } from '@appica/ui-react/button'
import { TextAnimate, type TextAnimateEffect } from '@appica/ui-react/text-animate'

const blurRise: TextAnimateEffect = (p) => ({
  style: {
    display: 'inline-block',
    opacity: p,
    filter: `blur(${((1 - p) * 6).toFixed(2)}px)`,
    transform: `translateY(${((1 - p) * 0.5).toFixed(3)}em)`,
  },
})

export default function TextAnimateCustom() {
  const [run, setRun] = useState(0)

  return (
    <div className="flex flex-col items-center gap-6">
      <TextAnimate
        key={run}
        effect={blurRise}
        by="char"
        stagger={0.5}
        duration={1.6}
        className="text-foreground-intense text-3xl font-semibold"
      >
        Roll your own effect
      </TextAnimate>
      <Button variant="outline" size="sm" onClick={() => setRun((n) => n + 1)}>
        Replay
      </Button>
    </div>
  )
}
```

For continuous effects, read `ctx.globalProgress` (the raw, un-staggered driver value) and
`ctx.index` instead of the first argument — that is how `wave` and `shimmer` phase each character.

### Multiple lines [#multiple-lines]

Use `\n` for explicit line breaks. Words still wrap within each line, and the stagger flows across
every line in reading order.

```tsx
import { TextAnimate } from '@appica/ui-react/text-animate'

export default function TextAnimateMultiline() {
  return (
    <TextAnimate
      effect="highlight"
      by="word"
      loop
      duration={4}
      className="text-foreground-intense max-w-md text-center text-xl leading-relaxed font-medium"
    >
      {'Line breaks are preserved.\nWords wrap and stagger\nacross every line you give it.'}
    </TextAnimate>
  )
}
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a
container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your
tree in `DirectionProvider` so direction-aware behavior follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

Word and line units follow the resolved direction automatically. For setup details and caveats, see
the [RTL guide](/ui/docs/react/rtl).

<Callout type="warning" title="Use word or line splitting for cursive scripts">
  `by="char"` wraps every character in its own element, which breaks the contextual shaping of cursive scripts like
  Arabic (letters stop joining). For Arabic, Persian, and similar scripts, use `by="word"` or `by="line"` so each shaped
  unit stays intact — as the demo below does.
</Callout>

<RtlPreview component="text-animate" />

## API reference [#api-reference]

Renders a `<span>` wrapping the animated text. The full string is always present as an `sr-only` copy
for assistive tech; the animated glyphs are `aria-hidden`.

| Prop       | <ColMinWidth width="210">Type</ColMinWidth>             | Default        | <ColMinWidth width="220">Description</ColMinWidth>                                                                             |
| ---------- | ------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `children` | `string`                                                | —              | The text to animate. Use `\n` for explicit line breaks.                                                                        |
| `effect`   | <Code>TextAnimateEffectName \| TextAnimateEffect</Code> | `'typewriter'` | A preset name (`typewriter`, `scramble`, `rise`, `highlight`, `wave`, `flip`, `shimmer`) or a custom `(progress, ctx) => {…}`. |
| `by`       | <Code>'char' \| 'word' \| 'line'</Code>                 | preset's level | Segmentation level. Defaults to the preset's natural level (e.g. `word` for `highlight`).                                      |
| `progress` | `number`                                                | —              | Controlled driver value (`0 → 1`). When set, the internal clock is disabled and you own the timeline.                          |
| `autoPlay` | `boolean`                                               | `true`         | Run the built-in clock when `progress` is not provided.                                                                        |
| `loop`     | `boolean`                                               | preset's value | Loop the built-in clock. Continuous presets (`wave`, `shimmer`) default to `true`.                                             |
| `duration` | `number`                                                | `1.6`          | Built-in clock length in **seconds**.                                                                                          |
| `delay`    | `number`                                                | `0`            | Built-in clock start delay in **seconds**.                                                                                     |
| `stagger`  | `number`                                                | preset's value | How offset each unit's window is from its neighbour's, `0 → 1`. `0` = all together; `1` = fully sequential.                    |

Forwards `ref`, `className`, and any other `<span>` attributes. The root is `inline-block` — override
`display` via `className` (e.g. `block`) if you need it.

### The effect function [#the-effect-function]

A custom `effect` is `(progress, ctx) => { style?, className?, content? }`, called once per unit per
frame:

| Field                | <ColMinWidth width="180">Type</ColMinWidth> | Description                                                                                  |
| -------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `progress`           | `number`                                    | The unit's **local** progress (`0 → 1`), already staggered. Most entrance effects use this.  |
| `ctx.index`          | `number`                                    | Global unit index, in reading order.                                                         |
| `ctx.total`          | `number`                                    | Total number of units.                                                                       |
| `ctx.text`           | `string`                                    | The unit's text (a character, word, or line).                                                |
| `ctx.by`             | <Code>'char' \| 'word' \| 'line'</Code>     | The active segmentation level.                                                               |
| `ctx.globalProgress` | `number`                                    | Raw, un-staggered driver value. Use for continuous effects (wave, shimmer) plus `ctx.index`. |
| `ctx.reduced`        | `boolean`                                   | `true` when the user prefers reduced motion — return a static, legible frame.                |

Return `style`/`className` to animate the glyph and/or `content` to replace the displayed text (how
`scramble` and `typewriter` mutate characters).

## Accessibility [#accessibility]

* The complete text is always rendered as an `sr-only` copy, so screen readers announce it once, in
  full — regardless of animation state. The animated glyphs are `aria-hidden`.
* The animation honours `prefers-reduced-motion` (and `ReducedMotionProvider`): the built-in clock
  snaps straight to the final frame, the caret stops blinking, and motion-heavy presets (`wave`,
  `flip`, `shimmer`) render their static, fully-legible state.
* A controlled `progress` is **not** overridden by reduced motion — a scroll-linked reveal keeps
  working — but the motion-heavy presets still drop their transforms for those users.
* `by="char"` breaks the contextual shaping of cursive scripts (e.g. Arabic); use `by="word"` or
  `by="line"` there.
* Because every unit re-renders each frame, keep animated strings to headline/sentence length rather
  than long bodies of text.
