Give the note its shape
Three draggable breakpoints author attack, decay, sustain and release for a held synthesized note.

Drag envelope nodes, then hold the play key to hear the result. Arrows adjust times; up/down on the decay node changes sustain. Releasing the key starts the release ramp.
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"
export const DEFAULT_CONFIG = { timeScale: 2, noteFrequency: 196, minimumSegment: 10, showEnvelopeCursor: true }
type Config = typeof DEFAULT_CONFIG
type Envelope = { attack: number; decay: number; sustain: number; release: number }
type Voice = {
context: AudioContext
oscillator: OscillatorNode
gain: GainNode
started: number
envelope: Envelope
releaseAt: number | null
releaseGain: number
cursor: boolean
reduced: boolean
}
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, Number.isFinite(v) ? v : min))
export function envelopeGain(elapsed: number, envelope: Envelope) {
if (elapsed <= 0) {
return 0
}
if (elapsed < envelope.attack) {
return elapsed / envelope.attack
}
if (elapsed < envelope.attack + envelope.decay) {
return 1 - ((1 - envelope.sustain) * (elapsed - envelope.attack)) / envelope.decay
}
return envelope.sustain
}
const INITIAL = { attack: 0.18, decay: 0.3, sustain: 0.58, release: 0.6 }
function useFittedStage() {
const viewport = React.useRef<HTMLDivElement>(null)
const [scale, setScale] = React.useState(1)
React.useLayoutEffect(() => {
const element = viewport.current!
const fit = () => setScale(Math.min(element.clientWidth / 600, element.clientHeight / 375))
fit()
const observer = new ResizeObserver(fit)
observer.observe(element)
return () => observer.disconnect()
}, [])
return { viewport, scale }
}
export default function Example({ active = true, config }: { active?: boolean; config?: Partial<Config> }) {
const fitted = useFittedStage()
const settings = { ...DEFAULT_CONFIG, ...config }
const minimum = clamp(settings.minimumSegment, 5, 50) / 1000
const [envelope, setEnvelope] = React.useState<Envelope>(INITIAL)
const [keyboard, setKeyboard] = React.useState(true)
const [stage, setStage] = React.useState("Ready")
const [held, setHeld] = React.useState(false)
const [audioError, setAudioError] = React.useState(false)
const screen = React.useRef<HTMLDivElement>(null),
cursor = React.useRef<HTMLDivElement>(null),
meter = React.useRef<HTMLDivElement>(null)
const voice = React.useRef<Voice | null>(null),
intent = React.useRef(false),
frame = React.useRef(0),
generation = React.useRef(0)
const drag = React.useRef<{
kind: "attack" | "decay" | "release"
base: Envelope
owner: HTMLButtonElement
id: number
} | null>(null)
const view = Math.max(
clamp(settings.timeScale, 0.5, 4),
envelope.attack + envelope.decay + 0.6 + envelope.release + 0.08,
)
const x = (t: number) => 24 + (t / view) * 388
const points = [
{ kind: "attack" as const, x: x(envelope.attack), y: 24 },
{ kind: "decay" as const, x: x(envelope.attack + envelope.decay), y: 126 - envelope.sustain * 102 },
{ kind: "release" as const, x: x(envelope.attack + envelope.decay + 0.6 + envelope.release), y: 126 },
]
const curve = `M24 126 L${points[0].x} 24 L${points[1].x} ${points[1].y} H${x(
envelope.attack + envelope.decay + 0.6,
)} L${points[2].x} 126`
const dispose = React.useCallback(() => {
generation.current++
intent.current = false
cancelAnimationFrame(frame.current)
const v = voice.current
voice.current = null
if (v) {
v.oscillator.stop()
v.oscillator.disconnect()
v.gain.disconnect()
if (v.context.state !== "closed") {
void v.context.close()
}
}
if (meter.current) {
meter.current.style.transform = "scaleX(0)"
}
if (cursor.current) {
cursor.current.style.opacity = "0"
}
setHeld(false)
setStage("Ready")
}, [])
const cancelDrag = React.useCallback(() => {
const d = drag.current
drag.current = null
if (d) {
setEnvelope(d.base)
if (d.owner.hasPointerCapture(d.id)) {
d.owner.releasePointerCapture(d.id)
}
}
}, [])
const release = React.useCallback(() => {
intent.current = false
setHeld(false)
const v = voice.current
if (!v || v.releaseAt !== null) {
return
}
const now = v.context.currentTime
const gain = envelopeGain(now - v.started, v.envelope)
v.gain.gain.cancelScheduledValues(now)
v.gain.gain.setValueAtTime(gain * 0.13, now)
v.gain.gain.linearRampToValueAtTime(0, now + v.envelope.release)
v.releaseAt = now
v.releaseGain = gain
setStage("Release")
}, [])
React.useEffect(() => {
if (!active) {
dispose()
cancelDrag()
}
}, [active, dispose, cancelDrag])
React.useEffect(() => {
setEnvelope((v) => ({
...v,
attack: Math.max(v.attack, minimum),
decay: Math.max(v.decay, minimum),
release: Math.max(v.release, minimum),
}))
}, [minimum])
React.useEffect(() => {
const blur = () => {
release()
cancelDrag()
}
const hidden = () => {
if (document.hidden) {
dispose()
cancelDrag()
}
}
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hidden)
return () => {
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hidden)
dispose()
}
}, [dispose, release, cancelDrag])
async function play() {
if (!active || intent.current) {
return
}
dispose()
intent.current = true
setHeld(true)
setAudioError(false)
const token = generation.current
try {
const context = new AudioContext()
await context.resume()
if (!intent.current || token !== generation.current) {
void context.close()
return
}
const oscillator = context.createOscillator(),
gain = context.createGain(),
start = context.currentTime
oscillator.type = "triangle"
oscillator.frequency.value = clamp(settings.noteFrequency, 98, 392)
gain.gain.setValueAtTime(0, start)
gain.gain.linearRampToValueAtTime(0.13, start + envelope.attack)
gain.gain.linearRampToValueAtTime(envelope.sustain * 0.13, start + envelope.attack + envelope.decay)
oscillator.connect(gain)
gain.connect(context.destination)
oscillator.start(start)
voice.current = {
context,
oscillator,
gain,
started: start,
envelope: { ...envelope },
releaseAt: null,
releaseGain: 0,
cursor: settings.showEnvelopeCursor,
reduced: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
}
const tick = () => {
const v = voice.current
if (!v) {
return
}
const elapsed = v.context.currentTime - v.started,
released = v.releaseAt === null ? 0 : v.context.currentTime - v.releaseAt
if (v.releaseAt !== null && released >= v.envelope.release) {
dispose()
return
}
const amplitude =
v.releaseAt === null ? envelopeGain(elapsed, v.envelope) : v.releaseGain * (1 - released / v.envelope.release)
const name =
v.releaseAt !== null
? "Release"
: elapsed < v.envelope.attack
? "Attack"
: elapsed < v.envelope.attack + v.envelope.decay
? "Decay"
: "Sustain"
setStage((previous) => (previous === name ? previous : name))
if (meter.current) {
meter.current.style.transform = `scaleX(${v.reduced ? 1 : amplitude})`
}
if (cursor.current) {
const t =
v.releaseAt !== null
? v.envelope.attack + v.envelope.decay + 0.6 + released
: Math.min(elapsed, v.envelope.attack + v.envelope.decay + 0.6)
cursor.current.style.transform = `translateX(${((24 + (t / view) * 388) / 436) * 100}%)`
cursor.current.style.opacity = v.cursor && !v.reduced ? "1" : "0"
}
frame.current = requestAnimationFrame(tick)
}
frame.current = requestAnimationFrame(tick)
} catch {
dispose()
setAudioError(true)
}
}
function move(event: React.PointerEvent<HTMLButtonElement>) {
const d = drag.current,
element = screen.current
if (!d || !element) {
return
}
const rect = element.getBoundingClientRect(),
px = ((event.clientX - rect.left) / rect.width) * 436,
py = ((event.clientY - rect.top) / rect.height) * 150
const t = ((px - 24) / 388) * view
setEnvelope((e) =>
d.kind === "attack"
? { ...e, attack: clamp(t, minimum, 1.2) }
: d.kind === "decay"
? {
...e,
decay: clamp(t - e.attack, minimum, 1.2),
sustain: Math.round(clamp((126 - py) / 102, 0, 1) * 100) / 100,
}
: { ...e, release: clamp(t - e.attack - e.decay - 0.6, minimum, 1.5) },
)
}
return (
<div
ref={fitted.viewport}
style={{
width: "100%",
height: "100%",
aspectRatio: "16 / 10",
position: "relative",
overflow: "hidden",
background: "#333a3c",
}}
>
<div
style={{
width: 600,
height: 375,
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${fitted.scale})`,
transformOrigin: "center",
}}
>
<div
className="envelope-shaper"
data-envelope={JSON.stringify(envelope)}
data-stage={stage}
data-keyboard={keyboard}
onKeyDownCapture={() => setKeyboard(true)}
onPointerDownCapture={() => setKeyboard(false)}
onKeyDown={(e) => {
if (e.key === "Escape") {
cancelDrag()
release()
}
}}
>
<style>{`
.envelope-shaper{height:100%;width:100%;display:flex;align-items:center;justify-content:center;background:#333a3c;font-family:Arial,sans-serif;padding:25px;box-sizing:border-box;color:#252a2c}.envelope-shaper *{box-sizing:border-box}.es-unit{width:500px;padding:21px 26px 20px;border-radius:16px;background:linear-gradient(135deg,#e6e6e0,#c7cac6);border:1px solid #f0f0e8;box-shadow:0 2px 0 #929894,0 5px 0 #6e7774,0 18px 30px #0005;position:relative}.es-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}.es-header strong{font-size:11px;letter-spacing:1px;font-weight:700}.es-header span{font:9px monospace;color:#6a7471;letter-spacing:.6px}.es-screen{height:154px;border:2px solid #78817a;border-radius:7px;background:#172722;box-shadow:inset 0 3px 9px #000a,0 1px #ffffffa1;position:relative;overflow:hidden}.es-screen svg{width:100%;height:100%;display:block}.es-cursor{position:absolute;left:0;top:13%;height:72%;width:100%;opacity:0;pointer-events:none}.es-cursor:after{content:"";position:absolute;inset:0 auto 0 0;width:1px;background:#e8b857;box-shadow:0 0 7px #edbc5480}.es-handle{position:absolute;width:44px;height:44px;transform:translate(-50%,-50%);border:0;background:none;touch-action:none;cursor:ew-resize;display:grid;place-items:center;padding:0}.es-handle:after{content:"";width:8px;height:8px;border:2px solid #edc376;background:#20372d;box-shadow:0 0 0 3px #152b22;border-radius:50%}.es-handle[data-kind=decay]{cursor:move}.es-handle:hover:after,.es-handle:active:after{background:#edc376;box-shadow:0 0 0 4px #edc37625}.envelope-shaper[data-keyboard=true] .es-handle:focus-visible{outline:1px solid #f0c676;outline-offset:-6px;border-radius:50%}.es-values{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:12px 2px 15px;border-bottom:1px solid #aeb6ae80}.es-value span{display:block;font:8px monospace;letter-spacing:.7px;color:#66716b;margin-bottom:4px}.es-value strong{font:13px monospace;font-weight:400}.es-value small{font:9px monospace;color:#67736a;margin-left:3px}.es-footer{display:flex;align-items:center;justify-content:space-between;padding-top:13px}.es-state{display:flex;align-items:center;gap:10px;font-size:10px;color:#4e5a52}.es-meter{width:47px;height:5px;background:#a4aea6;border-radius:1px;overflow:hidden}.es-meter div{height:100%;background:#526d45;transform:scaleX(0);transform-origin:left}.es-key{position:relative;min-width:124px;height:38px;border:1px solid #6e7970;border-radius:7px;background:linear-gradient(#f3f1e7,#c9d0c5);box-shadow:inset 0 1px #fff,0 3px 0 #7c877d,0 4px 5px #35463630;color:#344334;font-size:11px;cursor:pointer;touch-action:none;transition:transform 90ms ease,box-shadow 90ms ease}.es-key[aria-pressed=true]{transform:translateY(2px);box-shadow:inset 0 1px 3px #34433430,0 1px 0 #7c877d}.es-key:before{content:"";display:inline-block;width:5px;height:5px;margin-right:10px;border-radius:50%;background:#7c8b72;box-shadow:inset 0 1px 2px #0003}.es-key[aria-pressed=true]:before{background:#f9bd55;box-shadow:0 0 5px #f1a424}.envelope-shaper[data-keyboard=true] .es-key:focus-visible{outline:2px solid #344b2e;outline-offset:4px}.es-key:disabled{opacity:.5;cursor:default}.es-screw{position:absolute;width:4px;height:4px;border:1px solid #89968b;border-radius:50%;box-shadow:0 1px #fff8;right:9px;top:9px}.es-screw:after{content:"";position:absolute;top:1px;left:0;width:2px;height:1px;background:#879187}
@media(prefers-reduced-motion:reduce){.es-key{transition:none}}
`}</style>
<section className="es-unit" aria-label="Amplitude envelope instrument">
<i className="es-screw" />
<div className="es-header">
<strong>CONTOUR</strong>
<span>AMPLITUDE</span>
</div>
<div className="es-screen" ref={screen}>
<svg viewBox="0 0 436 150" preserveAspectRatio="none" aria-hidden="true">
<path d="M24 24H412M24 75H412M24 126H412" stroke="#6b8b7020" strokeWidth=".6" />
{Array.from({ length: 13 }, (_, i) => (
<path key={i} d={`M${24 + i * 32.3} 24V126`} stroke="#6b8b7020" strokeWidth=".6" />
))}
<path d={`${curve} H24 Z`} fill="#d4b5680e" />
<path d={curve} fill="none" stroke="#e6bd70" strokeWidth="1.6" strokeLinejoin="round" />
<path
d={`M${x(envelope.attack + envelope.decay + 0.6)} 24V126`}
stroke="#a5bca74a"
strokeWidth=".6"
strokeDasharray="2 4"
/>
<text x="26" y="143" fill="#85917c" fontFamily="monospace" fontSize="7">
0
</text>
<text x="408" y="143" fill="#85917c" fontFamily="monospace" fontSize="7" textAnchor="end">
{view.toFixed(1)} s
</text>
</svg>
<div className="es-cursor" ref={cursor} />
{points.map((p) => (
<button
key={p.kind}
className="es-handle"
data-kind={p.kind}
role="slider"
aria-label={p.kind === "decay" ? "Decay and sustain" : `${p.kind[0].toUpperCase()}${p.kind.slice(1)}`}
aria-valuemin={minimum * 1000}
aria-valuemax={p.kind === "release" ? 1500 : 1200}
aria-valuenow={Math.round(envelope[p.kind] * 1000)}
aria-valuetext={
p.kind === "decay"
? `${Math.round(envelope.decay * 1000)} milliseconds decay, ${Math.round(
envelope.sustain * 100,
)} percent sustain. Left and right change decay; up and down change sustain.`
: `${Math.round(envelope[p.kind] * 1000)} milliseconds`
}
disabled={!active}
style={{ left: `${(p.x / 436) * 100}%`, top: `${(p.y / 150) * 100}%` }}
onPointerDown={(e) => {
if (e.button !== 0) {
return
}
e.preventDefault()
e.currentTarget.focus()
drag.current = { kind: p.kind, base: { ...envelope }, owner: e.currentTarget, id: e.pointerId }
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={move}
onPointerUp={(e) => {
drag.current = null
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}}
onPointerCancel={cancelDrag}
onLostPointerCapture={() => {
if (drag.current) {
cancelDrag()
}
}}
onKeyDown={(e) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) {
return
}
e.preventDefault()
const direction = ["ArrowLeft", "ArrowDown"].includes(e.key) ? -1 : 1
setEnvelope((v) =>
p.kind === "decay" && ["ArrowUp", "ArrowDown"].includes(e.key)
? { ...v, sustain: clamp(v.sustain + direction * 0.05, 0, 1) }
: {
...v,
[p.kind]: clamp(v[p.kind] + direction * 0.01, minimum, p.kind === "release" ? 1.5 : 1.2),
},
)
}}
/>
))}
</div>
<div className="es-values">
{(
[
["Attack", envelope.attack * 1000, "ms"],
["Decay", envelope.decay * 1000, "ms"],
["Sustain", envelope.sustain * 100, "%"],
["Release", envelope.release * 1000, "ms"],
] as const
).map(([label, value, unit]) => (
<div className="es-value" key={label}>
<span>{label.toUpperCase()}</span>
<strong>{Math.round(value)}</strong>
<small>{unit}</small>
</div>
))}
</div>
<div className="es-footer">
<div className="es-state">
<div className="es-meter">
<div ref={meter} />
</div>
<span role="status">{audioError ? "Audio unavailable" : stage}</span>
</div>
<button
className="es-key"
aria-pressed={held}
aria-label="Hold to play note"
disabled={!active}
onPointerDown={(e) => {
if (e.button !== 0) {
return
}
e.preventDefault()
e.currentTarget.focus()
e.currentTarget.setPointerCapture(e.pointerId)
void play()
}}
onPointerUp={release}
onPointerCancel={release}
onLostPointerCapture={release}
onBlur={release}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault()
void play()
}
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault()
release()
}
}}
>
Hold to play
</button>
</div>
</section>
</div>
</div>
</div>
)
}
Where it works well
Connect an editable amplitude curve to the audible lifecycle of a synthesizer or sound-design instrument.
Reduced motion
The authored curve stays static while editing. Reduced motion removes the playback cursor and live meter motion, retaining the stage label. Host pause, hidden document and unmount dispose audio.
Things to know
One native triangle oscillator with a modest fixed peak gain, linear envelope stages and no MIDI. Browser audio permission and output availability apply. The sustain span is a schematic 0.6 seconds; the actual note holds for as long as the key is held. Changes affect the next note.