Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

webaudio-controls React Wrapper

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.


Contents


Why this wrapper exists

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.


How it works

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.


Setup

1. Copy the script into public/

Download webaudio-controls.js from the g200kg/webaudio-controls repository and place it at:

public/
  webaudio-controls.js

2. Load it before the app renders

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.

3. Use the components

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';

Components

All three components:

  • Render a loading placeholder until the custom element is registered.
  • Are purely controlled — pass value and onChange.
  • Accept a className prop to extend the outer container's Tailwind classes.

Knob

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>.

Switch

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>.

Fader

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>.

Full example

'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>
  );
}

Adding a new control type

The entire integration is built on two internal primitives:

  • useWebAudioControl(tagName) — resolves true once 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.


Design decisions

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.

About

A lightweight set of React components and hooks that bridge webaudio-controls.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages