A lightweight set of React components and hooks that bridge the webaudio-controls custom element library into a Next.js / React project — without a module bundler import, without polling, and without recreating the DOM element every time a value changes.
- Why this wrapper exists
- How it works
- Setup
- Components
- Full example
- Adding a new control type
- Design decisions
webaudio-controls ships as a script that registers browser custom elements
(<webaudio-knob>, <webaudio-switch>, <webaudio-slider>). It is not an
ES module, so you can't import it. The components here load it via a
<script> tag, then wrap each custom element in idiomatic React so the rest
of the application never has to think about the DOM directly.
Three layers cooperate inside app/components/webaudio-controls.js:
waitForCustomElement(tagName)
└─ customElements.whenDefined(tagName) → Promise<true | false>
useWebAudioControl(tagName)
└─ wraps the above in a React effect → boolean (ready)
useControlElement(tagName, options)
├─ Effect 1 – element creation
│ Runs once when ready === true.
│ Creates the <webaudio-*> node, sets structural attrs, attaches listeners.
│ Only re-runs when structural attrs (min/max/colors/etc.) change.
└─ Effect 2 – value sync
Runs on every value change.
Imperatively sets element.value — no DOM teardown.
The onChange callback is stored in a ref so event listeners are attached
once and always call the latest version of the callback, regardless of how
many times the parent re-renders.
Download webaudio-controls.js from the
g200kg/webaudio-controls
repository and place it at:
public/
webaudio-controls.js
In app/layout.js (Next.js App Router):
import Script from 'next/script';
import './globals.css';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Script src="/webaudio-controls.js" strategy="beforeInteractive" />
{children}
</body>
</html>
);
}strategy="beforeInteractive" ensures the custom elements are registered
before any React hydration runs.
import Knob from './components/knob';
import Switch from './components/switch';
import Fader from './components/fader';Or import directly from the wrapper if you prefer:
import { Knob, Switch, Fader } from './components/webaudio-controls';All three components:
- Render a loading placeholder until the custom element is registered.
- Are purely controlled — pass
valueandonChange. - Accept a
classNameprop to extend the outer container's Tailwind classes.
A rotary control backed by <webaudio-knob>.
<Knob
label="Reverb"
value={reverbAmount}
min={0}
max={100}
step={1}
suffix="%"
onChange={(v) => setReverbAmount(v)}
/>| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
— | Text label shown below the knob. |
value |
number |
— | Controlled value. |
min |
number |
0 |
Minimum value. |
max |
number |
100 |
Maximum value. |
step |
number |
1 |
Step increment. |
onChange |
(value: number) => void |
— | Called with a coerced number on every interaction. |
formatValue |
(value: number) => string |
— | Custom display formatter. Overrides suffix. |
suffix |
string |
'' |
Appended to the numeric display (e.g. '%', ' dB'). |
diameter |
number |
72 |
Knob diameter in pixels. |
colors |
string |
'#7c93ff;#0f172a;#f8fafc' |
Semicolon-separated indicator;body;ring colours. |
className |
string |
'' |
Extra classes on the outer wrapper <div>. |
A toggle or momentary button backed by <webaudio-switch>.
<Switch
label="Thinking"
value={thinkingEnabled ? 1 : 0}
onChange={(v) => setThinkingEnabled(v === 1)}
/>| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
— | Text label shown below the switch. |
value |
number |
— | 0 = off, 1 = on. |
onChange |
(value: number) => void |
— | Called with 0 or 1 (coerced from string). |
type |
string |
'toggle' |
'toggle' or 'kick' (momentary). |
width |
number |
56 |
Width in pixels. |
height |
number |
56 |
Height in pixels. |
colors |
string |
'#7c93ff;#0f172a;#f8fafc' |
Semicolon-separated colours. |
className |
string |
'' |
Extra classes on the outer wrapper <div>. |
A vertical slider backed by <webaudio-slider>.
<Fader
label="Mix"
value={mixLevel}
min={0}
max={100}
step={1}
onChange={(v) => setMixLevel(v)}
/>| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
— | Text label shown below the fader. |
value |
number |
— | Controlled value. |
min |
number |
0 |
Minimum value. |
max |
number |
100 |
Maximum value. |
step |
number |
1 |
Step increment. |
onChange |
(value: number) => void |
— | Called with a coerced number on every interaction. |
width |
number |
72 |
Width in pixels. |
height |
number |
140 |
Height in pixels. |
colors |
string |
'#7c93ff;#0f172a;#f8fafc' |
Semicolon-separated colours. |
className |
string |
'' |
Extra classes on the outer wrapper <div>. |
'use client';
import { useState } from 'react';
import Knob from './components/knob';
import Switch from './components/switch';
import Fader from './components/fader';
export default function EffectsRack() {
const [reverb, setReverb] = useState(40);
const [gain, setGain] = useState(0);
const [bypass, setBypass] = useState(0);
const [mix, setMix] = useState(60);
return (
<div className="flex flex-wrap gap-4 p-6">
{/* Knob with a percent suffix */}
<Knob
label="Reverb"
value={reverb}
min={0}
max={100}
suffix="%"
onChange={setReverb}
/>
{/* Knob with a custom formatter */}
<Knob
label="Gain"
value={gain}
min={-12}
max={12}
formatValue={(v) => `${v >= 0 ? '+' : ''}${v.toFixed(0)} dB`}
onChange={setGain}
/>
{/* Toggle switch */}
<Switch
label="Bypass"
value={bypass}
onChange={setBypass}
/>
{/* Vertical fader */}
<Fader
label="Mix"
value={mix}
onChange={setMix}
/>
</div>
);
}The entire integration is built on two internal primitives:
useWebAudioControl(tagName)— resolvestrueonce the element is defined.useControlElement(tagName, options)— manages creation, attribute sync, and value sync for any<webaudio-*>element.
To add a new control (e.g. a <webaudio-param> display):
1. Add the export to webaudio-controls.js:
export function Param({ label, value, width = 64, height = 24, className = '' }) {
const handleChange = useCallback(
(nextValue) => { /* param elements are typically read-only */ },
[],
);
const { containerRef, ready } = useControlElement('webaudio-param', {
value,
width,
height,
onChange: handleChange,
});
return (
<div className={`flex flex-col items-center gap-2 ${className}`.trim()}>
{ready ? (
<div ref={containerRef} />
) : (
<div className="h-6 w-16 animate-pulse rounded bg-slate-800" />
)}
<span className="text-[11px] uppercase tracking-[0.35em] text-slate-400">{label}</span>
</div>
);
}
Param.displayName = 'Param';2. Create a thin re-export file app/components/param.js:
'use client';
export { Param as default } from './webaudio-controls';That's all. The loading/SSR/event-listener machinery is handled by the shared hooks.
| Decision | Rationale |
|---|---|
| Script tag, not npm import | webaudio-controls is not an ES module. Loading it as a public script is the supported approach and avoids bundler friction. |
customElements.whenDefined() |
The native API for this exact use case. No polling, no timer leaks. Resolves immediately if already registered. |
| Split creation / value-sync effects | A controlled value changing (e.g. a knob turning) should never destroy and rebuild the DOM element. Structural attributes (min/max/colors) changing is the only valid reason to recreate. |
onChangeRef pattern |
Storing the callback in a ref means event listeners are attached once for the element's lifetime. Parent re-renders with new inline functions do not cause re-attachments. |
Numbers out of onChange |
All three components coerce the string values emitted by webaudio-controls to JavaScript numbers before calling onChange, so consumers never have to parse them. |
'use client' directive |
webaudio-controls accesses the DOM and window. Marking the file as a Client Component prevents Next.js from attempting to run it on the server. |