Weight Wave
A wave of boldness rolls through the letters of a line, each character animating its font weight on its own delay. Pure CSS. Continuous with a variable font; steps between available weights otherwise.
Live demo
Variable weight
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
Measured
JavaScript of its own
0 B
CSS 4.5 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 62 · Firefox 62 · Safari 11; needs font-variation.
Install
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 WeightWave from '@moonarc/core/WeightWave';
---
<WeightWave as="h1" text="Variable" />
<WeightWave text="slow" duration={4} step={140} />Copy it into your project instead (shadcn registry)
Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/weight-wave.jsonThe 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.
---
/**
* WeightWave — a wave of weight rolls through the letters, zero JS.
*
* Each character animates font-weight on its own delay. Needs a variable
* font with a weight axis to be continuous; static families step between
* the weights they have. Widths change with weight, so use it on a line
* you want to breathe, not in a paragraph.
*/
import type { HTMLAttributes, HTMLTag } from 'astro/types';
interface Props extends HTMLAttributes<'span'> {
/** The text. */
text: string;
/** Element to render. */
as?: HTMLTag;
/** Lightest weight. */
min?: number;
/** Heaviest weight. */
max?: number;
/** Seconds per full wave at one character. */
duration?: number;
/** Delay between neighbouring characters, ms. */
step?: number;
}
const { text, as: Tag = 'span', min = 300, max = 800, duration = 2.4, step = 80, class: className, style, ...rest } = Astro.props;
// graphemes, not code points: an emoji with a skin tone or a letter with a combining accent swells as one character
const chars = Array.from(new Intl.Segmenter().segment(text), (g) => g.segment);
// the caller's style joins the component's properties instead of replacing them
const vars = [`--ma-ww-min:${min}`, `--ma-ww-max:${max}`, `--ma-ww-dur:${duration}s`, `--ma-ww-step:${step}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 ran back to front. The element takes its direction from its own text instead.
---
<Tag class:list={['ma-weight', className]} dir="auto" style={vars} {...rest}>
{chars.map((c, i) => (c === ' ' ? ' ' : <span class="ma-weight__char" aria-hidden="true" style={`--ma-i:${i}`}>{c}</span>))}
<span class="ma-sr">{text}</span>
</Tag>
<style is:global>
@layer components {
:where(.ma-weight) {
font-variation-settings: normal;
white-space: pre-wrap;
}
:where(.ma-weight__char) {
display: inline-block;
font-weight: var(--ma-ww-min, 300);
animation: ma-weight var(--ma-ww-dur, 2.4s) var(--ma-ease-in-out) infinite;
animation-delay: calc(var(--ma-i, 0) * var(--ma-ww-step, 80ms));
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-weight__char) {
animation: none;
font-weight: inherit;
}
}
}
@keyframes ma-weight {
0%,
100% {
font-weight: var(--ma-ww-min, 300);
}
50% {
font-weight: var(--ma-ww-max, 800);
}
}
</style>Props
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | none | The text. |
as | HTMLTag | 'span' | Element to render. |
min | number | 300 | Lightest weight. |
max | number | 800 | Heaviest weight. |
duration | number | 2.4 | Seconds per full wave at one character. |
step | number | 80 | Delay between neighbouring characters, ms. Smaller is a faster-travelling wave. |
Reduced motion
Every character sits at the inherited weight.
With ClientRouter
CSS-only; nothing to rebind.
Why it is built this way
- font-weight animates the wght axis directly; no font-variation-settings string to parse per frame.
- ease-in-out so the wave swells and recedes; a linear wave looks mechanical.
- Glyph widths change with weight, which means layout every frame: keep it to one line, and never in running text.
- A visually hidden span (.ma-sr) carries the text; the characters are aria-hidden.
- A character is a grapheme (Intl.Segmenter), so an emoji with a skin tone or a letter with a combining accent counts as one. The inline-block characters would follow the page's direction, so the element takes dir="auto" and Latin text on a right-to-left page still runs left to right.
Replaces: VariableProximity (React Bits) · font-weight hover effects. See the migration table.
Related
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.
Text Scramble
TextRandom characters resolve into the real text from left to right when it scrolls into view, on load, or on hover.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown