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. There is 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 — 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 language 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
- 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
0:00
0:32
0:04
0:12
Usage
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
-
peaksArray[] - Peak amplitudes as a number array, each value 0 to 1. Property only (no attribute) — set it from script or a framework binding. 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 `duration` yourself if you track seconds. Updated by the component during scrubbing and reflected as an attribute.
-
durationnumbernull - 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. `bars` draws discrete bar pairs mirrored around the center line; `mirror` draws a filled min/max envelope.
-
labelstring'' - Accessible name for the waveform. Announced as the slider label when interactive, or as the image description otherwise.
Events
-
arc-inputdetail: { value: number, time: number | null } - Fired continuously while scrubbing (every pointer move and each keyboard nudge). `value` is the position fraction 0-1; `time` is seconds when `duration` is 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
- Sparkline Tiny 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.
- Meter Semantic gauge display with color-coded fill zones (success, warning, error) based on configurable low/high/optimum thresholds.
- Slider Range input slider with a label, live numeric value display, accent-primary fill track, and customisable min/max/step.