Components & interactionsAudio spectrum analyzer
Spectrum stack
Fine logarithmic frequency bands reveal the harmonics of an adjustable synthesized tone.

Enable sound and change oscillator frequency.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
function useReduced() {
const [reduced, setReduced] = React.useState(true)
React.useEffect(() => {
const media = matchMedia("(prefers-reduced-motion: reduce)"),
update = () => setReduced(media.matches)
update()
media.addEventListener("change", update)
return () => media.removeEventListener("change", update)
}, [])
return reduced
}
function closeContext(ctx: AudioContext) {
if (ctx.state !== "closed") void ctx.close().catch(() => {})
}
type Synth = { ctx: AudioContext; osc: OscillatorNode; analyser: AnalyserNode }
function previewBands(freq: number) {
return Array.from({ length: 40 }, (_, i) => {
const hz = 60 * Math.pow(16000 / 60, i / 39)
let value = 0.025
for (let h = 1; h <= 12; h++)
value = Math.max(value, (Math.exp(-Math.pow(Math.log(hz / (freq * h)) * 11, 2)) * 0.9) / Math.sqrt(h))
return value
})
}
export default function Example({ active = true }: { active?: boolean }) {
const [enabled, setEnabled] = React.useState(false),
[freq, setFreq] = React.useState(440),
[levels, setLevels] = React.useState<number[]>(previewBands(440)),
[notice, setNotice] = React.useState("")
const synth = React.useRef<Synth | null>(null),
generation = React.useRef(0)
const reduced = useReduced()
const stop = React.useCallback(() => {
generation.current++
const s = synth.current
synth.current = null
if (s) {
closeContext(s.ctx)
}
setEnabled(false)
setLevels(previewBands(440))
}, [])
React.useEffect(
() => () => {
generation.current++
const s = synth.current
if (s) {
closeContext(s.ctx)
synth.current = null
}
},
[],
)
React.useEffect(() => {
if (!active) stop()
}, [active, stop])
async function toggle() {
if (enabled) {
stop()
return
}
stop()
const gen = ++generation.current
let ctx: AudioContext | null = null
try {
ctx = new AudioContext()
const osc = ctx.createOscillator(),
gain = ctx.createGain(),
analyser = ctx.createAnalyser()
synth.current = { ctx, osc, analyser }
osc.type = "sawtooth"
osc.frequency.value = freq
gain.gain.value = 0.035
analyser.fftSize = 2048
analyser.smoothingTimeConstant = 0.75
osc.connect(analyser)
analyser.connect(gain)
gain.connect(ctx.destination)
await ctx.resume()
if (gen !== generation.current) {
closeContext(ctx)
return
}
osc.start()
setEnabled(true)
setNotice("")
} catch {
if (ctx) closeContext(ctx)
if (synth.current?.ctx === ctx) synth.current = null
if (gen === generation.current) {
setEnabled(false)
setNotice("Audio is unavailable in this browser.")
}
}
}
React.useEffect(() => {
if (synth.current) synth.current.osc.frequency.setTargetAtTime(freq, synth.current.ctx.currentTime, 0.025)
else setLevels(previewBands(freq))
}, [freq, enabled])
React.useEffect(() => {
if (!enabled || !active) return
const s = synth.current
if (!s) return
const data = new Uint8Array(s.analyser.frequencyBinCount)
const update = () => {
s.analyser.getByteFrequencyData(data)
setLevels(
Array.from({ length: 40 }, (_, i) => {
const hz = 60 * Math.pow(16000 / 60, i / 39),
bin = Math.round(hz / (s.ctx.sampleRate / 2048))
return Math.max(0.025, data[Math.min(data.length - 1, bin)] / 255)
}),
)
}
update()
const id = setInterval(update, reduced ? 250 : 33)
return () => clearInterval(id)
}, [enabled, active, reduced])
return (
<div className="sf-spectrum-stack" data-error={Boolean(notice)} data-paused={!active || reduced}>
<section className="instrument">
<header>
<strong>{enabled ? "Measured spectrum" : "Spectrum preview"}</strong>
<output>
{freq}
<small>Hz</small>
</output>
</header>
<div className="bands" aria-label={enabled ? "Measured frequency bands" : "Analytical harmonic preview"}>
{levels.map((v, i) => (
<i key={i} style={{ transform: `scaleY(${v})`, transition: reduced ? "none" : "transform 70ms linear" }} />
))}
</div>
<div className="scale" aria-hidden>
<span>60</span>
<span>250</span>
<span>1k</span>
<span>4k</span>
<span>16k</span>
</div>
<footer>
<input
aria-label="Oscillator frequency"
disabled={!active}
type="range"
min="110"
max="1760"
step="1"
value={freq}
onChange={(e) => setFreq(+e.target.value)}
/>
<button disabled={!active} onClick={() => void toggle()}>
{enabled ? "Sound off" : "Enable sound"}
</button>
</footer>
{notice && <p role="status">{notice}</p>}
</section>
<style>{`.sf-spectrum-stack{width:100%;height:100%;min-height:260px;box-sizing:border-box;display:grid;grid-template-columns:minmax(0,1fr);place-items:center;padding:16px;background:#f4f4f3;color:#191a1b;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;isolation:isolate;overflow:hidden;container-type:inline-size}.sf-spectrum-stack *{box-sizing:border-box}.sf-spectrum-stack button,.sf-spectrum-stack input,.sf-spectrum-stack select,.sf-spectrum-stack textarea{font:inherit;color:inherit}.sf-spectrum-stack button{cursor:pointer;touch-action:manipulation}.sf-spectrum-stack button:disabled{cursor:default;opacity:.45}.sf-spectrum-stack :focus-visible{outline:2px solid #2767dc;outline-offset:3px}.sf-spectrum-stack input[type=range]{min-height:44px;accent-color:currentColor;min-width:0}.sf-spectrum-stack .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-spectrum-stack[data-paused=true] *{animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-spectrum-stack *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.sf-spectrum-stack{background:#456c9c;color:#f2eadb;padding:28px}.sf-spectrum-stack .instrument{width:min(100%,460px)}.sf-spectrum-stack header{display:flex;align-items:flex-end;justify-content:space-between;border-bottom:1px solid #ffffff2b;padding-bottom:12px}.sf-spectrum-stack strong{font-size:11px;font-weight:400;letter-spacing:.4px}.sf-spectrum-stack output{font-size:31px;font-weight:300;letter-spacing:-1px;font-variant-numeric:tabular-nums;line-height:1}.sf-spectrum-stack small{font-size:10px;margin-left:6px;letter-spacing:0}.sf-spectrum-stack .bands{height:135px;display:flex;align-items:flex-end;justify-content:space-between;gap:3px;padding-top:16px;background:repeating-linear-gradient(0deg,transparent 0 31px,#ffffff10 31px 32px)}.sf-spectrum-stack .bands i{height:100%;width:3px;background:#f7ebd2;transform-origin:bottom;display:block}.sf-spectrum-stack .scale{display:flex;justify-content:space-between;font:9px ui-monospace,monospace;opacity:.65;margin-top:10px}.sf-spectrum-stack footer{display:flex;gap:23px;align-items:center;margin-top:15px}.sf-spectrum-stack input{width:100%;accent-color:#ff784b}.sf-spectrum-stack button{min-width:105px;height:44px;background:#f0e5ce;color:#254975;border:0;border-radius:4px;font-size:11px}.sf-spectrum-stack p{font-size:11px;margin:6px 0 0}@container(max-width:330px){.sf-spectrum-stack .bands{height:111px}.sf-spectrum-stack[data-error=true] .bands{height:85px}}`}</style>
</div>
)
}
Where it works well
Show where energy is concentrated; make a filter adjustment legible.
Reduced motion
Bars update without interpolated motion at four samples per second; sound remains opt-in.
Things to know
Uses a native oscillator and AnalyserNode. Idle bands are an analytical preview, not live microphone data.