Animation & motionScrolling signal waveform
Rolling signal
A fine waveform retains recent signal history so it can be held and inspected.

Hold the signal, then scrub its retained history.
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
}
const sample = (n: number) =>
Math.max(0.015, Math.abs(Math.sin(n * 0.27) * Math.cos(n * 0.087)) * (Math.sin(n * 0.013) > 0.1 ? 1 : 0.07))
export default function Example({ active = true }: { active?: boolean }) {
const canvas = React.useRef<HTMLCanvasElement>(null),
history = React.useRef(Array.from({ length: 900 }, (_, i) => sample(i + 200))),
counter = React.useRef(1100),
[held, setHeld] = React.useState(false),
[offset, setOffset] = React.useState(0),
[stamp, setStamp] = React.useState(1100 / 30)
const reduced = useReduced()
const draw = React.useCallback(() => {
const c = canvas.current
if (!c) return
const r = { width: c.clientWidth, height: c.clientHeight },
dpr = Math.min(devicePixelRatio, 2)
const display = c.getBoundingClientRect()
c.width = Math.max(1, Math.round(display.width * dpr))
c.height = Math.max(1, Math.round(display.height * dpr))
const ctx = c.getContext("2d")
if (!ctx) return
ctx.setTransform(c.width / Math.max(1, r.width), 0, 0, c.height / Math.max(1, r.height), 0, 0)
ctx.clearRect(0, 0, r.width, r.height)
ctx.strokeStyle = "#e5dfdc"
ctx.lineWidth = 1
for (let x = 0; x < r.width; x += r.width / 6) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, r.height)
ctx.stroke()
}
const count = 180,
data = history.current.slice(-count - offset, offset ? -offset : undefined)
data.forEach((v, i) => {
ctx.fillStyle = "#741c32"
const h = Math.max(1, v * r.height * 0.72)
ctx.fillRect((i / count) * r.width, r.height / 2 - h / 2, 1.2, h)
})
}, [offset])
React.useEffect(() => {
let resizeFrame = 0
const scheduleResize = () => {
cancelAnimationFrame(resizeFrame)
// Ancestor transforms can settle after layout observers have run.
resizeFrame = requestAnimationFrame(() => {
resizeFrame = requestAnimationFrame(draw)
})
}
const transforms = new MutationObserver(scheduleResize)
for (let ancestor = canvas.current?.parentElement; ancestor; ancestor = ancestor.parentElement) {
transforms.observe(ancestor, { attributes: true, attributeFilter: ["style", "class"] })
}
window.addEventListener("resize", scheduleResize)
const ro = new ResizeObserver(draw)
if (canvas.current) ro.observe(canvas.current)
draw()
return () => {
ro.disconnect()
transforms.disconnect()
window.removeEventListener("resize", scheduleResize)
cancelAnimationFrame(resizeFrame)
}
}, [draw])
React.useEffect(() => {
if (!active || reduced || held) return
const id = setInterval(() => {
history.current.push(sample(counter.current++))
if (history.current.length > 900) history.current.shift()
draw()
setStamp(counter.current / 30)
}, 1000 / 30)
return () => clearInterval(id)
}, [active, reduced, held, draw])
return (
<div className="sf-rolling-signal">
<section className="scope">
<header>
<span className="lamp" data-on={!held && !reduced && active} />
<strong>{held ? "History held" : reduced ? "Static signal preview" : "Signal preview"}</strong>
<output>{(stamp - offset / 30).toFixed(2)} s</output>
</header>
<div className="graph">
<canvas ref={canvas} aria-label="Retained procedural amplitude history" />
<div className="axis" aria-hidden>
{["−6s", "−4s", "−2s", "NOW"].map((t) => (
<span key={t}>{t}</span>
))}
</div>
</div>
<footer>
<button
disabled={!active || reduced}
onClick={() => {
setHeld(!held)
setOffset(0)
}}
>
{held ? "Resume" : "Hold"}
</button>
<label>
<span>History</span>
<input
disabled={!active || (!held && !reduced)}
aria-label="History position"
type="range"
min="0"
max="650"
value={650 - offset}
onChange={(e) => setOffset(650 - +e.target.value)}
/>
</label>
</footer>
</section>
<style>{`.sf-rolling-signal{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-rolling-signal *{box-sizing:border-box}.sf-rolling-signal button,.sf-rolling-signal input,.sf-rolling-signal select,.sf-rolling-signal textarea{font:inherit;color:inherit}.sf-rolling-signal button{cursor:pointer;touch-action:manipulation}.sf-rolling-signal button:disabled{cursor:default;opacity:.45}.sf-rolling-signal :focus-visible{outline:2px solid #2767dc;outline-offset:3px}.sf-rolling-signal input[type=range]{min-height:44px;accent-color:currentColor;min-width:0}.sf-rolling-signal .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-rolling-signal[data-paused=true] *{animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-rolling-signal *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.sf-rolling-signal{background:#f7f5f1;color:#662b37;padding:24px}.sf-rolling-signal .scope{width:min(100%,470px)}.sf-rolling-signal header{display:flex;align-items:center;gap:8px;margin-bottom:25px}.sf-rolling-signal .lamp{width:5px;height:5px;background:#bca2a2;border-radius:50%}.sf-rolling-signal .lamp[data-on=true]{background:#8d263f}.sf-rolling-signal strong{font-size:11px;font-weight:500}.sf-rolling-signal output{font:11px ui-monospace,monospace;margin-left:auto}.sf-rolling-signal .graph{height:146px;position:relative}.sf-rolling-signal canvas{width:100%;height:123px;display:block}.sf-rolling-signal .axis{display:flex;justify-content:space-between;font:9px ui-monospace,monospace;color:#a69195;margin-top:11px}.sf-rolling-signal footer{display:flex;align-items:center;gap:21px;margin-top:17px}.sf-rolling-signal button{background:#70263c;color:#fff8ef;border:0;border-radius:6px;min-height:44px;min-width:70px;font-size:12px}.sf-rolling-signal label{display:flex;align-items:center;gap:12px;flex:1;min-width:0;font-size:10px;color:#9e7e87}.sf-rolling-signal input{width:100%;accent-color:#70263c}`}</style>
</div>
)
}
Where it works well
Inspect the recent history of a continuous signal.
Reduced motion
The stream stops; the retained history remains inspectable.
Things to know
A generated signal with 900 retained samples; no microphone or stored audio.