Skip to content

Components / Text

Rotating Text

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.

Live demo

Ship less, nothing, CSS, zero
to the browser.

Ship once, everywhere, today, and the sentence follows.

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

Measured

JavaScript of its own

0 B

CSS 7.6 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 · newly available

Chrome 85 · Firefox 128 · Safari 16.4; needs property. Elsewhere: words crossfade in place without the vertical travel.

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 RotatingText from '@moonarc/core/RotatingText';
---
<p>Build <RotatingText words={['faster', 'lighter', 'calmer']} /> sites.</p>
<p>Ship <RotatingText words={['once', 'everywhere', 'today']} fluid />. The sentence follows.</p>
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/rotating-text.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.

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

Props

PropTypeDefaultDescription
wordsstring[]noneWords to cycle through, 2–8. A single word stays in place.
asHTMLTag'span'Element to render.
periodnumber2200Time each word is shown in ms, including its entrance. Under 1500 reads as nervous.
fluidbooleanfalseThe 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.

Reduced motion

Words crossfade in place; the vertical travel is removed.

With ClientRouter

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

Why it is built this way

Replaces: RotatingText (React Bits) · FlipWords (Aceternity) · WordRotate (Magic UI). See the migration table.

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.

Chrome 116 · not Firefox · Safari 18load

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