Subframe
Components & interactionsAudio crossfade control

Stereo blend

Two synthesized timbres blend through complementary gain curves drawn on a blue instrument surface.

Enable sound and move the equal-power blend control.

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 Sources = { ctx: AudioContext; oscs: OscillatorNode[]; gains: GainNode[] }
export default function Example({ active = true }: { active?: boolean }) {
  const [blend, setBlend] = React.useState(0.36),
    [sound, setSound] = React.useState(false),
    [message, setMessage] = React.useState("")
  const audio = React.useRef<Sources | null>(null),
    gen = React.useRef(0)
  const reduced = useReduced()
  const stop = React.useCallback(() => {
    gen.current++
    if (audio.current) {
      closeContext(audio.current.ctx)
      audio.current = null
    }
    setSound(false)
  }, [])
  React.useEffect(
    () => () => {
      gen.current++
      if (audio.current) {
        closeContext(audio.current.ctx)
        audio.current = null
      }
    },
    [],
  )
  React.useEffect(() => {
    if (!active) stop()
  }, [active, stop])
  React.useEffect(() => {
    const a = audio.current
    if (a)
      a.gains.forEach((g, i) =>
        g.gain.setTargetAtTime(
          (i ? Math.sin((blend * Math.PI) / 2) : Math.cos((blend * Math.PI) / 2)) * 0.045,
          a.ctx.currentTime,
          0.025,
        ),
      )
  }, [blend])
  async function toggle() {
    if (sound) {
      stop()
      return
    }
    stop()
    const request = ++gen.current
    let ctx: AudioContext | null = null
    try {
      ctx = new AudioContext()
      const oscs = [ctx.createOscillator(), ctx.createOscillator()],
        gains = [ctx.createGain(), ctx.createGain()]
      audio.current = { ctx, oscs, gains }
      const destination = ctx.destination
      oscs.forEach((o, i) => {
        o.type = i ? "triangle" : "sine"
        o.frequency.value = i ? 330 : 220
        gains[i].gain.value = (i ? Math.sin((blend * Math.PI) / 2) : Math.cos((blend * Math.PI) / 2)) * 0.045
        o.connect(gains[i])
        gains[i].connect(destination)
      })
      await ctx.resume()
      if (request !== gen.current) {
        closeContext(ctx)
        return
      }
      oscs.forEach((o) => o.start())
      setSound(true)
      setMessage("")
    } catch {
      if (ctx) closeContext(ctx)
      if (audio.current?.ctx === ctx) audio.current = null
      if (request === gen.current) {
        setSound(false)
        setMessage("Audio is unavailable.")
      }
    }
  }
  const a = Math.cos((blend * Math.PI) / 2),
    b = Math.sin((blend * Math.PI) / 2)
  return (
    <div className="sf-stereo-blend" data-error={Boolean(message)} data-paused={!active || reduced}>
      <section className="schematic">
        <header>
          <span>Pure tone</span>
          <button disabled={!active} onClick={() => void toggle()}>
            {sound ? "Mute" : "Enable sound"}
          </button>
          <span>Overtone</span>
        </header>
        <svg
          viewBox="0 0 400 132"
          aria-label={`Equal-power gains ${Math.round(a * 100)} and ${Math.round(b * 100)} percent`}
        >
          <path d="M0 116H400M0 58H400M100 0V132M200 0V132M300 0V132" stroke="#ffffff21" fill="none" />
          {[0, 1].map((i) => (
            <path
              key={i}
              d={Array.from(
                { length: 81 },
                (_, x) =>
                  `${x ? "L" : "M"}${x * 5} ${
                    116 - (i ? Math.sin(((x / 80) * Math.PI) / 2) : Math.cos(((x / 80) * Math.PI) / 2)) * 100
                  }`,
              ).join(" ")}
              fill="none"
              stroke={i ? "#b8d8fc" : "#f4e8c9"}
              strokeWidth="2"
            />
          ))}
          <path d={`M${blend * 400} 0V132`} stroke="#ff6c4a" />
          <circle cx={blend * 400} cy={116 - a * 100} r="5" fill="#f4e8c9" />
          <circle cx={blend * 400} cy={116 - b * 100} r="5" fill="#b8d8fc" />
        </svg>
        <label>
          <span>A</span>
          <input
            type="range"
            aria-label="Equal-power blend"
            min="0"
            max="1"
            step=".01"
            value={blend}
            disabled={!active}
            onChange={(e) => setBlend(+e.target.value)}
          />
          <span>B</span>
        </label>
        <footer>
          <output>{Math.round(a * 100)}%</output>
          <span>Equal power</span>
          <output>{Math.round(b * 100)}%</output>
        </footer>
        {message && <p role="status">{message}</p>}
      </section>
      <style>{`.sf-stereo-blend{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-stereo-blend *{box-sizing:border-box}.sf-stereo-blend button,.sf-stereo-blend input,.sf-stereo-blend select,.sf-stereo-blend textarea{font:inherit;color:inherit}.sf-stereo-blend button{cursor:pointer;touch-action:manipulation}.sf-stereo-blend button:disabled{cursor:default;opacity:.45}.sf-stereo-blend :focus-visible{outline:2px solid #2767dc;outline-offset:3px}.sf-stereo-blend input[type=range]{min-height:44px;accent-color:currentColor;min-width:0}.sf-stereo-blend .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-stereo-blend[data-paused=true] *{animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-stereo-blend *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.sf-stereo-blend{background:#244d97;color:#e7ecf4;padding:26px}.sf-stereo-blend .schematic{width:min(100%,430px)}.sf-stereo-blend header{display:flex;align-items:center;justify-content:space-between;font-size:11px;margin-bottom:14px}.sf-stereo-blend button{min-height:44px;border:1px solid #ffffff40;border-radius:3px;background:none;color:#f5e9c9;padding:0 12px;font-size:10px}.sf-stereo-blend svg{display:block;width:100%;height:132px;border-block:1px solid #ffffff36}.sf-stereo-blend label{display:flex;gap:14px;align-items:center;margin-top:8px;font:11px ui-monospace,monospace}.sf-stereo-blend input{width:100%;accent-color:#fa704e}.sf-stereo-blend footer{display:flex;justify-content:space-between;font:12px ui-monospace,monospace}.sf-stereo-blend footer span{font:10px -apple-system,sans-serif;opacity:.55}.sf-stereo-blend p{font-size:11px}@container(max-width:330px){.sf-stereo-blend svg{height:119px}.sf-stereo-blend[data-error=true] svg{height:85px}}`}</style>
    </div>
  )
}

Where it works well

Compare or combine timbres without the center level collapsing.

Reduced motion

Gain changes retain short audio ramps that prevent clicks; the diagram redraws directly.

Things to know

Two local oscillators, not uploaded tracks. Equal-power gain law does not guarantee equal perceived loudness for all timbres.

More examples