Components & interactionsSpectrogram selection
Spectral selection
A burnt-orange spectrogram reveals a swept tone and lets you isolate a time-and-frequency region.

Drag a region, move it with arrow keys, or resize it with Shift and arrows.
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 spectrum() {
const columns = 120,
bins = 64,
size = 128,
rate = 1024,
hop = 32,
values = new Float32Array(columns * bins)
for (let x = 0; x < columns; x++) {
const samples = Array.from({ length: size }, (_, n) => {
const t = (x * hop + n) / rate
return (
(Math.sin(2 * Math.PI * (72 * t + 26 * t * t)) * 0.6 +
Math.sin(2 * Math.PI * 300 * t) * Math.exp(-Math.pow((t - 2.25) * 4, 2)) * 0.6) *
(0.5 - 0.5 * Math.cos((2 * Math.PI * n) / (size - 1)))
)
})
for (let k = 0; k < bins; k++) {
let re = 0,
im = 0
for (let n = 0; n < size; n++) {
const a = (2 * Math.PI * k * n) / size
re += samples[n] * Math.cos(a)
im -= samples[n] * Math.sin(a)
}
values[x * bins + k] = Math.max(0, Math.min(1, (20 * Math.log10(Math.hypot(re, im) / size + 1e-6) + 64) / 55))
}
}
return values
}
export default function Example({ active = true }: { active?: boolean }) {
const canvas = React.useRef<HTMLCanvasElement>(null),
[box, setBox] = React.useState([0.34, 0.18, 0.73, 0.62]),
drag = React.useRef<number[] | null>(null)
const data = React.useMemo(spectrum, [])
React.useEffect(() => {
const c = canvas.current
if (!c) return
const ctx = c.getContext("2d")
if (!ctx) return
// The FFT bins are data samples, not display pixels. Interpolate the heatmap
// into a display-density canvas while retaining the exact selection coordinates.
const source = document.createElement("canvas")
source.width = 120
source.height = 64
const sourceContext = source.getContext("2d")!
const image = sourceContext.createImageData(120, 64)
for (let x = 0; x < 120; x++)
for (let y = 0; y < 64; y++) {
const v = data[x * 64 + 63 - y],
i = (y * 120 + x) * 4
image.data[i] = 22 + Math.pow(v, 0.8) * 233
image.data[i + 1] = 18 + Math.pow(v, 2.1) * 177
image.data[i + 2] = 17 + Math.pow(v, 4) * 76
image.data[i + 3] = 255
}
sourceContext.putImageData(image, 0, 0)
const resize = () => {
const bounds = c.getBoundingClientRect()
const density = Math.min(devicePixelRatio || 1, 2)
c.width = Math.max(1, Math.round(bounds.width * density))
c.height = Math.max(1, Math.round(bounds.height * density))
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = "high"
ctx.drawImage(source, 0, 0, c.width, c.height)
}
let frame = 0
const schedule = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(resize)
}
const observer = new ResizeObserver(schedule)
observer.observe(c)
const transforms = new MutationObserver(schedule)
for (let ancestor = c.parentElement; ancestor; ancestor = ancestor.parentElement)
transforms.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] })
window.addEventListener("resize", schedule)
resize()
return () => {
observer.disconnect()
transforms.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", schedule)
}
}, [data])
const point = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect()
return [
Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)),
Math.max(0, Math.min(1, (e.clientY - r.top) / r.height)),
]
}
React.useEffect(() => {
if (!active) drag.current = null
}, [active])
function keyboard(e: React.KeyboardEvent<HTMLDivElement>) {
if (!active || !e.key.startsWith("Arrow")) return
e.preventDefault()
const dx = e.key === "ArrowLeft" ? -0.025 : e.key === "ArrowRight" ? 0.025 : 0,
dy = e.key === "ArrowUp" ? -0.025 : e.key === "ArrowDown" ? 0.025 : 0
setBox((b) => {
if (e.shiftKey)
return [
b[0],
b[1],
Math.max(b[0] + 0.03, Math.min(1, b[2] + dx)),
Math.max(b[1] + 0.03, Math.min(1, b[3] + dy)),
]
const x = Math.max(-b[0], Math.min(1 - b[2], dx)),
y = Math.max(-b[1], Math.min(1 - b[3], dy))
return [b[0] + x, b[1] + y, b[2] + x, b[3] + y]
})
}
return (
<div className="sf-spectral-selection">
<section className="spectrogram">
<header>
<strong>Spectral region</strong>
<button disabled={!active} onClick={() => setBox([0, 0, 1, 1])}>
Select all
</button>
</header>
<div className="plot">
<aside aria-hidden>
<span>512</span>
<span>256</span>
<span>0 Hz</span>
</aside>
<div
className="field"
role="group"
tabIndex={0}
aria-label="Time and frequency selection. Drag to select; arrow keys move; shift arrows resize."
onKeyDown={keyboard}
onPointerDown={(e) => {
if (!active) return
e.currentTarget.setPointerCapture(e.pointerId)
drag.current = point(e)
setBox([drag.current[0], drag.current[1], drag.current[0], drag.current[1]])
}}
onPointerMove={(e) => {
if (!drag.current || !active) return
const p = point(e),
d = drag.current
setBox([Math.min(p[0], d[0]), Math.min(p[1], d[1]), Math.max(p[0], d[0]), Math.max(p[1], d[1])])
}}
onPointerUp={() => (drag.current = null)}
onPointerCancel={() => (drag.current = null)}
onLostPointerCapture={() => (drag.current = null)}
>
<canvas ref={canvas} aria-label="Hann-windowed spectrum of an original chirp and brief 300 Hz tone" />
<div
className="selection"
style={{
left: box[0] * 100 + "%",
top: box[1] * 100 + "%",
width: (box[2] - box[0]) * 100 + "%",
height: (box[3] - box[1]) * 100 + "%",
}}
/>
</div>
</div>
<div className="axis" aria-hidden>
<span>0.0</span>
<span>1.0</span>
<span>2.0</span>
<span>3.75 s</span>
</div>
<footer>
<output>
{(box[0] * 3.75).toFixed(2)}–{(box[2] * 3.75).toFixed(2)} s
</output>
<output>
{Math.round((1 - box[3]) * 512)}–{Math.round((1 - box[1]) * 512)} Hz
</output>
</footer>
</section>
<style>{`.sf-spectral-selection{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-spectral-selection *{box-sizing:border-box}.sf-spectral-selection button,.sf-spectral-selection input,.sf-spectral-selection select,.sf-spectral-selection textarea{font:inherit;color:inherit}.sf-spectral-selection button{cursor:pointer;touch-action:manipulation}.sf-spectral-selection button:disabled{cursor:default;opacity:.45}.sf-spectral-selection :focus-visible{outline:2px solid #2767dc;outline-offset:3px}.sf-spectral-selection input[type=range]{min-height:44px;accent-color:currentColor;min-width:0}.sf-spectral-selection .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-spectral-selection[data-paused=true] *{animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-spectral-selection *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.sf-spectral-selection{background:#211c19;color:#e3d4c4;padding:24px}.sf-spectral-selection .spectrogram{width:min(100%,470px)}.sf-spectral-selection header{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px}.sf-spectral-selection strong{font-size:12px;font-weight:500}.sf-spectral-selection button{height:44px;padding:0 9px;background:none;border:0;color:#b18c6b;font-size:10px}.sf-spectral-selection .plot{display:flex;height:169px;gap:11px}.sf-spectral-selection aside{display:flex;flex-direction:column;justify-content:space-between;width:31px;flex-shrink:0;text-align:right;font:9px ui-monospace,monospace;color:#8c7e70}.sf-spectral-selection .field{flex:1;min-width:0;position:relative;touch-action:none;cursor:crosshair;border:1px solid #5d4633;overflow:hidden}.sf-spectral-selection canvas{width:100%;height:100%;display:block}.sf-spectral-selection .selection{position:absolute;border:1px solid #fff6df;background:#fff6df0b;box-shadow:0 0 0 500px #110a0640;pointer-events:none}.sf-spectral-selection .axis{display:flex;justify-content:space-between;margin:9px 0 0 43px;font:9px ui-monospace,monospace;color:#8c7e70}.sf-spectral-selection footer{display:flex;justify-content:space-between;margin:18px 0 0 43px;font:11px ui-monospace,monospace;color:#d9c5a9}@container(max-width:330px){.sf-spectral-selection .plot{height:151px}.sf-spectral-selection footer{font-size:10px}}`}</style>
</div>
)
}
Where it works well
Select a sound feature occupying only a time/frequency region.
Reduced motion
Selection follows direct input without ambient or eased motion.
Things to know
Bounded Hann-windowed DFT of original synthesized samples. Selection is visual; no audio editing, playback or export.