Bubble Text
TextSwells 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.
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, 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 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
every browser.
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 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>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/text-roll.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.
---
/**
* 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>| Prop | Type | Default | Description |
|---|---|---|---|
text | string | none | The text; rendered twice, so it must be a plain string. |
as | HTMLTag | 'span' | Element to render. |
step | number | preset (40) | Delay between neighbouring characters in ms. 15–30 reads as a ripple, 40+ as a wave. |
duration | number | preset (ui: 450) | Per-character duration in ms; unset, the active preset's. |
The roll is removed; the label is static.
ClientRouterCSS-only; nothing to rebind.
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.
Cycles through a list of words in place.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown