# Text effects

> Small motion effects for words on marketing and product pages, a flipping phrase, a shimmer, a marquee, a word by word reveal and handwritten notes and marks. All of them hold still under reduced motion and never split Arabic into letters.

Source: https://docs.nasaqui.com/components/text-effects

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/text-effects.json
```

Six effects that give text a little life. Each one keeps the real text in the page for search engines and screen readers, and each one holds still when the visitor
prefers reduced motion. Arabic and other joining scripts are cut into words, never letters, because a letter animated alone breaks the joins and reads wrongly.

| Effect | Use it for |
| --- | --- |
| `TextFlip` | One word or phrase in a headline that rotates ("Built for teams / freelancers / clinics"). |
| `TextShimmer` | A light sweep across one line: a status such as "Thinking", or a highlighted word. For AI streaming states use `AiShimmer`. |
| `Marquee` | A row of logos or short items that slides sideways without end. |
| `TextReveal` | A paragraph or headline that appears word by word when it scrolls into view. |
| `HandwrittenNote` | A sticky note with a tape strip, for an aside or an annotation on a screenshot. |
| `HandwrittenMark` | A drawn underline, circle, highlight or strike over a few words. |

## When to use

- Marketing pages, empty states and onboarding where one moving element helps the eye.
- One effect per screen area. Two flipping phrases in one headline compete.

## When not to use

- Body text, tables, forms and anything read closely: motion there is a cost.
- Meaning that only motion carries. Every effect has static text that says the same thing.
- Streaming AI output: use the states in [`ai-states`](https://docs.nasaqui.com/components/ai-states).

## Import

```tsx
import { TextFlip, TextShimmer, Marquee, TextReveal, HandwrittenNote, HandwrittenMark, splitText } from "@fadymondy/nasaq/web";
```

## Quick start

```tsx
import { TextFlip } from "@fadymondy/nasaq/web";

export const Headline = () => (
  <h1>
    Run your clinic like a <TextFlip phrases={["team", "studio", "practice"]} />
  </h1>
);
```

## Anatomy

```
TextFlip         data-slot="text-flip"      visible phrase (aria-hidden) + one sr-only list of all phrases
TextShimmer      data-slot="text-shimmer"   text with a moving gradient clipped to it
Marquee          data-slot="marquee"        track with the children repeated; extra copies are aria-hidden and inert
TextReveal       data-slot="text-reveal"    real text for assistive tech + animated word spans (aria-hidden)
HandwrittenNote  data-slot="handwritten-note"   aside with a tape strip and an optional author
HandwrittenMark  data-slot="handwritten-mark"   the words plus an SVG stroke that draws itself
```

## API

### TextFlip

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `phrases` | `readonly string[]` | none | The words to rotate through. Give at least two. |
| `interval` | `number` | `2600` | Milliseconds each phrase stays. |
| `by` | `"word" \| "grapheme"` | `"word"` | How the phrase splits for the flip. Joining scripts always use words. |
| `paused` | `boolean` | `false` | Stop rotating. Hover and focus also stop it. |
| `loop` | `boolean` | `true` | Go back to the first phrase; when `false` it stops on the last. |
| `onIndexChange` | `(index: number) => void` | none | The visible phrase changed. |
| `lang` | `string` | active locale | Language of the phrases, for splitting. |

### TextShimmer

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | none | The text. |
| `duration` | `number` | `2.4` | Seconds per sweep. |
| `paused` | `boolean` | `false` | Hold still. |

The sweep runs in the reading direction (right to left in Arabic).

### Marquee

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `speed` | `number` | `48` | Pixels per second. |
| `direction` | `"start" \| "end"` | `"start"` | Which way items travel, logical so it flips in RTL. |
| `pauseOnHover` | `boolean` | `true` | Pause while the pointer or focus is inside. |
| `paused` | `boolean` | `false` | Hold still. |
| `gap` | `number` | `32` | Pixels between items. |
| `fade` | `boolean` | `true` | Fade the edges. |

Under reduced motion the items wrap into a static row.

### TextReveal

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | none | The text. |
| `by` | `"word" \| "grapheme"` | `"word"` | Grapheme is ignored for Arabic. |
| `as` | `"p" \| "h1".."h6" \| "span" \| "div"` | `"span"` | Element to render. |
| `immediate` | `boolean` | `false` | Play now instead of when scrolled into view. |
| `step` | `number` | `45` | Milliseconds between units (capped to a total). |
| `lang` | `string` | active locale | Language of the text. |

### HandwrittenNote

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `tone` | `"note" \| "info" \| "success" \| "brand" \| "neutral"` | `"note"` | Paper colour from the tag tokens. |
| `rotate` | `number` | `-2` | Tilt in degrees. |
| `tape` | `boolean` | `true` | The tape strip. |
| `author` | `ReactNode` | none | A signature line. |

Set `--nq-font-handwriting` to load a handwriting face you are licensed to use; the default is the system cursive stack. No font file ships with Nasaq.

### HandwrittenMark

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `kind` | `"underline" \| "circle" \| "highlight" \| "strike"` | `"underline"` | The stroke. |
| `tone` | `"brand" \| "danger" \| "warning" \| "success" \| "info"` | `"brand"` | Stroke colour. |
| `animate` | `boolean` | `true` | Draw the stroke when it mounts; drawn at once under reduced motion. |
| `delay` | `number` | `0` | Milliseconds before drawing. |

### Helpers (pure, no React)

`splitText(text, mode, locale)` returns tokens that join back to the input. `countTextTokens`, `revealTextTokens`, `nextFlipIndex`, `marqueeDuration`,
`marqueeCopies` and `textStaggerDelay` are the maths behind the effects.

## Examples

```tsx
import { Marquee, TextReveal } from "@fadymondy/nasaq/web";

export const Logos = () => (
  <Marquee speed={40}>
    {["Seatfor", "Mahaam", "Zekra", "Hosbah"].map((n) => <span key={n} className="text-h3">{n}</span>)}
  </Marquee>
);
export const Story = () => <TextReveal as="p" text="Nasaq draws the same product in every language." />;
```

## Accessibility

- Screen readers get the full text once: `TextFlip` lists every phrase in visually hidden text, `TextReveal` keeps the real string, `Marquee` hides its duplicate copies.
- Rotation and sliding stop while a person hovers or focuses inside, so they can read and click.
- Reduced motion: `TextFlip` shows the first phrase, `TextShimmer` and `Marquee` hold still, `TextReveal` and `HandwrittenMark` show the finished result at once.
- No effect carries information that the plain text does not.

## RTL & i18n

- Arabic and other joining scripts split into words (`hasJoiningScript`), never letters.
- The shimmer and the marquee follow the reading direction; the marquee direction is logical (`start` / `end`).
- Numbers inside phrases stay in their own order; use `<bdi>` around codes.

## Styling & tokens

Colours come from `--nq-*` tokens and the tag colours (notes and marks). Target the `data-slot` values above, or use `className` and `style`.

## Do / Don't

- Do keep phrases of similar length so the headline does not jump.
- Do use `HandwrittenMark` once or twice a page; it is a highlighter, not a texture.
- Don't flip long sentences; flip a word or a short phrase.
- Don't put a marquee of important links where people must click them while they move.

## Related

- [`ai-states`](https://docs.nasaqui.com/components/ai-states)
- [`screenshot-frame`](https://docs.nasaqui.com/components/screenshot-frame)

## Lab

https://docs.nasaqui.com/?path=/docs/components-typography-text-effects--docs

## Code

### React

```tsx
import { TextFlip } from "@fadymondy/nasaq/web";

export const Headline = () => (
  <h1>
    Run your clinic like a <TextFlip phrases={["team", "studio", "practice"]} />
  </h1>
);
```

### shadcn

```tsx
import { TextFlip } from "@/components/ui/text-effects";

export const Headline = () => (
  <h1>
    Run your clinic like a <TextFlip phrases={["team", "studio", "practice"]} />
  </h1>
);
```

### Vue

```vue
<script setup lang="ts">
import { NqHandwrittenMark, NqHandwrittenNote, NqMarquee, NqTextFlip, NqTextReveal, NqTextShimmer } from "@fadymondy/nasaq/vue";
</script>

<template>
  <div class="flex flex-col items-start gap-6">
    <h2 class="text-h2">Ship <NqTextFlip :phrases="['faster', 'safer', 'together']" /></h2>
    <NqTextShimmer>Thinking it through</NqTextShimmer>
    <NqTextReveal text="Words fade and rise into place as you scroll." immediate />
    <p>Mark <NqHandwrittenMark kind="underline">the important part</NqHandwrittenMark> by hand.</p>
    <NqHandwrittenNote author="Fady">Remember to say thanks.</NqHandwrittenNote>
    <NqMarquee class="w-full"><span class="px-4">Fast</span><span class="px-4">Bilingual</span><span class="px-4">Accessible</span></NqMarquee>
  </div>
</template>
```

### Blade

```blade
<div class="flex flex-col items-start gap-6">
    <h2 class="text-h2">Ship <x-nq::text-effects.text-flip id="fx-flip" :phrases="['faster', 'safer', 'together']" /></h2>
    <x-nq::text-effects.text-shimmer id="fx-shimmer">Thinking it through</x-nq::text-effects.text-shimmer>
    <x-nq::text-effects.text-reveal id="fx-reveal" text="Words fade and rise into place as you scroll." immediate />
    <x-nq::text-effects.text-reveal id="fx-reveal-scroll" text="Hidden until seen" />
    <p>Mark <x-nq::text-effects.handwritten-mark id="fx-mark" kind="underline">the important part</x-nq::text-effects.handwritten-mark> by hand.</p>
    <p>And <x-nq::text-effects.handwritten-mark id="fx-mark-still" kind="circle" :animate="false">this</x-nq::text-effects.handwritten-mark> too.</p>
    <x-nq::text-effects.handwritten-note id="fx-note" author="Fady">Remember to say thanks.</x-nq::text-effects.handwritten-note>
    <x-nq::text-effects.marquee id="fx-marquee" class="w-full"><span class="px-4">Fast</span><span class="px-4">Bilingual</span><span class="px-4">Accessible</span></x-nq::text-effects.marquee>
</div>
```

### HTML + Alpine

```html
<div class="flex flex-col items-start gap-6">
    <h2 class="text-h2">Ship <span data-slot="text-flip" x-data="nqTextFlip(JSON.parse('{\u0022interval\u0022:2600,\u0022loop\u0022:true,\u0022paused\u0022:false}'))" x-modelable="paused" x-on:pointerenter="halted = true" x-on:pointerleave="halted = false" x-on:focusin="halted = true"
    x-on:focusout="halted = false"  id="fx-flip" class="relative inline-grid align-baseline [perspective:600px]">
    <span class="sr-only">faster, safer, together</span>
                    <span data-phrase aria-hidden="true"  data-active  class="col-start-1 row-start-1 whitespace-nowrap [transform-style:preserve-3d]"><span data-token data-delay="0" class="inline-block [backface-visibility:hidden]" style="opacity: 1; transform: none; transition-property: transform, opacity; transition-duration: 420ms, 320ms; transition-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1); transition-delay: 240ms">faster</span></span>
                    <span data-phrase aria-hidden="true"  class="col-start-1 row-start-1 whitespace-nowrap [transform-style:preserve-3d]"><span data-token data-delay="0" class="inline-block [backface-visibility:hidden]" style="opacity: 0; transform: rotateX(-90deg) translateY(0.35em); transition-property: transform, opacity; transition-duration: 240ms, 240ms; transition-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1); transition-delay: 0ms">safer</span></span>
                    <span data-phrase aria-hidden="true"  class="col-start-1 row-start-1 whitespace-nowrap [transform-style:preserve-3d]"><span data-token data-delay="0" class="inline-block [backface-visibility:hidden]" style="opacity: 0; transform: rotateX(-90deg) translateY(0.35em); transition-property: transform, opacity; transition-duration: 240ms, 240ms; transition-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1); transition-delay: 0ms">together</span></span>
    </span></h2>
    <span data-slot="text-shimmer" x-data="nqTextShimmer(JSON.parse('{\u0022duration\u0022:2.4,\u0022paused\u0022:false}'))" x-modelable="paused" data-reduced-class="inline-block text-foreground"
     id="fx-shimmer" class="inline-block bg-clip-text text-transparent [-webkit-text-fill-color:transparent] [background-size:250%_100%] [background-image:linear-gradient(100deg,var(--nq-fg-muted)_35%,var(--nq-fg)_50%,var(--nq-fg-muted)_65%)] forced-colors:bg-none forced-colors:[-webkit-text-fill-color:currentColor]">Thinking it through</span>    <span data-slot="text-reveal" data-split="word"  data-shown   x-data="nqTextReveal(JSON.parse('{\u0022immediate\u0022:true}'))"
    id="fx-reveal" class=""><span class="sr-only">Words fade and rise into place as you scroll.</span><span data-visual aria-hidden="true"><span data-token data-delay="0" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 0ms">Words</span> <span data-token data-delay="45" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 45ms">fade</span> <span data-token data-delay="90" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 90ms">and</span> <span data-token data-delay="135" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 135ms">rise</span> <span data-token data-delay="180" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 180ms">into</span> <span data-token data-delay="225" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 225ms">place</span> <span data-token data-delay="270" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 270ms">as</span> <span data-token data-delay="315" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 315ms">you</span> <span data-token data-delay="360" class="inline-block" style="opacity: 1; transform: none; filter: none; transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 360ms">scroll.</span></span></span>    <span data-slot="text-reveal" data-split="word"   x-data="nqTextReveal(JSON.parse('{\u0022immediate\u0022:false}'))"
    id="fx-reveal-scroll" class=""><span class="sr-only">Hidden until seen</span><span data-visual aria-hidden="true"><span data-token data-delay="0" class="inline-block" style="opacity: 0; transform: translateY(0.4em); filter: blur(4px); transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 0ms">Hidden</span> <span data-token data-delay="45" class="inline-block" style="opacity: 0; transform: translateY(0.4em); filter: blur(4px); transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 45ms">until</span> <span data-token data-delay="90" class="inline-block" style="opacity: 0; transform: translateY(0.4em); filter: blur(4px); transition: opacity 420ms ease-out, transform 420ms ease-out, filter 420ms ease-out; transition-delay: 90ms">seen</span></span></span>    <p>Mark <span data-slot="handwritten-mark" data-kind="underline"  x-data="nqHandwrittenMark(JSON.parse('{\u0022animate\u0022:true,\u0022delay\u0022:0}'))"
    id="fx-mark" class="relative inline-block text-nq-brand">
    <span class="relative z-10 text-foreground">the important part</span>
    <svg aria-hidden="true" focusable="false" viewBox="0 0 100 20" preserveAspectRatio="none" class="pointer-events-none absolute overflow-visible inset-x-[-2%] -bottom-[0.3em] h-[0.5em] z-20">
        <path d="M2 12 C 18 6, 34 16, 52 9 S 84 8, 98 11" pathLength="1" fill="none" stroke="currentColor" stroke-opacity="1" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"
            style="stroke-width: 0.09em; stroke-dasharray: 1; stroke-dashoffset: 1; transition: none"></path>
    </svg>
</span> by hand.</p>
    <p>And <span data-slot="handwritten-mark" data-kind="circle"  data-drawn  x-data="nqHandwrittenMark(JSON.parse('{\u0022animate\u0022:false,\u0022delay\u0022:0}'))"
    id="fx-mark-still" class="relative inline-block text-nq-brand">
    <span class="relative z-10 text-foreground">this</span>
    <svg aria-hidden="true" focusable="false" viewBox="0 0 100 42" preserveAspectRatio="none" class="pointer-events-none absolute overflow-visible -inset-x-[0.5em] -inset-y-[0.35em] z-20">
        <path d="M50 4 C 82 2, 98 12, 96 22 C 94 34, 60 39, 40 38 C 12 36, 2 27, 5 16 C 9 6, 34 3, 62 4" pathLength="1" fill="none" stroke="currentColor" stroke-opacity="1" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"
            style="stroke-width: 0.07em; stroke-dasharray: 1; stroke-dashoffset: 0; transition: none"></path>
    </svg>
</span> too.</p>
    <aside data-slot="handwritten-note" style="transform: rotate(-2deg); "
    id="fx-note" class="relative inline-block max-w-xs rounded-[3px] border px-4 pb-3 pt-5 text-foreground shadow-floating bg-nq-warning-soft border-nq-warning/40">
            <span aria-hidden="true" class="absolute inset-x-0 -top-2.5 mx-auto h-5 w-16 rotate-2 bg-[color-mix(in_oklab,var(--nq-fg)_14%,transparent)]"></span>
        <div class="text-h3 leading-snug" style="font-family: var(--nq-font-handwriting, &quot;Bradley Hand&quot;, &quot;Segoe Print&quot;, &quot;Segoe Script&quot;, &quot;Comic Sans MS&quot;, &quot;Noto Naskh Arabic&quot;, cursive)">Remember to say thanks.</div>
            <div class="mt-2 text-caption text-muted-foreground">Fady</div>
    </aside>
    <div data-slot="marquee" x-data="nqMarquee(JSON.parse('{\u0022speed\u0022:48,\u0022direction\u0022:\u0022start\u0022,\u0022pauseOnHover\u0022:true,\u0022paused\u0022:false}'))" x-modelable="paused" x-on:pointerenter="halted = true" x-on:pointerleave="halted = false" x-on:focusin="halted = true"
    x-on:focusout="halted = false"  style="--marquee-gap: 32px; "
    id="fx-marquee" class="overflow-hidden [mask-image:linear-gradient(to_right,transparent,black_8%,black_92%,transparent)] w-full">
    <div data-track class="flex w-max">
        <div data-slot="marquee-copy" class="flex shrink-0 items-center gap-(--marquee-gap) pe-(--marquee-gap)"><span class="px-4">Fast</span><span class="px-4">Bilingual</span><span class="px-4">Accessible</span></div>
        <div data-slot="marquee-copy" aria-hidden="true" inert class="flex shrink-0 items-center gap-(--marquee-gap) pe-(--marquee-gap)"><span class="px-4">Fast</span><span class="px-4">Bilingual</span><span class="px-4">Accessible</span></div>
    </div>
</div>
</div>
```
