Split Text
TextSplits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.
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.
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.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 9.1 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 85 · Firefox 128 · Safari 16.4; needs property. Elsewhere: words crossfade in place without the vertical travel.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/rotating-text.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* 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;
/** Rounds before the first word comes back and stays. Unset, the words turn while the page is open; WCAG 2.2.2 asks
* motion that runs past five seconds to stop or be stoppable, and a pointer over the words only pauses them. */
cycles?: number;
}
const { words, as: Tag = 'span', period = 2200, fluid = false, cycles, class: className, style, ...rest } = Astro.props;
const list: string[] = words.slice(0, 8);
const n = Math.max(list.length, 2);
const rounds = cycles !== undefined && Number.isFinite(cycles) && cycles >= 1 ? Math.round(cycles) : undefined;
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}`, rounds && `--ma-rotate-cycles:${rounds}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<Tag class:list={['ma-rotate', className]} data-fluid={fluid ? '' : undefined} data-cycles={rounds ? '' : 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;
}
/* a pointer over the words holds them where they are */
:where(.ma-rotate:hover .ma-rotate__word) {
animation-play-state: paused;
}
/* cycles: every word holds its last frame (away, and 0 wide when fluid) after the last round, and the first word's
settle, listed last, brings it back on the entrance curve (0.18 of a period at every count) and keeps it */
:where(.ma-rotate[data-cycles] .ma-rotate__word) {
animation-iteration-count: var(--ma-rotate-cycles);
animation-fill-mode: forwards;
}
:where(.ma-rotate[data-cycles] .ma-rotate__word:first-child) {
animation-name: var(--ma-rotate-anim), ma-rotate-settle;
animation-duration: calc(var(--ma-rotate-n) * var(--ma-rotate-period)), calc(0.18 * var(--ma-rotate-period));
animation-timing-function: linear, cubic-bezier(0.23, 1, 0.32, 1);
animation-delay: 0ms, calc(var(--ma-rotate-cycles) * var(--ma-rotate-n) * var(--ma-rotate-period));
animation-iteration-count: var(--ma-rotate-cycles), 1;
}
:where(.ma-rotate[data-cycles][data-fluid] .ma-rotate__word:first-child) {
animation-name: var(--ma-rotate-anim), var(--ma-rotate-anim-w), ma-rotate-settle;
animation-duration: calc(var(--ma-rotate-n) * var(--ma-rotate-period)), calc(var(--ma-rotate-n) * var(--ma-rotate-period)), calc(0.18 * var(--ma-rotate-period));
animation-timing-function: linear, linear, cubic-bezier(0.23, 1, 0.32, 1);
animation-delay: 0ms, 0ms, calc(var(--ma-rotate-cycles) * var(--ma-rotate-n) * var(--ma-rotate-period));
animation-iteration-count: var(--ma-rotate-cycles), var(--ma-rotate-cycles), 1;
}
/* reduced motion and cycles: the words would still swap in place, and a fluid cell would keep the widest word's
width after they stop, a gap after the one that stays. So it starts where it would settle: the first word
alone, at its own width, the others 0 wide. Last in the layer, after the cycles rules it replaces */
@media (prefers-reduced-motion: reduce) {
/* on one line: 0 wide, a word would wrap at every break it may take, and under overflow-wrap: anywhere at every
letter, a column of them taller than the word that shows */
:where(.ma-rotate[data-cycles] .ma-rotate__word) {
animation: none;
opacity: 0;
max-width: 0;
white-space: nowrap;
}
:where(.ma-rotate[data-cycles] .ma-rotate__word:first-child) {
opacity: 1;
max-width: none;
}
}
}
@keyframes ma-rotate-settle {
from { --ma-rotate-y: 110%; opacity: 0; }
to { --ma-rotate-y: 0%; opacity: 1; max-width: var(--ma-rotate-w, none); }
}
@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>| 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. |
cycles | number | none | Rounds before the first word comes back and stays. Unset, the words turn while the page is open. WCAG 2.2.2 asks motion that runs past five seconds to stop or be stoppable: set it wherever the words sit beside content people read. A pointer over the words pauses them either way. |
Words crossfade in place; the vertical travel is removed. With cycles, the first word shows alone from the start, at its own width.
ClientRouterCSS-only; fresh elements restart their own animation after every navigation.
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.
Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown