# Waveform (Moonarc)

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.

- Import: `import Waveform from '@moonarc/core/Waveform'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/waveform.json`
- Tier A · category data · trigger hover, always
- Readout: `<Waveform seed="Episode 18" progress={0.35} />`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 5.1 kB raw)
- Page: https://moonarc.dev/components/waveform/

## Props

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

## Usage

```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); -->
```

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

## Craft

- The shape is a seeded walk. Each bar moves a little from the one before and is pulled back toward the middle, about one bar in twelve is a pause, and the first and last few taper, so the row reads as speech. The seed is hashed, so a title keeps its shape between builds.
- The played share is arithmetic in CSS. Each bar mixes from the unplayed to the played colour by how far the playhead is past it, held between 0 and 1, so the bar at the playhead blends and a player only has to write one custom property.
- Bars are scaled on their block axis instead of resized, so moving them costs no layout. Each starts at its own point in the loop, set from its index, so they move out of step the way speech does.
- Without a label it is hidden from assistive technology: the player that drives it owns the controls and the time, so the bars add nothing a screen reader needs. With a label it is an image with that name.

## Replaces

- Waveform images exported from an audio editor
- wavesurfer.js, where the shape only has to suggest the sound

## Source

```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>

```
