Percussion navigator
A detailed percussion waveform stays connected to a draggable overview as you pan and magnify its transients.

Drag the overview window or choose 1×, 2×, 4×, or 8× magnification
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 { useMemo, useRef, useState } from "react"
const DURATION = 12
const SAMPLE_RATE = 2000
const beats = [0.32, 1.02, 1.46, 2.35, 3.06, 3.27, 4.32, 5.1, 5.58, 6.4, 7.16, 7.43, 8.34, 9.08, 9.6, 10.48, 11.15]
// A deterministic percussion signal. Both views read these same samples.
const samples = Float32Array.from({ length: DURATION * SAMPLE_RATE }, (_, i) => {
const t = i / SAMPLE_RATE
return beats.reduce((v, beat, n) => {
const age = t - beat
if (age < 0 || age > 1.1) return v
const decay = n % 3 === 0 ? 0.23 : 0.09
const tone = Math.sin(age * (n % 3 === 0 ? 420 : 970) + 70 * Math.exp(-age * 25))
const texture = Math.sin(age * 5641) * Math.sin(age * 3543)
return v + Math.exp(-age / decay) * (tone * 0.72 + texture * 0.28) * (n % 4 === 2 ? 0.5 : 0.95)
}, 0)
})
function signalPath(start: number, span: number, width: number, height: number) {
const top: string[] = [],
bottom: string[] = []
for (let x = 0; x <= width; x++) {
const a = Math.floor((start + (x / width) * span) * SAMPLE_RATE)
const b = Math.min(samples.length, Math.ceil((start + ((x + 1) / width) * span) * SAMPLE_RATE))
let lo = 0,
hi = 0
for (let i = a; i < b; i++) {
lo = Math.min(lo, samples[i])
hi = Math.max(hi, samples[i])
}
top.push(`${x},${(height / 2 - hi * height * 0.43).toFixed(2)}`)
bottom.push(`${x},${(height / 2 - lo * height * 0.43).toFixed(2)}`)
}
return `M${top.join("L")}L${bottom.reverse().join("L")}Z`
}
const overview = signalPath(0, DURATION, 480, 42)
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
export default function RangeZoom({ active = true }: { active?: boolean }) {
const [view, setView] = useState({ start: 2.4, span: 6 })
const track = useRef<HTMLDivElement>(null)
const drag = useRef<{ pointerId: number; x: number; start: number; width: number } | null>(null)
const path = useMemo(() => signalPath(view.start, view.span, 480, 126), [view])
const zoom = (span: number) =>
setView((v) => ({ span, start: clamp(v.start + (v.span - span) / 2, 0, DURATION - span) }))
return (
<div className="sf-rz2" data-active={active}>
<style>{`
.sf-rz2{height:100%;min-height:300px;display:grid;place-items:center;background:#6d2434;color:#262329;font:12px Arial,sans-serif}.sf-rz2 *{box-sizing:border-box}.sf-rz2-panel{width:min(510px,92%);background:#f0eee8;border:1px solid #f8f6ef;border-radius:9px;padding:20px 22px 17px;box-shadow:0 2px 1px #39071755,0 18px 36px #350a1b44}.sf-rz2-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.sf-rz2-title{display:flex;align-items:center;gap:8px;font-weight:600;letter-spacing:-.2px}.sf-rz2-title svg{color:#a03c4e}.sf-rz2-head output{font:10px ui-monospace,monospace;color:#716d70;white-space:nowrap}.sf-rz2-ruler{display:flex;justify-content:space-between;font:9px ui-monospace,monospace;color:#898287;padding-bottom:9px}.sf-rz2-wave{position:relative;height:126px;border-top:1px solid #cfcac7;border-bottom:1px solid #cfcac7;background:repeating-linear-gradient(90deg,#d8d3ce88 0 1px,transparent 1px 25%),linear-gradient(transparent calc(50% - .5px),#cfcac7 calc(50% - .5px),#cfcac7 calc(50% + .5px),transparent calc(50% + .5px))}.sf-rz2-wave svg{display:block;width:100%;height:100%;fill:#982c43}.sf-rz2-bottom{display:flex;justify-content:space-between;align-items:center;margin:13px 0 8px}.sf-rz2-bottom span{font:9px ui-monospace,monospace;color:#7d7579}.sf-rz2-zooms{display:flex;gap:2px;padding:2px;background:#dfdcd6;border-radius:5px}.sf-rz2-zooms button{border:0;border-radius:3px;color:#7c7377;min-width:35px;height:27px;background:transparent;cursor:pointer;font:10px ui-monospace,monospace}.sf-rz2-zooms button[aria-pressed=true]{color:#732139;background:#fffdf8;box-shadow:0 1px 2px #6e596128}.sf-rz2-mini{height:42px;position:relative;border-radius:3px;background:#ddd8d2;touch-action:none}.sf-rz2-mini>svg{width:100%;height:100%;display:block;fill:#9c8d8c}.sf-rz2-window{position:absolute;top:-1px;bottom:-1px;padding:0;border:1.5px solid #922b46;border-radius:4px;background:#f8c7b320;box-shadow:0 0 0 1px #fff8;cursor:grab;touch-action:none;min-width:30px}.sf-rz2-window:active{cursor:grabbing}.sf-rz2-window:before,.sf-rz2-window:after{content:"";position:absolute;top:14px;width:2px;height:12px;border-radius:1px;background:#922b46;box-shadow:1px 0 #fff8}.sf-rz2-window:before{left:4px}.sf-rz2-window:after{right:4px}.sf-rz2 button:focus-visible{outline:2px solid #5c1430;outline-offset:3px}.sf-rz2 button{transition:background 120ms ease}.sf-rz2[data-active=false] button{transition:none}@media(hover:hover){.sf-rz2-zooms button:hover{background:#f8f5ef}}@media(prefers-reduced-motion:reduce){.sf-rz2 button{transition:none}}
`}</style>
<div className="sf-rz2-panel">
<div className="sf-rz2-head">
<div className="sf-rz2-title">Percussion study</div>
<output aria-live="off">
{view.start.toFixed(2)} — {(view.start + view.span).toFixed(2)} s
</output>
</div>
<div className="sf-rz2-ruler" aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => (
<span key={i}>{(view.start + (view.span * i) / 4).toFixed(2)}</span>
))}
</div>
<div className="sf-rz2-wave">
<svg
viewBox="0 0 480 126"
preserveAspectRatio="none"
role="img"
aria-label={`Synthetic percussion waveform from ${view.start.toFixed(2)} to ${(
view.start + view.span
).toFixed(2)} seconds`}
>
<path d={path} />
</svg>
</div>
<div className="sf-rz2-bottom">
<span>12.00 s overview</span>
<div className="sf-rz2-zooms" aria-label="Waveform magnification">
{[1, 2, 4, 8].map((n) => (
<button
type="button"
key={n}
aria-label={`${n} times magnification`}
aria-pressed={view.span === DURATION / n}
onClick={() => zoom(DURATION / n)}
>
{n}×
</button>
))}
</div>
</div>
<div className="sf-rz2-mini" ref={track}>
<svg viewBox="0 0 480 42" preserveAspectRatio="none" aria-hidden="true">
<path d={overview} />
</svg>
<button
type="button"
role="slider"
aria-label="Visible waveform range"
aria-valuemin={0}
aria-valuemax={DURATION - view.span}
aria-valuenow={Number(view.start.toFixed(2))}
aria-valuetext={`${view.start.toFixed(2)} to ${(view.start + view.span).toFixed(
2,
)} seconds. Arrow keys pan.`}
className="sf-rz2-window"
style={{ left: `${(view.start / DURATION) * 100}%`, width: `${(view.span / DURATION) * 100}%` }}
onPointerDown={(e) => {
if (e.button !== 0 || drag.current) return
const width = track.current!.getBoundingClientRect().width
drag.current = { pointerId: e.pointerId, x: e.clientX, start: view.start, width }
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
const d = drag.current
if (d?.pointerId === e.pointerId)
setView((v) => ({
...v,
start: clamp(d.start + ((e.clientX - d.x) / d.width) * DURATION, 0, DURATION - v.span),
}))
}}
onPointerUp={(e) => {
if (drag.current?.pointerId !== e.pointerId) return
drag.current = null
}}
onPointerCancel={(e) => {
if (drag.current?.pointerId !== e.pointerId) return
drag.current = null
}}
onLostPointerCapture={(e) => {
if (drag.current?.pointerId !== e.pointerId) return
drag.current = null
}}
onKeyDown={(e) => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return
e.preventDefault()
setView((v) => ({
...v,
start:
e.key === "Home"
? 0
: e.key === "End"
? DURATION - v.span
: clamp(v.start + (e.key === "ArrowRight" ? 1 : -1) * (e.shiftKey ? 1 : 0.1), 0, DURATION - v.span),
}))
}}
/>
</div>
</div>
</div>
)
}
Where it works well
Navigating audio, sensor signals, or other dense time series while keeping the full sequence in view.
Reduced motion
All waveform navigation is immediate; reduced motion also removes control color transitions.
Things to know
One deterministic 12-second synthetic percussion signal; no audio playback, media import, trimming, or real-time recording. Min/max envelopes sample the same data at both scales.