Waveform
Audio waveform visualization that doubles as a scrubber. Renders a consumer-computed peaks array as an SVG waveform with an accent played region and glowing playhead, and becomes a full seek control when interactive.
<arc-waveform>Overview
Waveform draws an audio clip's shape from a plain array of peak amplitudes (one number per bar, 0 to 1) and marks playback progress on it: the played region renders in accent with a soft glow, the unplayed region stays muted, and a thin glowing playhead line sits at the current position. Set interactive and the same element becomes a scrubber, with pointer dragging, keyboard seeking, and full slider semantics for assistive technology.
The component never touches audio: no AudioContext, no decoding, no fetching. The consumer computes peaks however it likes (from a decoded buffer, a server-side analysis pass, or a cached sidecar file) and hands them over as the peaks property. That split keeps the component a pure function of its props, so it server-renders, and keeps it cheap enough to repeat down a lane of clips in a DAW timeline or a list of voice memos.
position is a fraction of the total (0 to 1), not seconds. If your player thinks in seconds, divide by the duration on the way in and multiply on the way out. The time field on both event details does the multiplication for you whenever duration is set. Setting duration also renders a monospaced time readout below the track and switches the slider's spoken value from a percentage to elapsed and total time.
While scrubbing, arc-input fires on every pointer move with the live position, and arc-change fires once on release with the committed one. This is the standard ARC edit/commit contract. Wire the actual seek of your audio source to arc-change, and use arc-input for live feedback such as a time display or audible scrubbing. Two variants cover the common looks: bars draws discrete bar pairs mirrored around the center line, and mirror draws a filled min/max envelope.
Guidelines
When to use
- Compute peaks once per clip and cache them. A few dozen to a few hundred values is plenty; the component clamps each to 0-1
- Wire the actual seek of your audio source to arc-change, and keep arc-input for cheap live feedback like a time display
- Set duration whenever you know it, so users get time readouts and screen readers hear times instead of percentages
- Give every waveform a label. It names the slider for assistive technology, or describes the image when not interactive
- Use the bars variant for scrubbers and player UI, and the mirror variant for dense timeline lanes where discrete bars would shimmer
- Set the height with the --waveform-height custom property when the default is too short or too tall for its row
When not to use
- Do not use Waveform for generic trend data. That is Sparkline’s job; Waveform’s shape says "audio" and its center-mirrored geometry distorts ordinary series
- Do not use it as a level indicator or progress bar. Meter shows a single current value against a range; Waveform shows amplitude over time
- Do not feed it raw sample data. Downsample to peaks first; tens of thousands of bars help no one and cost real DOM
- Do not track position in seconds. The position property is a 0-1 fraction; convert at the edges or read the time field on event details
- Do not seek your audio source on arc-input. That fires on every pointer move; the committed value arrives once, on arc-change
Features
- `steps` for a timeline of discrete frames (arrow keys move one step), and `value-text` for your own screen-reader wording
- Renders from a consumer-computed peaks array: no AudioContext, no decoding, no audio dependencies
- Two variants: `bars` (mirrored bar pairs) and `mirror` (filled min/max envelope)
- Played region in accent with a soft glow; unplayed region muted; thin glowing playhead line
- Interactive mode adds pointer scrubbing and keyboard seeking (arrows, PageUp/PageDown, Home/End)
- Standard edit/commit events: `arc-input` continuously while scrubbing, `arc-change` once on release
- Optional `duration` enables monospaced elapsed/total time readouts and spoken time values
- Full slider ARIA when interactive; labeled image semantics otherwise
- Resizes by viewBox scaling: no ResizeObserver, no measuring, fluid at any width
- Playhead motion uses the motion tokens and honors prefers-reduced-motion
- Server-renders: the SVG is a pure function of props
- Empty or missing peaks render an empty track rather than an error
Preview
Usage
Waveform is in the media domain group, so it is absent from the default @arclux/arc-ui barrel. Import it from@arclux/arc-ui/media, or from its own subpath@arclux/arc-ui/waveform. Everything else (the element, the CSS, the framework wrappers, the support it gets) is unchanged.
Layout and styling work without JavaScript via the HTML/CSS versions. Interactive features like events and state management require the Web Component or a framework wrapper.
<script type="module" src="@arclux/arc-ui"></script>
<!-- Interactive scrubber with time readouts -->
<arc-waveform id="scrubber" interactive duration="212" label="Track position"></arc-waveform>
<!-- Static clip preview, mirror envelope -->
<arc-waveform id="clip" variant="mirror" position="0.6" label="Vocal take"></arc-waveform>
<script type="module">
const scrubber = document.getElementById('scrubber');
// Peaks are a property, not an attribute — you compute them (0..1 per bar).
scrubber.peaks = await fetchPeaks('track-7');
scrubber.addEventListener('arc-input', (e) => {
// Live position while dragging: e.detail.value is 0..1,
// e.detail.time is seconds because duration is set.
timeDisplay.textContent = format(e.detail.time);
});
scrubber.addEventListener('arc-change', (e) => {
audio.currentTime = e.detail.time; // seek once, on release
});
// Follow playback
audio.addEventListener('timeupdate', () => {
scrubber.position = audio.currentTime / audio.duration;
});
</script>import { Waveform } from '@arclux/arc-ui-react';
export function TrackScrubber({ peaks, audio }: { peaks: number[]; audio: HTMLAudioElement }) {
return (
<Waveform
peaks={peaks}
duration={audio.duration}
position={audio.currentTime / audio.duration}
interactive
label="Track position"
onArcInput={(e) => console.log('Live:', e.detail.time)}
onArcChange={(e) => { audio.currentTime = e.detail.time; }}
/>
);
}<script setup>
import { Waveform } from '@arclux/arc-ui-vue';
import { ref } from 'vue';
const peaks = ref([0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8]);
const position = ref(0);
</script>
<template>
<Waveform
:peaks="peaks"
:position="position"
:duration="212"
interactive
label="Track position"
@arc-input="(e) => console.log('Live:', e.detail.time)"
@arc-change="(e) => seek(e.detail.time)"
/>
</template><script>
import { Waveform } from '@arclux/arc-ui-svelte';
let peaks = [0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8];
let position = 0;
</script>
<Waveform
{peaks}
{position}
duration={212}
interactive
label="Track position"
on:arc-input={(e) => console.log('Live:', e.detail.time)}
on:arc-change={(e) => seek(e.detail.time)}
/>import { Component } from '@angular/core';
import { Waveform } from '@arclux/arc-ui-angular';
@Component({
imports: [Waveform],
template: `
<arc-waveform
[peaks]="peaks"
[position]="position"
duration="212"
interactive
label="Track position"
(arc-input)="onScrub($event)"
(arc-change)="onSeek($event)"
></arc-waveform>
`,
})
export class TrackScrubberComponent {
peaks = [0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8];
position = 0;
onScrub(e: CustomEvent) { console.log('Live:', e.detail.time); }
onSeek(e: CustomEvent) { this.position = e.detail.value; }
}import { Waveform } from '@arclux/arc-ui-solid';
import { createSignal } from 'solid-js';
export function TrackScrubber() {
const [position, setPosition] = createSignal(0);
const peaks = [0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8];
return (
<Waveform
peaks={peaks}
position={position()}
duration={212}
interactive
label="Track position"
onArcChange={(e) => setPosition(e.detail.value)}
/>
);
}import { Waveform } from '@arclux/arc-ui-preact';
import { useState } from 'preact/hooks';
export function TrackScrubber() {
const [position, setPosition] = useState(0);
const peaks = [0.2, 0.5, 0.9, 0.4, 0.7, 0.3, 0.8];
return (
<Waveform
peaks={peaks}
position={position}
duration={212}
interactive
label="Track position"
onArcChange={(e) => setPosition(e.detail.value)}
/>
);
}API
labelstring''- Accessible name for the waveform. Announced as the slider label when interactive, or as the image description otherwise.
peaksArray[]- Peak amplitudes as a number array, each value 0 to 1. Set it from script, a framework binding, or a JSON attribute. Values outside the range are clamped. An empty or missing array renders an empty track.
positionnumber0- Current playhead position as a fraction of the total, 0 to 1. Not seconds: multiply by
durationyourself if you track seconds. Updated by the component during scrubbing and reflected as an attribute. durationnumber0- Total duration in seconds. Optional; when set, time readouts render below the waveform and the slider announces times instead of percentages.
interactivebooleanfalse- Enables scrubbing. The waveform becomes a focusable slider: click or drag to seek, arrow keys to nudge, Home/End to jump. Without it the waveform is a static image.
variant'bars' | 'mirror''bars'- Rendering style.
barsdraws discrete bar pairs mirrored around the center line;mirrordraws a filled min/max envelope. stepsnumber0- Divides the track into this many equal steps, for a timeline of discrete frames rather than continuous audio. The playhead snaps to a step from pointer and keyboard alike, arrow keys move one step, Page Up/Down a tenth of the track (at least one step), and the slider reports the step number rather than a percentage. Unset, the track is continuous.
value-textstring- What the slider announces for the current position, replacing the built-in percentage or time. Set it from your
arc-inputhandler, which runs before the position renders, so the text and the value always arrive together (e.g. "85 of 200 ms").
Events
arc-inputdetail:{ value: number, time: number | null }- Fired continuously while scrubbing (every pointer move and each keyboard nudge).
valueis the position fraction 0-1;timeis seconds whendurationis set, otherwise null. Use for live preview: updating a time display, or audibly scrubbing. arc-changedetail:{ value: number, time: number | null }- Fired once when the seek commits: on pointer release, or with each keyboard nudge. Use for the actual seek on your audio source.
See Also
- SparklineTiny inline SVG chart for embedding lightweight line or bar visualizations inside tables, stat cards, and dashboards. Renders from a simple comma-separated data string with no external charting dependencies.
- MeterSemantic gauge display with color-coded fill zones (success, warning, error) based on configurable low/high/optimum thresholds.
- SliderRange input slider with a label, live numeric value display, accent-primary fill track, and customisable min/max/step.