---
title: "Text motion"
description: "Splits a string into characters, words or lines and staggers a preset reveal across them."
canonical_url: "https://nuxt-motion-block.pages.dev/components/text/text-effect"
---
# Text motion

> Splits a string into characters, words or lines and staggers a preset reveal across them.

Takes a plain string and splits it into segments — characters, words or lines —
then reveals each one in sequence. It is the general-purpose version of the
family: if you want a specific motion (blur, scramble, roll, wave) there is a
dedicated component, and this is the one to reach for when the difference
between those motions does not matter.

Two independent rates shape the result. `speedReveal` sets the stagger between
segments — the built-in defaults are `0.03s` per character, `0.05s` per word and
`0.1s` per line, divided by `speedReveal`. `speedSegment` sets how long each
segment takes on its own. Text that should feel punchy wants a high
`speedSegment` and a low `speedReveal`; text that should feel deliberate wants
the opposite.

The character mode groups by word so that lines still wrap at word boundaries.
Each character gets its own `overflow: hidden` wrapper, which is what lets a
`y`-based preset appear to rise out of the baseline rather than slide as a whole
paragraph.

## Usage

Pass the text as `children` and pick a segmentation. The component renders as
`as` (a `p` by default) and passes `class` to that element, so it drops into
existing copy without a wrapper.

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="text-effect-basic">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <MTextEffect
    children="Split the string, then stagger the reveal."
    per="char"
    preset="blur"
    as="p"
    class="text-xl font-semibold text-default"
  />
</template>
```

</template>
</code-preview>

## Examples

### Switch the segmentation and preset

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="text-effect-complex">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <div class="flex w-full max-w-md flex-col gap-5">
    <div class="rounded-2xl border border-default bg-default p-6">
      <p class="mb-3 text-xs font-medium uppercase tracking-widest text-muted">
        {{ per }} · {{ preset }}
      </p>
      <MTextEffect
        :key="run"
        :children="copy"
        :per="per"
        :preset="preset"
        as="span"
        class="text-xl font-semibold text-default"
        :speed-reveal="1.4"
      />
    </div>

    <div class="grid grid-cols-2 gap-3">
      <USelect
        v-model="per"
        :items="perOptions"
      />
      <USelect
        v-model="preset"
        :items="presetOptions"
      />
    </div>

    <UButton
      label="Replay"
      icon="i-lucide-rotate-ccw"
      variant="outline"
      class="justify-center"
      @click="run++"
    />
  </div>
</template>

<script setup lang="ts">
const copy = 'Split the string, then stagger the reveal.'
const run = ref(0)
const per = ref<'word' | 'char' | 'line'>('char')
const preset = ref<'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide'>('blur')

const perOptions = ['char', 'word', 'line']
const presetOptions = ['blur', 'fade-in-blur', 'scale', 'fade', 'slide']
</script>
```

</template>
</code-preview>

### Control the timing

`speedReveal` sets the gap between segments while `speedSegment` sets how long
each segment takes, and `delay` holds the whole reveal back. Combine them with
`per="line"` for a deliberate, multi-line statement; `trigger` hides the tree
entirely instead of freezing it.

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="text-effect-timing">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <div class="flex w-full max-w-md flex-col gap-6">
    <div class="rounded-2xl border border-default bg-default p-6">
      <MTextEffect
        :key="run"
        :children="copy"
        per="line"
        preset="fade-in-blur"
        as="h2"
        class="text-2xl font-semibold leading-snug text-default"
        :delay="0.4"
        :speed-reveal="3"
        :speed-segment="0.5"
        :trigger="playing"
      />
    </div>

    <div class="flex items-center justify-center gap-3">
      <UButton
        label="Replay"
        icon="i-lucide-rotate-ccw"
        variant="outline"
        @click="run++"
      />
      <UButton
        :label="playing ? 'Hide' : 'Show'"
        :icon="playing ? 'i-lucide-eye-off' : 'i-lucide-eye'"
        variant="outline"
        @click="playing = !playing"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
const run = ref(0)
const playing = ref(true)
const copy = 'Build faster.\nShip sooner.\nSleep better.'
</script>
```

</template>
</code-preview>

### Custom variants and transitions

`variants` replaces the generated ones, so supply your own `container` stagger
and `item` hidden/visible pair. `containerTransition` and `segmentTransition`
merge onto the container and each segment respectively, and
`segmentWrapperClassName` reshapes the per-word wrapper that `char` mode
introduces.

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="text-effect-variants">



</component-example>
</div>

<template v-slot:code="">

```vue
<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <MTextEffect
      :key="run"
      :children="copy"
      per="char"
      as="span"
      class="text-3xl font-black uppercase tracking-tight text-primary"
      :variants="{
        container: {
          hidden: { opacity: 1 },
          visible: { opacity: 1, transition: { staggerChildren: 0.025 } },
        },
        item: {
          hidden: { opacity: 0, y: '0.5em' },
          visible: { opacity: 1, y: 0 },
        },
      }"
      :container-transition="{ duration: 0.3 }"
      :segment-transition="{ type: 'spring', stiffness: 420, damping: 24 }"
      segment-wrapper-class-name="overflow-visible"
    />

    <UButton
      label="Replay"
      icon="i-lucide-rotate-ccw"
      variant="outline"
      class="self-center"
      @click="run++"
    />
  </div>
</template>

<script setup lang="ts">
const run = ref(0)
const copy = 'Custom variants'
</script>
```

</template>
</code-preview>

## Customize

<code-preview label="Preview">
<div className="w-full">
<component-example name="text-effect-custom">



</component-example>
</div>

<template v-slot:code="">
<playground-code name="TextEffect">



</playground-code>
</template>
</code-preview>

## Props

<component-props>
<component-prop name="children" type="string" default="''">

The text to animate. It is split on whitespace for `word`, on newlines for
`line`, and per character for `char` — there is no slot, so a multi-line
string in `line` mode needs real `\n` characters.

</component-prop>

<component-prop name="per" type="'word' | 'char' | 'line'" default="'word'">

Segmentation unit. Each unit sets its own default stagger: `0.03s` for a
character, `0.05s` for a word, `0.1s` for a line.

</component-prop>

<component-prop name="preset" type="'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide'" default="'fade'">

The motion applied to each segment. `fade` is opacity only; `slide` and
`fade-in-blur` travel 20px upward and `blur`/`fade-in-blur` resolve a 12px
filter. An unrecognised value falls back to `fade` rather than throwing.

</component-prop>

<component-prop name="as" type="string" default="'p'">

Element to render as.

</component-prop>

<component-prop name="delay" type="number" default="0">

Seconds before the first segment animates, applied as the container's
`delayChildren`.

</component-prop>

<component-prop name="speedReveal" type="number" default="1">

Multiplier on the between-segment stagger. Higher is tighter. A value of `0`
collapses the stagger to zero and the segments animate together.

</component-prop>

<component-prop name="speedSegment" type="number" default="1">

Multiplier on each segment's own duration, which the source derives as
`(1 / speedSegment) * 0.4` seconds.

</component-prop>

<component-prop name="trigger" type="boolean" default="true">

Whether the animated tree renders at all. Set it to `false` and the root
element is empty — the text does not appear as plain text — so it is a switch
for "show the animation" versus "show nothing", not a way to disable motion.
To replay, remount the component (a changing `:key` is enough).

</component-prop>

<component-prop name="class" type="string">

Merged onto the root element, which carries `overflow: hidden`, `display: inline-flex`
and `flex-wrap: wrap` inline so a `char` preset cannot leak outside the element.

</component-prop>

<component-prop name="variants" type="{ container?: Record<string, any>, item?: Record<string, any> }">

Motion variants that replace the generated ones. `container` replaces the
stagger orchestration entirely — supply your own `staggerChildren` if you do,
since the computed `speedReveal` and `delay` no longer apply. `item` replaces
the preset's hidden/visible pair.

</component-prop>

<component-prop name="containerTransition" type="any">

Transition merged onto the container `Motion`. Only reaches the container, not
the segments.

</component-prop>

<component-prop name="segmentTransition" type="any">

Transition merged onto every segment `Motion`, applied after the preset's own
duration, so it wins where the two overlap.

</component-prop>

<component-prop name="segmentWrapperClassName" type="string">

Class on the per-word wrapper that `char` mode introduces around each word.
The wrapper is what carries `overflow: hidden` in `char` mode; changing it
away from that can make `slide` and `fade-in-blur` look like they slide in
from nowhere.

</component-prop>
</component-props>

## UI

This component's theme exposes the slots below. Override any of them per instance with the [`ui` prop](https://nuxt-motion-block.pages.dev/getting-started/theming), or app-wide under `ui.textEffect` in `app.config.ts`.

<component-props>
<component-prop name="ui" type="object">

Per-slot classes merged over the theme defaults.

</component-prop>
</component-props>

<component-slots>
<component-slot-row name="root">



</component-slot-row>

<component-slot-row name="container">



</component-slot-row>

<component-slot-row name="segmentWrapper">



</component-slot-row>

<component-slot-row name="segment">



</component-slot-row>
</component-slots>

## Accessibility

The component renders ordinary text inside a real element, so a screen reader
announces it as text and it needs no ARIA. Two caveats come from the DOM shape:
each segment is its own element, so a copied selection carries one element per
character in `char` mode, and `trigger="false"` removes the content from the
accessibility tree entirely rather than rendering it statically.

There is no reduced-motion handling in the source. Under
`prefers-reduced-motion: reduce` the reveal still plays. Set `trigger="false"`
and render your own plain text if you need a non-animated fallback.

## Performance

- Segments are computed once per `children`/`per` change, not per frame; the
animation runs on transforms and opacity.
- `char` mode builds one extra wrapper element per word, so the element count is
roughly `characters + words`. `word` mode is the cheapest option; `char` on a
long paragraph is not.
- The blur presets animate a `filter`, which is composited but not free — a long
string in `blur` or `fade-in-blur` does more work per frame than `fade`.
- Changing `speedReveal` or `speedSegment` recomputes the variants and the
stagger, but no animation state is reset, so it can be driven live.

<tip>

`speedSegment` is not "speed" in the intuitive sense. It only sets each segment's
own duration, while `speedReveal` sets the gap between segments — so raising
`speedSegment` makes every segment take longer but the overall run *longer and
more deliberate*, not faster. Raise `speedReveal` when you want it to feel
brisk.

</tip>


## Sitemap

See the full [sitemap](https://nuxt-motion-block.pages.dev/sitemap.md) for all pages.
