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 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
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;
}
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>| 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. |
Words crossfade in place; the vertical travel is removed.
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