Audio UI
The devices of a track, in the order the sound goes through them, with a library to add one from. Each slot shows the device's own panel, the face it has on its own page, so a device looks the same alone and in a chain. The chain keeps no list and makes no sound: it draws the slots you give it, in the order you give it, and a move, a removal or a device dropped on it comes back through a callback. Your engine follows the same list, whether it runs on Web Audio, in an AudioWorklet or in WASM.
Installation
pnpm dlx shadcn@latest add @audio-ui/device-chain
The library is an item of its own, npx shadcn@latest add @audio-ui/device-browser, which installs the chain with it. The block above, which plays a loop through the chain, is @audio-ui/block-device-chain.
Anatomy
<DeviceChain>
<DeviceChainSlot>
<CompressorPanel>
<DeviceChainSlotControls />
</CompressorPanel>
</DeviceChainSlot>
<DeviceChainAdd />
</DeviceChain>
<DeviceBrowser />Usage
A slot is an id, the device it holds and its state. Keep the list, give the chain its ids, and put the device's panel in each slot with DeviceChainSlotControls at the end of its header: the children of a panel go there. The devices are items of their own: npx shadcn@latest add @audio-ui/compressor @audio-ui/delay for the two below.
import { useState } from "react";
import {
DeviceChain,
DeviceChainAdd,
DeviceChainSlot,
DeviceChainSlotControls,
} from "@/components/audio-ui/device-chain";
import {
type CompressorState,
compressorDevice,
} from "@/components/audio-ui/devices/compressor/device";
import { CompressorPanel } from "@/components/audio-ui/devices/compressor/panel";
import {
type DelayState,
delayDevice,
} from "@/components/audio-ui/devices/delay/device";
import { DelayPanel } from "@/components/audio-ui/devices/delay/panel";
type Slot =
| { device: "compressor"; id: string; state: CompressorState }
| { device: "delay"; id: string; state: DelayState };
const [slots, setSlots] = useState<Slot[]>([
{ device: "compressor", id: "a", state: compressorDevice.defaults() },
{ device: "delay", id: "b", state: delayDevice.defaults() },
]);
const move = (id: string, index: number) =>
setSlots((current) => {
const moved = current.find((slot) => slot.id === id);
const rest = current.filter((slot) => slot.id !== id);
return moved ? [...rest.slice(0, index), moved, ...rest.slice(index)] : current;
});
const change = (id: string, state: Slot["state"]) =>
setSlots((current) =>
current.map((slot) => (slot.id === id ? ({ ...slot, state } as Slot) : slot))
);
<DeviceChain
aria-label="Devices"
onMove={move}
onRemove={(id) => setSlots((current) => current.filter((slot) => slot.id !== id))}
order={slots.map((slot) => slot.id)}
>
{slots.map((slot) => (
<DeviceChainSlot
enabled={slot.state.enabled}
key={slot.id}
onEnabledChange={(enabled) => change(slot.id, { ...slot.state, enabled })}
title={slot.device === "compressor" ? compressorDevice.name : delayDevice.name}
value={slot.id}
>
{slot.device === "compressor" ? (
<CompressorPanel
className="w-96"
onValueChange={(state) => change(slot.id, state)}
value={slot.state}
>
<DeviceChainSlotControls />
</CompressorPanel>
) : (
<DelayPanel
className="w-80"
onValueChange={(state) => change(slot.id, state)}
value={slot.state}
>
<DeviceChainSlotControls />
</DelayPanel>
)}
</DeviceChainSlot>
))}
<DeviceChainAdd onClick={openLibrary} />
</DeviceChain>;onMove(id, index) gives the place the slot lands in, counted once it has left its own: take it out of the list, then put it back at index. Leave out onMove and the slots stay where they are; leave out onRemove and none can be removed. The panel sets the width of its slot, so give it one with className. enabled and onEnabledChange are for the on and off square of the folded strip; open, the panel's own square does it.
A device of your own
A device with no panel of its own, such as the tremolo of Your own device, is drawn from its parameters by the frame, the header and the panel of @audio-ui/device-panel, so any device goes in a chain the day it is defined:
import {
DeviceFrame,
DeviceHeader,
DevicePanel,
} from "@/components/audio-ui/device-panel";
<DeviceChainSlot title={tremolo.name} value={slot.id}>
<DeviceFrame className="h-full w-72">
<DeviceHeader activator="on" device={tremolo} onValueChange={change} value={state}>
<DeviceChainSlotControls />
</DeviceHeader>
<DevicePanel
className="p-2"
device={tremolo}
exclude={["on"]}
onValueChange={change}
value={state}
/>
</DeviceFrame>
</DeviceChainSlot>;Library
DeviceBrowser lists devices by shelf, with a search over all of them. A device definition is an item as it is: it has an id, a name and a description.
import { DeviceBrowser } from "@/components/audio-ui/device-browser";
<DeviceBrowser
onPick={(id) => add(id)}
shelves={[
{ items: [compressorDevice, limiterDevice], label: "Dynamics" },
{ items: [delayDevice, reverbDevice], label: "Space" },
]}
/>;onPick gets the id of the device to add, at the end of the chain. A device dragged from the library onto the chain comes to its onAdd(id, index) instead, with the place it was dropped in. The drag carries the id under DEVICE_DRAG_TYPE, exported by device-chain, so anything of yours can be dragged onto a chain the same way:
import { DEVICE_DRAG_TYPE } from "@/components/audio-ui/device-chain";
<div
draggable
onDragStart={(event) => event.dataTransfer.setData(DEVICE_DRAG_TYPE, "audioui.reverb")}
/>;With open and onOpenChange, the library folds to a strip that opens it again. searchRef is its search field, for a DeviceChainAdd that opens the library and puts the focus in it.
Playing the chain
The block runs one engine per slot, in series, the output of each into the input of the next: a slot added makes an engine, a slot removed lets its engine go, a new state goes to its engine only, and a move wires them again in the new order. A compressor's key input, its sidechain, is fed with the kick of the loop, so switching its Sidechain on ducks the loop with it. The engines are the devices' own (<name>-engine), the same ones their pages play.
Over an engine of your own, keep the same list and send it the order and the states: the chain and the panels never touch the audio.
Exporting
Export WAV renders the loop through the chain into a file, faster than real time: the same engines made on an OfflineAudioContext, once those that load a worklet have it, and the file runs on for three seconds after the loop, for echoes and the tail of a reverb to die away. A render says nothing while it runs, so the block stops it a few times on the way (suspend) to show how far it has got. wav-file (npx shadcn@latest add @audio-ui/wav-file) turns what renders into a WAV file:
import { downloadWavFile } from "@/lib/wav-file";
const offline = new OfflineAudioContext(2, seconds * 48_000, 48_000);
// The same engines, made on `offline`, with the source connected and started.
const rendered = await offline.startRendering();
downloadWavFile(rendered, { bitDepth: 24, filename: "chain.wav" });downloadWavFile writes 16-bit PCM unless told 24, and normalize: true scales the sound so its loudest sample sits at full scale. encodeWav(channels, sampleRate, options) gives the bytes instead, to upload or to keep.
Interaction
Chain · 6
Library · 6
Each slot is a group named after its device, and its grip is a button, "Move Compressor", that says its keys. A move or a removal is announced ("Compressor moved to 2 of 3."). After a move the focus stays on the grip, after a removal it goes to the device that takes the place, and after a fold to the button that unfolds it. The library's list is a list box, and its shelves are toggle buttons.
API reference
DeviceChain
Props · 4
index, counted once it has left its place. Leave it out and the slots stay where they are.DeviceChainSlot
Props · 4
order.Parts
Components · 2
DeviceChainSlotControlsDeviceChainAddonClick. Its children replace the words.DeviceBrowser
Props · 5
More Shadcn Device chain components
Browse 1 production-ready audio/ui Device chain components for players, mixers, and synth interfaces. These examples use the headless primitives from @audio-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 1 Shadcn Device chain components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the audio/ui library.