Skip to content
Home Theme Gallery

Waveform

The custom element is <hui-waveform>.

Overview

Draws audio levels as a row of mirrored, rounded bars on a canvas: levels the server analysed, an idle processing animation, or the live level of a MediaStream the host hands it. The element never asks for a microphone.

Example

Levels the server sent
Source
<hui-waveform label="Recorded levels" style="height:80px">
<script type="application/json">{ "levels": [0.08,0.14,0.26,0.42,0.61,0.78,0.9,0.97,1,0.94,0.82,0.66,0.49,0.35,0.24,0.18,0.22,0.34,0.5,0.68,0.83,0.93,0.98,0.95,0.86,0.72,0.56,0.4,0.28,0.19,0.13,0.1,0.15,0.27,0.44,0.62,0.77,0.88,0.94,0.9,0.79,0.64,0.47,0.33,0.22,0.15,0.1,0.07] }</script>
</hui-waveform>

The Go template that renders it:

{{/* Static levels: peaks your server already computed. The element
only draws them. */}}
<hui-waveform label="{{ .Recording.Title }}" style="height:80px">
<script type="application/json">{{ .Recording.LevelsJSON }}</script>
</hui-waveform>
{{/* Live: the page owns the permission prompt, not the element. */}}
<hui-waveform id="mic" mode="live" label="Live audio waveform" style="height:80px"></hui-waveform>
<button type="button" onclick="startListening()">Start Listening</button>
<button type="button" onclick="stopListening()">Stop Listening</button>
<script>
const waveform = document.getElementById('mic');
let stream = null;
async function startListening() {
try {
// Your page asks. The element never will.
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
waveform.listen(stream);
waveform.mode = 'live';
} catch (error) {
// The reader said no, or there is no microphone. Fall back to the
// idle animation rather than leaving an empty box.
waveform.mode = 'processing';
}
}
function stopListening() {
waveform.stop();
stream?.getTracks().forEach((track) => track.stop());
stream = null;
waveform.mode = 'processing';
}
</script>

API

Attributes5
Attributes
NameTypeDefaultDescription
labelstring""Required. The accessible name. The mode is appended to it, so a listening waveform reads "…, listening" and a processing one "…, processing".
mode"static" | "processing" | "live""static"static draws the JSON child, processing the layered-wave animation, live the stream given to listen(). Live mode mirrors the speech-band spectrum around the centre; scrolling mode draws its average energy over time. An unrecognised value falls back to static.
scrollingbooleanfalseBars move right to left rather than holding still. Ignored under reduced motion.
bar-widthnumber3The width of one bar in pixels, measured from the reference.
gapnumber2The gap between two bars in pixels.
Properties5
Properties
NameTypeDefaultDescription
labelstring''Reflects to the label attribute.
modeWaveformMode'static'Reflects to the mode attribute, normalised.
scrollingbooleanfalseReflects to the scrolling attribute.
barWidthnumber3Reflects to the bar-width attribute.
gapnumber2Reflects to the gap attribute.
Methods2
Methods
NameTypeDescription
listen(stream)(stream: MediaStream) => voidDraw the levels of a stream the host obtained. The element reads it through an AnalyserNode and never connects it to the speakers, records it or sends it anywhere.
stop()() => voidRelease the analyser and the stream's source node. Safe to call when nothing is playing.
Slots1
Slots
NameDescription
(default)One <script type="application/json"> child holding { "levels": [] }, each level 0 to 1. Rewriting it redraws the bars, so an HTMX swap is the whole update.
::part() hooks2
::part() hooks
NameDescription
figureThe role="img" wrapper carrying the accessible name.
canvasThe <canvas> the bars are drawn on, aria-hidden.

States

Processing

mode="processing" layers three smooth waves, weighted towards the centre, with a different spatial pattern when scrolling is set. It needs no data and no stream. Switching from live input cross-fades into processing; stopping settles to a dotted baseline.

Processing
Source
<hui-waveform mode="processing" label="Live audio waveform" style="height:80px"></hui-waveform>

Scrolling

scrolling moves the row right to left rather than holding it still. Under prefers-reduced-motion it holds still instead - as does the processing animation, while live levels keep updating, because the information they carry is the point.

Scrolling
Source
<hui-waveform scrolling label="Scrolling levels" style="height:80px">
<script type="application/json">{ "levels": [0.08,0.14,0.26,0.42,0.61,0.78,0.9,0.97,1,0.94,0.82,0.66,0.49,0.35,0.24,0.18,0.22,0.34,0.5,0.68,0.83,0.93,0.98,0.95,0.86,0.72,0.56,0.4,0.28,0.19,0.13,0.1,0.15,0.27,0.44,0.62,0.77,0.88,0.94,0.9,0.79,0.64,0.47,0.33,0.22,0.15,0.1,0.07] }</script>
</hui-waveform>

Wider bars

The defaults - a 3px bar at a 5px pitch - are measured from the reference. bar-width and gap are the host's to change; the bar count follows from the width available.

Wider bars
Source
<hui-waveform bar-width="6" gap="4" label="Wide bars" style="height:80px">
<script type="application/json">{ "levels": [0.08,0.14,0.26,0.42,0.61,0.78,0.9,0.97,1,0.94,0.82,0.66,0.49,0.35,0.24,0.18,0.22,0.34,0.5,0.68,0.83,0.93,0.98,0.95,0.86,0.72,0.56,0.4,0.28,0.19,0.13,0.1,0.15,0.27,0.44,0.62,0.77,0.88,0.94,0.9,0.79,0.64,0.47,0.33,0.22,0.15,0.1,0.07] }</script>
</hui-waveform>

Server-side mechanics

The server owns the levels and the mode; the viewer owns nothing the element keeps. Static levels arrive as a JSON child - peaks the server computed from a recording - and rewriting that child is the entire update, so an hx-swap that replaces it redraws the bars with no memory of the old ones. The element holds no copy of the audio: in live mode the analyser reads the signal frame by frame and the numbers are discarded. Deliberately not the charts' series/rows shape (UI-CONTRACT-008): a list of peaks is not a table, and forcing one would make a host invent a series name and a category for every peak.

Accessibility

  • The wrapper is role="img" named by label plus the mode; the canvas is aria-hidden.
  • Nothing is live-read. A picture of sound announced as it moved would be noise, not information.
  • A silent bar is still drawn, as a dot the width of the stroke, so the row reads as a waveform at rest rather than as an empty box.
  • Under forced colours the bars are drawn in CanvasText at full opacity, which a canvas has to be told explicitly.

Gotchas

  • The element never calls getUserMedia. A microphone is a permission prompt the page should own, and a library element that raised one by itself would surprise everyone - so the host asks, and passes the resulting MediaStream to listen().
  • Unparseable JSON draws a silent baseline, sets data-invalid and warns once. It never throws into the host's render, and never warns twice for the same fault.
  • Under prefers-reduced-motion the processing animation, transitions and the scroll stop, but live levels keep updating. Stopping those would remove the information the element exists to show.
  • A canvas sees no CSS, so the bar colour is read back from a probe element in the shadow root. That is also how forced colours work: the probe resolves to CanvasText and the bars are drawn solid, without the opacity that carries level in a themed palette.