Skip to content

Components / Data

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: 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 demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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); -->
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/waveform.json

The 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.

Waveform.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
seedstringnoneThe word the shape is drawn from, an episode's title for example: the same word draws the same bars on every build.
barsnumber48How many bars, 12 to 120 (a value outside that range is held to it). The gap between the bars narrows as there are more.
progressnumber0The 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.
playingbooleanfalseThe bars move as if the sound is playing. A player sets the data-playing attribute on the element itself while it plays.
labelstringnoneWhat the waveform shows, for a screen reader (role="img"). Unset, it is decoration and hidden from assistive technology.
stillbooleanfalseNo movement, ever: not while playing and not on hover.
--ma-wave-heightCSS length (custom property)3remThe height of the row. Its width is its box's.
--ma-wave-onCSS colour (custom property)the emphasis colour, else the text colourThe colour of the played bars.
--ma-wave-offCSS colour (custom property)the text colour at 32 %The colour of the bars not yet played.

Reduced motion

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.

With ClientRouter

CSS 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.

Why it is built this way

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.

every browserscroll

Motif

Backgrounds

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.

every browserhover
Ambient loopthe same element rides into the next page

Press play, then navigate: position, volume and playback carry over.

Persist Player

Page transitions

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.

every browserclick

A small line chart that draws itself from left to right when it scrolls into view; the last point lands and pulses.

every browserscroll

The first screen of one person's work, as a bento.

newly · Chrome 105 · Firefox 128 · Safari 17pointerscrollalways

Episodes

The latest conversations

All episodes
  1. 29 September 202652 min

  2. 15 September 202647 min

  3. 1 September 202658 min

  4. 18 August 202644 min

The issues, episodes, lessons or videos of one person's work, as a list of rows in the order you give them (newest first).

every browserhoverclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown