Skip to content

Components / Text

Text Roll

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.

Live demo

Get started

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 5.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import TextRoll from '@moonarc/core/TextRoll';
---
<a href="/setup/"><TextRoll text="Get started" /> →</a>
<nav>{links.map((l) => <a href={l.href}><TextRoll text={l.label} step={20} /></a>)}</nav>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/text-roll.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

TextRoll.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>

Props

PropTypeDefaultDescription
textstringnoneThe text; rendered twice, so it must be a plain string.
asHTMLTag'span'Element to render.
stepnumberpreset (40)Delay between neighbouring characters in ms. 15–30 reads as a ripple, 40+ as a wave.
durationnumberpreset (ui: 450)Per-character duration in ms; unset, the active preset's.

Reduced motion

The roll is removed; the label is static.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: TextRoll (Motion Primitives) · link hover roll (Skiper, motion.dev). See the migration table.

Swells the letter under the cursor to the heaviest weight and lifts it; its neighbours follow at half and a quarter, and everything settles when the pointer leaves.

every browserhover

Cycles through a list of words in place.

newly · Chrome 85 · Firefox 128 · Safari 16.4always

A link underline that enters from the left on hover and leaves to the right, so the exit continues the entrance instead of rewinding it.

every browserhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown