# Text Roll (Moonarc)

A label rolls upward into a copy of itself on hover, letter by letter from the left, and rolls back when the pointer leaves. Two copies in one clipped cell, one translate per character on a per-index delay. Pure CSS; works from the link or button around it.

- Import: `import TextRoll from '@moonarc/core/TextRoll'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/text-roll.json`
- Tier A · category text · trigger hover
- Readout: `<TextRoll text="Get started">`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 5.0 kB raw)
- Page: https://moonarc.dev/components/text-roll/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text; rendered twice, so it must be a plain string. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `step` | `number` | `preset (40)` | Delay between neighbouring characters in ms. 15–30 reads as a ripple, 40+ as a wave. |
| `duration` | `number` | `preset (ui: 450)` | Per-character duration in ms; unset, the active preset's. |

## Usage

```astro
<a href="/setup/"><TextRoll text="Get started" /> →</a>
<nav>{links.map((l) => <a href={l.href}><TextRoll text={l.label} step={20} /></a>)}</nav>
```

## Reduced motion

The roll is removed; the label is static.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- The second copy is the same text, so the roll never changes what is read: it is a hover acknowledgement, not a reveal. That is why it can be fast.
- Per-character delay of one stagger step, left to right, on the preset spring: the roll reads as one gesture running through the word.
- Hover is read from the containing link or button too, so the whole target triggers it.
- Each character is inline-block and translates by its own height inside a clipped grid cell: no measuring, no line-height dependency.
- A character is a grapheme (Intl.Segmenter), so an emoji with a skin tone or a letter with a combining accent counts as one. The inline-block characters would follow the page's direction, so the element takes dir="auto" and Latin text on a right-to-left page still runs left to right.

## Replaces

- TextRoll (Motion Primitives)
- link hover roll (Skiper, motion.dev)

## Source

```astro
---
/**
 * TextRoll — a label rolls up into a copy of itself on hover, one letter
 * after another. Two copies of the text are stacked in one grid cell and
 * clipped; each character translates by its own height on a per-index
 * delay, so the roll runs left to right. Zero JS. Hover on the element or
 * on the link or button that contains it.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Delay between neighbouring characters in ms. Unset: the preset's --ma-stagger-tight. */
  step?: number;
  /** Per-character duration in ms. Unset: the preset's --ma-duration. */
  duration?: number;
}

const { text, as: Tag = 'span', step, duration, class: className, style, ...rest } = Astro.props;
// graphemes, not code points: an emoji with a skin tone or a letter with a combining accent rolls as one character
const chars = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment);
const vars = [step !== undefined && `--ma-roll-step:${step}ms`, duration !== undefined && `--ma-roll-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
// dir="auto": the characters are inline-blocks, which bidi orders by the page's direction, so Latin text on a
// right-to-left page rolled back to front. The element takes its direction from its own text instead.
---

<Tag class:list={['ma-roll', className]} dir="auto" style={vars || undefined} {...rest}>
  <span class="ma-roll__line" aria-hidden="true">{chars.map((c, i) => <span class="ma-roll__char" style={`--ma-i:${i}`}>{c}</span>)}</span>
  <span class="ma-roll__line ma-roll__line--next" aria-hidden="true">{chars.map((c, i) => <span class="ma-roll__char" style={`--ma-i:${i}`}>{c}</span>)}</span>
  <span class="ma-sr">{text}</span>
</Tag>

<style is:global>
  @layer components {
    :where(.ma-roll) {
      display: inline-grid;
      overflow: clip;
      line-height: 1.15;
      vertical-align: bottom;
    }
    :where(.ma-roll__line) {
      grid-area: 1 / 1;
      white-space: pre;
    }
    :where(.ma-roll__char) {
      display: inline-block;
      transition: translate var(--ma-roll-dur, var(--ma-duration)) var(--ma-ease);
      transition-delay: calc(var(--ma-i, 0) * var(--ma-roll-step, var(--ma-stagger-tight)));
    }
    :where(.ma-roll__line--next .ma-roll__char) {
      translate: 0 100%;
    }
    @media (hover: hover) {
      :where(.ma-roll:hover .ma-roll__line .ma-roll__char),
      :where(:is(a, button, label):hover .ma-roll .ma-roll__line .ma-roll__char) {
        translate: 0 -100%;
      }
      :where(.ma-roll:hover .ma-roll__line--next .ma-roll__char),
      :where(:is(a, button, label):hover .ma-roll .ma-roll__line--next .ma-roll__char) {
        translate: 0 0;
      }
    }
    :where(:is(a, button):focus-visible .ma-roll .ma-roll__line .ma-roll__char) {
      translate: 0 -100%;
    }
    :where(:is(a, button):focus-visible .ma-roll .ma-roll__line--next .ma-roll__char) {
      translate: 0 0;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-roll__char) {
        transition: none;
      }
    }
  }
</style>

```
