Bar Grow
DataA bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value.
A row of bars drawn from a word, like the shape of an episode's sound. The same word draws the same shape on every build: the bars rise and fall the way speech does, with now and then a pause, and taper in and out at the ends. A share of it can be marked as played (the progress prop, or the --ma-wave-progress custom property a player script writes): the played bars take the emphasis colour and the rest a fainter share of the text colour. While playing is set the bars move, each out of step with its neighbours, and they move while a link or a button around the waveform is hovered or focused, or an element marked ma-wave-host around it is hovered. Zero JavaScript: a player that drives it owns the controls and the time.
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.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
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 Waveform from '@moonarc/core/Waveform';
---
<a href="/episodes/18/" class="block">
<Waveform seed="Episode 18" bars={48} progress={0.35} />
</a>
<!-- a player script drives it with two lines:
wave.style.setProperty('--ma-wave-progress', audio.currentTime / audio.duration);
wave.toggleAttribute('data-playing', !audio.paused); -->Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/waveform.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.
---
/**
* Waveform — a row of bars drawn from a word, like the shape of an episode's sound: the same word draws the same
* shape on every build, rising and falling the way speech does. A share of it can be marked as played (`progress`, or
* the --ma-wave-progress custom property a player script sets), and while it plays, or while a link around it is
* hovered, the bars move. Zero JavaScript.
*
* It is decoration unless it has a `label`; a player that drives it owns the controls and the time.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** The word the shape is drawn from: an episode's title, say. */
seed: string;
/** How many bars, 12–120. */
bars?: number;
/** The share already played, 0–1: the bars up to it take the played colour. */
progress?: number;
/** The bars move as if it is playing (a player sets data-playing itself). */
playing?: boolean;
/** What it shows, for a screen reader. Unset, it is decoration and hidden from it. */
label?: string;
/** No movement, ever. */
still?: boolean;
}
const { seed, bars: count = 48, progress = 0, playing = false, label, still = false, class: className, style, ...rest } = Astro.props;
// a style given as an object keeps its declarations next to the component's own (camelCase keys become CSS names)
const own = typeof style === 'string' ? style : style && typeof style === 'object' ? Object.entries(style as Record<string, string | number>).map(([k, v]) => `${k.startsWith('--') ? k : k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${v}`).join(';') : '';
const n = Math.max(12, Math.min(120, Math.round(count)));
// a seeded walk: each bar a little away from the last, pulled back toward the middle, with now and then a pause (a
// short bar) the way speech has breaths; the first and last few taper in and out
let h = 2166136261;
for (const ch of seed) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
const rand = () => {
h = Math.imul(h ^ (h >>> 15), 2246822507) >>> 0;
h = Math.imul(h ^ (h >>> 13), 3266489909) >>> 0;
return ((h ^= h >>> 16) >>> 0) / 4294967296;
};
let level = 0.55;
const heights = Array.from({ length: n }, (_, i) => {
level += (rand() - 0.5) * 0.5 + (0.55 - level) * 0.25;
const breath = rand() < 0.08 ? 0.35 : 1;
const edge = Math.min(1, (Math.min(i, n - 1 - i) + 1) / 4);
return Math.max(0.12, Math.min(1, level * breath * (0.55 + 0.45 * edge)));
});
const a11y = label ? { role: 'img' as const, 'aria-label': label } : { 'aria-hidden': 'true' as const };
const inline = [`--ma-wave-n:${n}`, `--ma-wave-progress:${Math.max(0, Math.min(1, progress))}`, own].filter(Boolean).join(';');
---
<div class:list={['ma-wave', className]} data-playing={playing ? '' : undefined} data-still={still ? '' : undefined} style={inline} {...a11y} {...rest}>
{heights.map((v, i) => <span style={`--i:${i};--h:${v.toFixed(3)};--d:${((i * 37) % 23) * -53}ms`}></span>)}
</div>
<style is:global>
@layer components {
/* theme contract: the bars are the text's colour; the played ones --ma-wave-on (the emphasis colour, else the text),
the rest --ma-wave-off (a share of the text). The row fills its box's width at --ma-wave-height */
:where(.ma-wave) {
--_on: var(--ma-wave-on, var(--ma-em, currentColor));
--_off: var(--ma-wave-off, color-mix(in oklab, currentColor 32%, transparent));
display: flex; align-items: center; gap: max(1px, calc(18% / var(--ma-wave-n)));
inline-size: 100%; block-size: var(--ma-wave-height, 3rem);
}
:where(.ma-wave > span) {
--_t: clamp(0, calc(var(--ma-wave-progress, 0) * var(--ma-wave-n) - var(--i)), 1);
flex: 1 1 0; block-size: 100%; border-radius: 999px;
background: color-mix(in oklab, var(--_on) calc(var(--_t) * 100%), var(--_off));
scale: 1 var(--h); transform-origin: 50% 50%;
transition: scale var(--ma-duration-base, 300ms) var(--ma-ease-out);
}
@media (prefers-reduced-motion: no-preference) {
:where(.ma-wave[data-playing]:not([data-still]) > span),
:where(:is(:is(a, button, .ma-wave-host):is(:hover, :focus-visible), .ma-wave-host:has(:focus-visible)) .ma-wave:not([data-still]) > span) {
animation: ma-wave-bar 0.9s var(--ma-ease-in-out, ease-in-out) var(--d) infinite alternate;
}
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-wave > span) { transition: none; }
}
}
@keyframes ma-wave-bar {
from { scale: 1 var(--h); }
to { scale: 1 calc(var(--h) * 0.35 + 0.08); }
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
seed | string | none | The word the shape is drawn from, an episode's title for example: the same word draws the same bars on every build. |
bars | number | 48 | How many bars, 12 to 120 (a value outside that range is held to it). The gap between the bars narrows as there are more. |
progress | number | 0 | The share already played, 0 to 1: the bars up to it take the played colour, and the bar the share ends in blends between the two. A player script can write --ma-wave-progress on the element instead. |
playing | boolean | false | The bars move as if the sound is playing. A player sets the data-playing attribute on the element itself while it plays. |
label | string | none | What the waveform shows, for a screen reader (role="img"). Unset, it is decoration and hidden from assistive technology. |
still | boolean | false | No movement, ever: not while playing and not on hover. |
--ma-wave-height | CSS length (custom property) | 3rem | The height of the row. Its width is its box's. |
--ma-wave-on | CSS colour (custom property) | the emphasis colour, else the text colour | The colour of the played bars. |
--ma-wave-off | CSS colour (custom property) | the text colour at 32 % | The colour of the bars not yet played. |
The bars do not move: playing, data-playing and a hover leave them at their heights, and the short transition between heights is off. The played share still colours them.
ClientRouterCSS only; nothing to rebind. A script that drives it through data-playing and --ma-wave-progress binds itself, for example through the runtime's onMount.
Replaces: Waveform images exported from an audio editor · wavesurfer.js, where the shape only has to suggest the sound. See the migration table.
A bar chart whose bars grow from the baseline one after another when it scrolls into view; hovering a bar shows its value.
A drawn picture for a slot that has no photograph yet: a sun over the sea, a split gate, a flower, a grid of tiles, a phone going live, a plate set for dinner, waves under a moon or three shapes.
Press play, then navigate: position, volume and playback carry over.
An audio player that keeps playing while you navigate: the same <audio> element is carried into the next page by Astro's router, so position, volume and playback never restart.
A small line chart that draws itself from left to right when it scrolls into view; the last point lands and pulses.
The first screen of one person's work, as a bento.
Episodes
Episode 48
Episode 47
Episode 46
Episode 45
The issues, episodes, lessons or videos of one person's work, as a list of rows in the order you give them (newest first).
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown