# Rotating Text (Moonarc)

Cycles through a list of words in place. Each word rises in, holds, and leaves upward, and the box is sized to the widest word so the surrounding text never shifts. Pure CSS.

- Import: `import RotatingText from '@moonarc/core/RotatingText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/rotating-text.json`
- Tier A · category text · trigger always
- Readout: `<RotatingText period={1600}>`
- Browser support: newly (Chrome 85 · Firefox 128 · Safari 16.4); elsewhere: words crossfade in place without the vertical travel
- Measured cost: 0 B JS (CSS 7.6 kB raw)
- Page: https://moonarc.dev/components/rotating-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `words` | `string[]` | none | Words to cycle through, 2–8. A single word stays in place. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `period` | `number` | `2200` | Time each word is shown in ms, including its entrance. Under 1500 reads as nervous. |
| `fluid` | `boolean` | `false` | The box follows the current word's width, so the text around it shifts smoothly as words change instead of being sized to the widest word. |

## Usage

```astro
<p>Build <RotatingText words={['faster', 'lighter', 'calmer']} /> sites.</p>
<p>Ship <RotatingText words={['once', 'everywhere', 'today']} fluid />. The sentence follows.</p>
```

## Reduced motion

Words crossfade in place; the vertical travel is removed.

## With ClientRouter

CSS-only; fresh elements restart their own animation after every navigation.

## Craft

- inline-grid with every word in one cell sizes the box to the widest word, so the sentence around it never reflows.
- Entrance is ease-out, exit is ease-in, hold is linear: the same asymmetry as any enter/exit pair.
- The travel lives in a registered custom property so reduced motion neutralises it with a single declaration.
- A visually hidden span (.ma-sr) lists every word once; the animated spans are aria-hidden. Not aria-label: RotatingText usually sits inside a sentence as a span, where a label is not allowed and is not read.
- fluid is a second animation on max-width, not a measurement: each word contributes 0 while it is away and its own width while it is the word, so the grid cell (the widest contribution) travels from one width to the next. The growth bound is 0.75 em per character, an upper limit that only sets how fast the width reaches the word's real size.

## Replaces

- RotatingText (React Bits)
- FlipWords (Aceternity)
- WordRotate (Magic UI)

## Source

```astro
---
/**
 * RotatingText — cycles through words in place, zero JS.
 *
 * Every custom property is namespaced (--ma-rotate-*): @property is global,
 * and a short name registered by one component as non-inherited breaks any
 * other component that reads the same name through inheritance.
 *
 * Words stack in one grid cell so the box is always as wide as the widest
 * word; each word runs the same keyframes offset by its index. The travel is a
 * registered custom property (--ma-rotate-y) so reduced motion can neutralise it with
 * one declaration and keep the crossfade.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Words to cycle through, 2–8. A single word stays in place. */
  words: string[];
  /** Element to render. */
  as?: HTMLTag;
  /** Time each word is shown, ms (includes its entrance). */
  period?: number;
  /** The box follows the current word's width, so the sentence around it shifts smoothly instead of being sized to the widest word. */
  fluid?: boolean;
}

const { words, as: Tag = 'span', period = 2200, fluid = false, class: className, style, ...rest } = Astro.props;
const list: string[] = words.slice(0, 8);
const n = Math.max(list.length, 2);
const inline = [`--ma-rotate-n:${n}`, `--ma-rotate-period:${period}ms`, `--ma-rotate-anim:ma-rotate-${n}`, fluid && `--ma-rotate-anim-w:ma-rotate-fluid-${n}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-rotate', className]} data-fluid={fluid ? '' : undefined} style={inline} {...rest}>
  {list.map((word, i) => <span class="ma-rotate__word" aria-hidden="true" style={`--ma-rotate-i:${i}${fluid ? `;--ma-rotate-len:${[...word].length}` : ''}`}>{word}</span>)}
  <span class="ma-sr">{list.join(', ')}</span>
</Tag>

<style is:global>
  @property --ma-rotate-y {
    syntax: '<length-percentage>';
    inherits: false;
    initial-value: 0%;
  }

  @layer components {
    :where(.ma-rotate) {
      display: inline-grid;
      overflow: clip;
      vertical-align: bottom;
    }
    :where(.ma-rotate__word) {
      grid-area: 1 / 1;
      translate: 0 var(--ma-rotate-y);
      opacity: 0;
      animation: var(--ma-rotate-anim) calc(var(--ma-rotate-n) * var(--ma-rotate-period)) linear infinite;
      animation-delay: calc(var(--ma-rotate-i) * var(--ma-rotate-period));
      will-change: translate, opacity;
    }
    /* fluid: a second animation caps each word's max-width — 0 while it is away, its bound while it is the word — so the cell
       (the widest contribution) moves from one word's width to the next instead of sitting at the widest of them all */
    :where(.ma-rotate[data-fluid] .ma-rotate__word) {
      white-space: nowrap;
      --ma-rotate-w: calc(var(--ma-rotate-len, 8) * 0.75em);
      animation-name: var(--ma-rotate-anim), var(--ma-rotate-anim-w);
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-rotate__word) {
        translate: 0 0;
        will-change: auto;
      }
      :where(.ma-rotate[data-fluid] .ma-rotate__word) {
        animation-name: var(--ma-rotate-anim);
        max-width: none;
      }
    }
    /* One word (the first child and the one before .ma-sr) has nothing to rotate to: it stays, reduced motion or not.
       It used to run the two-word cycle against an empty slot and was gone for almost half of every turn. */
    :where(.ma-rotate__word:first-child:nth-last-child(2)) {
      opacity: 1;
      translate: none;
      animation: none;
      will-change: auto;
    }
  }

  @keyframes ma-rotate-2 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    9.00% { --ma-rotate-y: 0%; opacity: 1; }
    50.00% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    59.00% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
  @keyframes ma-rotate-3 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    6.00% { --ma-rotate-y: 0%; opacity: 1; }
    33.33% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    39.33% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
  @keyframes ma-rotate-4 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    4.50% { --ma-rotate-y: 0%; opacity: 1; }
    25.00% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    29.50% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
  @keyframes ma-rotate-5 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    3.60% { --ma-rotate-y: 0%; opacity: 1; }
    20.00% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    23.60% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
  @keyframes ma-rotate-6 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    3.00% { --ma-rotate-y: 0%; opacity: 1; }
    16.67% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    19.67% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
  @keyframes ma-rotate-7 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    2.57% { --ma-rotate-y: 0%; opacity: 1; }
    14.29% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    16.86% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
  @keyframes ma-rotate-fluid-2 {
    0% { max-width: 0; }
    9%, 59% { max-width: var(--ma-rotate-w); }
    62%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-fluid-3 {
    0% { max-width: 0; }
    6%, 39.33% { max-width: var(--ma-rotate-w); }
    41.33%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-fluid-4 {
    0% { max-width: 0; }
    4.5%, 29.5% { max-width: var(--ma-rotate-w); }
    31%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-fluid-5 {
    0% { max-width: 0; }
    3.6%, 23.6% { max-width: var(--ma-rotate-w); }
    24.8%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-fluid-6 {
    0% { max-width: 0; }
    3%, 19.67% { max-width: var(--ma-rotate-w); }
    20.67%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-fluid-7 {
    0% { max-width: 0; }
    2.57%, 16.86% { max-width: var(--ma-rotate-w); }
    17.71%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-fluid-8 {
    0% { max-width: 0; }
    2.25%, 14.75% { max-width: var(--ma-rotate-w); }
    15.5%, 100% { max-width: 0; }
  }
  @keyframes ma-rotate-8 {
    0% { --ma-rotate-y: 110%; opacity: 0; animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1); }
    2.25% { --ma-rotate-y: 0%; opacity: 1; }
    12.50% { --ma-rotate-y: 0%; opacity: 1; animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0); }
    14.75% { --ma-rotate-y: -110%; opacity: 0; }
    100% { --ma-rotate-y: -110%; opacity: 0; }
  }
</style>

```
