Media
Waveform
An audio waveform drawn from peaks, with the played part filled, that seeks on click, drag or keys.
- Built on
<svg>Pointer captureclip-pathrole="slider"- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.9 kB
- Dependencies
- React
Code
"use client";
import { Waveform } from "@/components/equip/waveform";
/* Stand-in loudness for a 52 second voice memo: speech-like bursts with pauses. */
const peaks = Array.from({ length: 48 }, (_, i) =>
Math.abs(Math.sin(i * 0.9) * 0.55 + Math.sin(i * 0.31) * 0.35) * (i % 13 < 11 ? 1 : 0.15),
);
export default function Demo() {
return <Waveform label="Voice memo" peaks={peaks} duration={52} defaultValue={19} style={{ width: 216 }} />;
}Examples
Paired with an audio player
The peaks are measured from the same generated WAV the player plays, and both seek the one audio element.
Code
"use client";
import { useEffect, useRef, useState } from "react";
import { AudioPlayer } from "@/components/equip/audio-player";
import { Waveform } from "@/components/equip/waveform";
const RATE = 8000;
const SECONDS = 20;
/* Spoken-word stand-in: tone bursts with gaps, generated so nothing is fetched. */
function samples() {
return Float32Array.from({ length: RATE * SECONDS }, (_, i) => {
const t = i / RATE;
const word = Math.max(0, Math.sin(t * 2.6) * Math.sin(t * 0.7 + 1));
return Math.sin(2 * Math.PI * (160 + 40 * Math.sin(t * 3)) * t) * word * 0.6;
});
}
function wav(data: Float32Array) {
const view = new DataView(new ArrayBuffer(44 + data.length * 2));
const word = (at: number, text: string) => [...text].forEach((c, i) => view.setUint8(at + i, c.charCodeAt(0)));
word(0, "RIFF"); view.setUint32(4, 36 + data.length * 2, true); word(8, "WAVEfmt ");
view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true);
view.setUint32(24, RATE, true); view.setUint32(28, RATE * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
word(36, "data"); view.setUint32(40, data.length * 2, true);
data.forEach((s, i) => view.setInt16(44 + i * 2, s * 16000, true));
return new Blob([view.buffer], { type: "audio/wav" });
}
/** The loudest sample in each of count slices. */
function measure(data: Float32Array, count: number) {
const size = Math.floor(data.length / count);
return Array.from({ length: count }, (_, b) => {
let max = 0;
for (let i = b * size; i < (b + 1) * size; i++) max = Math.max(max, Math.abs(data[i] ?? 0));
return max / 0.6;
});
}
export default function Example() {
const audio = useRef<HTMLAudioElement>(null);
const [src, setSrc] = useState<string>();
const [peaks, setPeaks] = useState<number[]>([]);
const [time, setTime] = useState(0);
useEffect(() => {
const data = samples();
setPeaks(measure(data, 120));
const url = URL.createObjectURL(wav(data));
setSrc(url);
return () => URL.revokeObjectURL(url);
}, []);
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 420 }}>
<Waveform
label="Position in standup recording"
peaks={peaks}
bars={60}
duration={SECONDS}
value={time}
onSeek={(s) => {
if (audio.current) audio.current.currentTime = s;
setTime(s);
}}
/>
<AudioPlayer
ref={audio}
variant="compact"
src={src}
title="Standup, Tuesday"
onTimeUpdate={(event) => setTime(event.currentTarget.currentTime)}
/>
</div>
);
}Long recording, still loading
bars resamples 600 peaks down to 72, keeping the loudest in each slice; buffered draws how far the file has arrived.
Board meeting, 12 March6:52 / 46:00
Code
"use client";
import { useState } from "react";
import { Waveform } from "@/components/equip/waveform";
const peaks = Array.from({ length: 600 }, (_, i) => {
const phrase = Math.abs(Math.sin(i * 0.05));
return phrase * (0.35 + 0.65 * Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.4)));
});
function clock(s: number) {
return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}
export default function Example() {
const [time, setTime] = useState(412);
return (
<div style={{ display: "grid", gap: 6, maxWidth: 480 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--eq-text-s)" }}>
<strong>Board meeting, 12 March</strong>
<span style={{ color: "var(--eq-ink-2)", fontVariantNumeric: "tabular-nums" }}>
{clock(time)} / {clock(2760)}
</span>
</div>
<Waveform label="Board meeting recording" peaks={peaks} bars={72} duration={2760} buffered={1400} value={time} onSeek={setTime} style={{ height: 36 }} />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
peaks | number[] | none | Loudness per slice, 0 to 1, oldest first. Measure them on the server or from decoded audio. |
duration | number | none | Length of the audio in seconds. |
value | number | none | Playhead in seconds, when you control it. Feed it from the audio element's timeupdate. |
defaultValue | number | 0 | Playhead on first render, when you do not. |
onSeek | (seconds: number) => void | none | Called as the user clicks, drags or presses keys. Set the audio element's currentTime here. |
label | string | none | Names the slider for assistive tech. |
buffered | number | none | Seconds loaded so far, drawn a step darker than the unloaded bars. |
bars | number | none | How many bars to draw. Peaks are resampled, keeping the loudest in each slice. |
disabled | boolean | false | Stops seeking and drops it from the tab order. |
...rest | ComponentProps<"div"> | none | Every native div attribute. Set the height in style; it is 48px by default. |
Anatomy
| root | .eq-waveform | The slider itself. |
|---|---|---|
| bars | .eq-waveform-bars | The full waveform, unplayed. |
| loaded | .eq-waveform-loaded | The same bars, clipped to the buffered length. |
| played | .eq-waveform-played | The same bars in the solid fill, clipped to the playhead. |
| head | .eq-waveform-head | The playhead line. |
| hover | .eq-waveform-hover | Mouse only: where a click would land. |
| tip | .eq-waveform-tip | The time under the mouse. |
Keyboard
| ArrowLeftArrowDown | Seeks back 5 seconds. |
|---|---|
| ArrowRightArrowUp | Seeks forward 5 seconds. |
| PageDownPageUp | Seeks back or forward a tenth of the length. |
| HomeEnd | Jumps to the start or the end. |
Notes
- It is a slider with aria-valuetext such as "1:05 of 3:20", so it is announced like a seek bar.
- It does not play anything: pair it with an audio element or the audio player and wire value and onSeek both ways.
- Drawing takes peaks rather than decoding audio, so a long file costs nothing to show; compute peaks once and store them.
- Hover and the time tip are for mouse users; the keys and the announced value cover the same ground.
- Bars are an svg stretched to the box, so any width and height work without measuring.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/waveform.tsx173 lines
"use client";
import { useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import "./waveform.css";
export type WaveformProps = Omit<ComponentProps<"div">, "onChange" | "defaultValue" | "children"> & {
/** Loudness per slice, 0 to 1, oldest first. Any length; bars resamples it. */
peaks: number[];
/** Length of the audio in seconds. */
duration: number;
/** Playhead in seconds, when you control it. */
value?: number;
/** Playhead on first render, when you do not. */
defaultValue?: number;
/** Called while the user clicks, drags or presses keys to move the playhead. */
onSeek?: (seconds: number) => void;
/** Names the control, e.g. "Voice note from Priya". */
label: string;
/** How far the audio has loaded, in seconds. Drawn a step darker than the rest. */
buffered?: number;
/** Number of bars to draw. Defaults to one per peak. */
bars?: number;
disabled?: boolean;
};
/** 65 gives "1:05". */
function clock(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
/** Takes the loudest peak in each bucket, so short spikes survive the resampling. */
function resample(peaks: number[], count: number): number[] {
if (count <= 0 || peaks.length === 0) return [];
if (count === peaks.length) return peaks;
return Array.from({ length: count }, (_, i) => {
const from = Math.floor((i * peaks.length) / count);
const to = Math.max(from + 1, Math.floor(((i + 1) * peaks.length) / count));
return Math.max(...peaks.slice(from, to));
});
}
function Bars({ peaks, className }: { peaks: number[]; className: string }) {
return (
<svg className={className} viewBox={`0 0 ${peaks.length} 2`} preserveAspectRatio="none" aria-hidden="true">
{peaks.map((p, i) => {
// Rounded, so a server and a browser whose Math.sin differ in the last digit draw the same markup.
const h = Math.round(Math.max(0.06, Math.min(1, p || 0)) * 1000) / 1000;
return <rect key={i} x={i + 0.18} y={Math.round((1 - h) * 1000) / 1000} width={0.64} height={Math.round(h * 2000) / 1000} />;
})}
</svg>
);
}
export function Waveform({
peaks,
duration,
value,
defaultValue = 0,
onSeek,
label,
buffered,
bars,
disabled = false,
className,
style,
onKeyDown,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerLeave,
...rest
}: WaveformProps) {
const [inner, setInner] = useState(defaultValue);
const [dragging, setDragging] = useState(false);
const [hover, setHover] = useState<number | null>(null);
const length = Number.isFinite(duration) && duration > 0 ? duration : 0;
const current = Math.min(Math.max(value ?? inner, 0), length);
const shown = resample(peaks, bars ?? peaks.length);
const at = length > 0 ? current / length : 0;
const loaded = buffered === undefined || length === 0 ? 1 : Math.min(1, Math.max(0, buffered / length));
function seek(seconds: number) {
if (disabled || length === 0) return;
const next = Math.min(Math.max(seconds, 0), length);
if (value === undefined) setInner(next);
onSeek?.(next);
}
function fraction(event: PointerEvent<HTMLDivElement>) {
const box = event.currentTarget.getBoundingClientRect();
return box.width > 0 ? Math.min(1, Math.max(0, (event.clientX - box.left) / box.width)) : 0;
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
const big = length / 10;
const moves: Record<string, number> = {
ArrowLeft: current - 5,
ArrowDown: current - 5,
ArrowRight: current + 5,
ArrowUp: current + 5,
PageDown: current - big,
PageUp: current + big,
Home: 0,
End: length,
};
const next = moves[event.key];
if (next === undefined) return;
event.preventDefault();
event.stopPropagation();
seek(next);
}
return (
<div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={Math.round(length)}
aria-valuenow={Math.round(current)}
aria-valuetext={`${clock(current)} of ${clock(length)}`}
aria-disabled={disabled || undefined}
{...rest}
className={["eq-waveform", className].filter(Boolean).join(" ")}
style={
{
...style,
"--_at": at,
"--_loaded": loaded,
"--_hover": hover ?? 0,
} as CSSProperties
}
data-dragging={dragging || undefined}
data-hovering={hover !== null || undefined}
onKeyDown={handleKeyDown}
onPointerDown={(event) => {
onPointerDown?.(event);
if (event.defaultPrevented || disabled || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus({ preventScroll: true });
setDragging(true);
seek(fraction(event) * length);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
if (disabled) return;
const f = fraction(event);
if (event.pointerType === "mouse") setHover(f);
if (dragging) seek(f * length);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
setDragging(false);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setHover(null);
}}
onLostPointerCapture={() => setDragging(false)}
>
<Bars peaks={shown} className="eq-waveform-bars" />
<Bars peaks={shown} className="eq-waveform-loaded" />
<Bars peaks={shown} className="eq-waveform-played" />
<span className="eq-waveform-hover" aria-hidden="true">
<span className="eq-waveform-tip">{clock((hover ?? 0) * length)}</span>
</span>
<span className="eq-waveform-head" aria-hidden="true" />
</div>
);
}components/equip/waveform.css91 lines
@layer equip {
.eq-waveform {
position: relative;
box-sizing: border-box;
display: grid;
height: 48px;
min-width: 0;
border-radius: var(--eq-radius-s);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
cursor: pointer;
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
.eq-waveform:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-waveform[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
/* Three copies of the bars, stacked: the groove, how far it has loaded, and what has played. */
.eq-waveform-bars,
.eq-waveform-loaded,
.eq-waveform-played {
grid-area: 1 / 1;
display: block;
width: 100%;
height: 100%;
}
.eq-waveform-bars {
fill: var(--eq-tint-strong);
}
.eq-waveform-loaded {
fill: color-mix(in srgb, var(--eq-ink) 28%, transparent);
clip-path: inset(0 calc((1 - var(--_loaded)) * 100%) 0 0);
}
/* Follows real playback, so it is not eased: easing would only make it lag. */
.eq-waveform-played {
fill: var(--eq-solid);
clip-path: inset(0 calc((1 - var(--_at)) * 100%) 0 0);
}
.eq-waveform-head,
.eq-waveform-hover {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
border-radius: 1px;
pointer-events: none;
}
.eq-waveform-head {
left: calc(var(--_at) * 100%);
background: var(--eq-solid);
}
.eq-waveform-hover {
left: calc(var(--_hover) * 100%);
background: var(--eq-ink-3);
opacity: 0;
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-waveform[data-hovering] .eq-waveform-hover {
opacity: 1;
}
.eq-waveform-tip {
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
translate: -50% 0;
padding: 3px 5px;
border-radius: var(--eq-radius-s);
background: var(--eq-solid);
color: var(--eq-solid-ink);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
}