# Device chain

> A track's devices in a row, each drawn by its own panel: move, fold, switch off and remove them, and drop one from a library where it goes. Controlled, with no audio in it.

```tsx
"use client";

import type {
  Device,
  DeviceContext,
  DeviceState,
  ParameterDescriptor,
  ParameterValue,
} from "@audio-ui/core/device";
import type { Tempo } from "@audio-ui/core/time";
import {
  type ComponentType,
  type ReactNode,
  useCallback,
  useMemo,
  useRef,
  useState,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { Button } from "@/components/ui/button";
import { DeviceBrowser } from "@/components/audio-ui/device-browser";
import {
  DeviceChain,
  DeviceChainAdd,
  DeviceChainSlot,
  DeviceChainSlotControls,
} from "@/components/audio-ui/device-chain";
import { ChorusPanel } from "@/components/audio-ui/devices/chorus/panel";
import { CompressorPanel } from "@/components/audio-ui/devices/compressor/panel";
import { DelayPanel } from "@/components/audio-ui/devices/delay/panel";
import { EqualizerPanel } from "@/components/audio-ui/devices/equalizer/panel";
import { FilterPanel } from "@/components/audio-ui/devices/filter/panel";
import { LimiterPanel } from "@/components/audio-ui/devices/limiter/panel";
import { ReduxPanel } from "@/components/audio-ui/devices/redux/panel";
import { ReverbPanel } from "@/components/audio-ui/devices/reverb/panel";
import { SaturatorPanel } from "@/components/audio-ui/devices/saturator/panel";
import { UtilityPanel } from "@/components/audio-ui/devices/utility/panel";
import { useDemoSource } from "@/hooks/use-demo-source";
import {
  type DemoEngine,
  useDeviceLoop,
} from "@/hooks/use-device-loop";
import { useMasterBus } from "@/hooks/use-master-bus";
import { chorusDevice } from "@/components/audio-ui/devices/chorus/device";
import { createChorusEngine } from "@/components/audio-ui/devices/chorus/engine";
import { compressorDevice } from "@/components/audio-ui/devices/compressor/device";
import { createCompressorEngine } from "@/components/audio-ui/devices/compressor/engine";
import { delayDevice } from "@/components/audio-ui/devices/delay/device";
import { createDelayEngine } from "@/components/audio-ui/devices/delay/engine";
import {
  type DemoSourceName,
  demoKey,
  demoTempo,
  renderDemoSource,
} from "@/lib/demo-sound";
import { equalizerDevice } from "@/components/audio-ui/devices/equalizer/device";
import { createEqualizerEngine } from "@/components/audio-ui/devices/equalizer/engine";
import { filterDevice } from "@/components/audio-ui/devices/filter/device";
import { createFilterEngine } from "@/components/audio-ui/devices/filter/engine";
import { limiterDevice } from "@/components/audio-ui/devices/limiter/device";
import { createLimiterEngine } from "@/components/audio-ui/devices/limiter/engine";
import { reduxDevice } from "@/components/audio-ui/devices/redux/device";
import { createReduxEngine } from "@/components/audio-ui/devices/redux/engine";
import { reverbDevice } from "@/components/audio-ui/devices/reverb/device";
import { createReverbEngine } from "@/components/audio-ui/devices/reverb/engine";
import { saturatorDevice } from "@/components/audio-ui/devices/saturator/device";
import { createSaturatorEngine } from "@/components/audio-ui/devices/saturator/engine";
import { utilityDevice } from "@/components/audio-ui/devices/utility/device";
import { createUtilityEngine } from "@/components/audio-ui/devices/utility/engine";
import { downloadWavFile } from "@/lib/wav-file";

const MASTER_VOLUME = 0.7;

type AnyState = Record<string, ParameterValue>;

/** What the chain gives a device's panel: the props every panel of the catalog takes. */
interface ChainPanelProps<State> {
  children?: ReactNode;
  className?: string;
  onValueChange: (state: State) => void;
  /** For the panels that show a time in notes: the tempo of the loop. */
  tempo?: Tempo;
  value: State;
}

/** A device the chain can hold: its description, its engine and its panel. */
interface ChainDevice {
  create: (
    ctx: BaseAudioContext,
    state: AnyState,
    context?: DeviceContext
  ) => DemoEngine<AnyState>;
  device: Device<readonly ParameterDescriptor[]>;
  Panel: ComponentType<ChainPanelProps<AnyState>>;
  /** How wide its panel is drawn in the row. */
  width: string;
}

/** Pairs a device with its engine and its panel, checked against each other, for a list that holds them all. */
function chainDevice<const Parameters extends readonly ParameterDescriptor[]>(
  device: Device<Parameters>,
  create: (
    ctx: BaseAudioContext,
    state: DeviceState<Parameters>,
    context?: DeviceContext
  ) => DemoEngine<DeviceState<Parameters>>,
  Panel: ComponentType<ChainPanelProps<DeviceState<Parameters>>>,
  width: string
): ChainDevice {
  return {
    create: create as unknown as ChainDevice["create"],
    device: device as unknown as ChainDevice["device"],
    Panel: Panel as unknown as ChainDevice["Panel"],
    width,
  };
}

/** The devices of the library, by shelf. Any device goes in the same way: its description, its engine and its panel. */
const SHELVES = [
  {
    devices: [
      chainDevice(
        compressorDevice,
        createCompressorEngine,
        CompressorPanel,
        "w-96"
      ),
      chainDevice(limiterDevice, createLimiterEngine, LimiterPanel, "w-80"),
    ],
    label: "Dynamics",
  },
  {
    devices: [
      chainDevice(
        equalizerDevice,
        createEqualizerEngine,
        EqualizerPanel,
        "w-96"
      ),
      chainDevice(filterDevice, createFilterEngine, FilterPanel, "w-80"),
    ],
    label: "Tone",
  },
  {
    devices: [
      chainDevice(
        saturatorDevice,
        createSaturatorEngine,
        SaturatorPanel,
        "w-80"
      ),
      chainDevice(reduxDevice, createReduxEngine, ReduxPanel, "w-80"),
    ],
    label: "Distortion",
  },
  {
    devices: [
      chainDevice(delayDevice, createDelayEngine, DelayPanel, "w-80"),
      chainDevice(reverbDevice, createReverbEngine, ReverbPanel, "w-80"),
      chainDevice(chorusDevice, createChorusEngine, ChorusPanel, "w-80"),
    ],
    label: "Space",
  },
  {
    devices: [
      chainDevice(utilityDevice, createUtilityEngine, UtilityPanel, "w-80"),
    ],
    label: "Utility",
  },
];

const LIBRARY = SHELVES.map((shelf) => ({
  items: shelf.devices.map((entry) => entry.device),
  label: shelf.label,
}));

const DEVICES = new Map(
  SHELVES.flatMap((shelf) => shelf.devices).map((entry) => [
    entry.device.id,
    entry,
  ])
);

/** One device in the chain: its slot's id, the device it is, and its state. */
interface Slot {
  device: string;
  id: string;
  state: AnyState;
}

function slotOf(device: string, id: string): Slot | null {
  const entry = DEVICES.get(device);
  return entry ? { device, id, state: entry.device.defaults() } : null;
}

const START = [equalizerDevice.id, compressorDevice.id, delayDevice.id].flatMap(
  (device, index) => slotOf(device, `start-${index}`) ?? []
);

/** The chain's engines as one: and when those that load a worklet have it, for a render that must not start before. */
type ChainEngine = DemoEngine<Slot[]> & {
  ready: () => Promise<unknown>;
  readonly sidechain: AudioNode;
};

/**
 * The devices of the chain on the Web Audio API, one engine per slot, in
 * series from `input` to `output`, and the key at `sidechain` fed to every
 * engine that takes one. It follows the slots it is given: an engine is made
 * for a new slot, let go with its slot, told a slot's new state, and the
 * engines are wired again when their order changes. It runs on any context,
 * an `OfflineAudioContext` too.
 */
function createChainEngine(
  ctx: BaseAudioContext,
  initial: Slot[],
  context?: DeviceContext
): ChainEngine {
  const input = ctx.createGain();
  const output = ctx.createGain();
  const sidechain = ctx.createGain();
  const running = new Map<
    string,
    { engine: DemoEngine<AnyState>; state: AnyState }
  >();
  let order: string[] = [];
  let tempo = context?.tempo;
  // An engine with a worklet plays its signal through untouched until the worklet has loaded.
  const loading: Promise<unknown>[] = [];

  const wire = () => {
    input.disconnect();
    for (const { engine } of running.values()) {
      engine.output.disconnect();
    }
    let tail: AudioNode = input;
    for (const id of order) {
      const engine = running.get(id)?.engine;
      if (engine) {
        tail.connect(engine.input);
        tail = engine.output;
      }
    }
    tail.connect(output);
  };

  const release = (engine: DemoEngine<AnyState>) => {
    engine.output.disconnect();
    if (engine.sidechain) {
      sidechain.disconnect(engine.sidechain);
    }
    engine.dispose();
  };

  const start = (slot: Slot, next?: DeviceContext) => {
    const create = DEVICES.get(slot.device)?.create;
    if (!create) {
      return;
    }
    const engine = create(ctx, slot.state, next);
    if (engine.sidechain) {
      sidechain.connect(engine.sidechain);
    }
    const { ready } = engine as { ready?: Promise<unknown> };
    if (ready) {
      loading.push(ready);
    }
    running.set(slot.id, { engine, state: slot.state });
  };

  const update = (slots: Slot[], next?: DeviceContext) => {
    const retimed = next?.tempo !== tempo;
    tempo = next?.tempo;
    const ids = new Set(slots.map((slot) => slot.id));
    for (const [id, { engine }] of running) {
      if (!ids.has(id)) {
        release(engine);
        running.delete(id);
      }
    }
    for (const slot of slots) {
      const current = running.get(slot.id);
      if (!current) {
        start(slot, next);
      } else if (current.state !== slot.state || retimed) {
        current.engine.update(slot.state, next);
        current.state = slot.state;
      }
    }
    const nextOrder = slots
      .map((slot) => slot.id)
      .filter((id) => running.has(id));
    if (nextOrder.join() !== order.join()) {
      order = nextOrder;
      wire();
    }
  };

  update(initial, context);
  return {
    dispose: () => {
      input.disconnect();
      for (const { engine } of running.values()) {
        release(engine);
      }
      running.clear();
    },
    input,
    output,
    ready: () => Promise.all(loading),
    sidechain,
    update,
  };
}

/** How many times the loop plays into an exported file. */
const BOUNCE_LOOPS = 2;
/** How long the file runs on after the loop, for echoes and the tail of a reverb to die away. */
const BOUNCE_TAIL_SECONDS = 3;
const BOUNCE_RATE = 48_000;
/** How loud the loop goes into the chain: as loud as it plays. */
const SIGNAL_LEVEL = 0.8;
/** How many times the render stops on its way to say how far it has got. */
const BOUNCE_STEPS = 20;

/**
 * Plays the loop through the chain's devices into a file, faster than real
 * time: the same engines on an `OfflineAudioContext`, once those that load a
 * worklet have it, with the kick at the key, as it plays. `onProgress` hears
 * how far it has got, from 0 to 1.
 */
async function bounce(
  slots: Slot[],
  name: DemoSourceName,
  onProgress: (done: number) => void
): Promise<AudioBuffer> {
  const [loop, key] = await Promise.all([
    renderDemoSource(name, BOUNCE_RATE),
    renderDemoSource(demoKey(name), BOUNCE_RATE),
  ]);
  const playing = loop.duration * BOUNCE_LOOPS;
  const length = playing + BOUNCE_TAIL_SECONDS;
  const ctx = new OfflineAudioContext(
    2,
    Math.ceil(length * BOUNCE_RATE),
    BOUNCE_RATE
  );
  const chain = createChainEngine(ctx, slots, { tempo: demoTempo(name) });
  await chain.ready();
  const level = ctx.createGain();
  level.gain.value = SIGNAL_LEVEL;
  level.connect(chain.input);
  for (const [buffer, into] of [
    [loop, level],
    [key, chain.sidechain],
  ] as const) {
    const source = ctx.createBufferSource();
    source.buffer = buffer;
    source.loop = true;
    source.connect(into);
    source.start(0);
    source.stop(playing);
  }
  chain.output.connect(ctx.destination);
  // A render says nothing while it runs: it stops a few times on the way, to tell how far it is.
  for (let step = 1; step < BOUNCE_STEPS; step += 1) {
    ctx.suspend((length * step) / BOUNCE_STEPS).then(() => {
      onProgress(step / BOUNCE_STEPS);
      return ctx.resume();
    });
  }
  const rendered = await ctx.startRendering();
  chain.dispose();
  return rendered;
}

/** A file name with the time in it, so a second export does not take the first one's place. */
function exportName(date: Date) {
  const pad = (value: number) => String(value).padStart(2, "0");
  const day = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
  const time = `${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
  return `device-chain-${day}-${time}.wav`;
}

interface ChainSlotProps {
  onChange: (id: string, state: AnyState) => void;
  slot: Slot;
  tempo: Tempo;
}

function ChainSlot({ onChange, slot, tempo }: ChainSlotProps) {
  const entry = DEVICES.get(slot.device);
  const change = useCallback(
    (state: AnyState) => onChange(slot.id, state),
    [onChange, slot.id]
  );
  const toggle = useCallback(
    (enabled: boolean) => {
      if (entry) {
        onChange(
          slot.id,
          entry.device.set(slot.state as never, "enabled", enabled) as AnyState
        );
      }
    },
    [entry, onChange, slot.id, slot.state]
  );
  if (!entry) {
    return null;
  }
  const { Panel } = entry;
  return (
    <DeviceChainSlot
      enabled={slot.state.enabled === true}
      onEnabledChange={toggle}
      title={entry.device.name}
      value={slot.id}
    >
      <Panel
        className={entry.width}
        onValueChange={change}
        tempo={tempo}
        value={slot.state}
      >
        <DeviceChainSlotControls />
      </Panel>
    </DeviceChainSlot>
  );
}

/**
 * A track's devices in a row, with the library beside them. Drag a device from
 * the library to where it goes in the chain, or mark one and press Enter; drag
 * a device by its header to move it, or press Left or Right on its grip. Play
 * the loop: it goes through the devices in the order they are drawn, each its
 * own panel over the real engine, and a compressor switched to its sidechain
 * ducks it with the kick. Export WAV renders it through the same engines into
 * a file, faster than real time.
 */
export function BlockDeviceChain() {
  const [slots, setSlots] = useState<Slot[]>(START);
  const [libraryOpen, setLibraryOpen] = useState(true);
  const search = useRef<HTMLInputElement>(null);
  const made = useRef(START.length);
  const master = useMasterBus(MASTER_VOLUME);
  const name = useDemoSource("groove");
  const { tempo } = useDeviceLoop({
    create: createChainEngine,
    destination: master,
    key: true,
    state: slots,
  });

  const add = useCallback((device: string, index?: number) => {
    const slot = slotOf(device, `${device}-${made.current}`);
    if (!slot) {
      return;
    }
    made.current += 1;
    setSlots((current) => {
      const at = index ?? current.length;
      return [...current.slice(0, at), slot, ...current.slice(at)];
    });
  }, []);
  const pick = useCallback((device: string) => add(device), [add]);
  const move = useCallback((id: string, index: number) => {
    setSlots((current) => {
      const moved = current.find((slot) => slot.id === id);
      if (!moved) {
        return current;
      }
      const rest = current.filter((slot) => slot.id !== id);
      return [...rest.slice(0, index), moved, ...rest.slice(index)];
    });
  }, []);
  const remove = useCallback((id: string) => {
    setSlots((current) => current.filter((slot) => slot.id !== id));
  }, []);
  const change = useCallback((id: string, state: AnyState) => {
    setSlots((current) =>
      current.map((slot) => (slot.id === id ? { ...slot, state } : slot))
    );
  }, []);
  const browse = useCallback(() => {
    setLibraryOpen(true);
    requestAnimationFrame(() => search.current?.focus());
  }, []);
  const order = useMemo(() => slots.map((slot) => slot.id), [slots]);
  // The loop through the chain as a file: how far the render has got, and what to announce of it.
  const [progress, setProgress] = useState<number | null>(null);
  const [announced, setAnnounced] = useState("");
  const exportWav = useCallback(() => {
    const filename = exportName(new Date());
    setProgress(0);
    setAnnounced("Exporting the loop through the chain.");
    bounce(slots, name, setProgress)
      .then((rendered) => {
        downloadWavFile(rendered, { filename });
        setAnnounced(`Exported as ${filename}.`);
      })
      .catch(() => setAnnounced("The export did not work."))
      .finally(() => setProgress(null));
  }, [name, slots]);

  return (
    <div className="rounded-xl @container/chain w-full min-w-0 overflow-hidden border bg-card">
      {/* Side by side when there is room; narrower, the library sits over the chain and folds to a bar. */}
      <div className="flex flex-col @2xl/chain:h-96 @2xl/chain:flex-row">
        <DeviceBrowser
          className="@max-2xl/chain:h-60 @max-2xl/chain:w-full @max-2xl/chain:border-e-0 @max-2xl/chain:border-b @max-2xl/chain:data-folded:h-auto @max-2xl/chain:data-folded:flex-row @max-2xl/chain:data-folded:justify-center @max-2xl/chain:data-folded:*:rotate-0 @max-2xl/chain:data-folded:*:[writing-mode:horizontal-tb]"
          onOpenChange={setLibraryOpen}
          onPick={pick}
          open={libraryOpen}
          searchRef={search}
          shelves={LIBRARY}
        />
        <DeviceChain
          aria-label="Devices"
          className="h-96 flex-1 bg-muted/30 @2xl/chain:h-auto"
          onAdd={add}
          onMove={move}
          onRemove={remove}
          order={order}
        >
          {slots.map((slot) => (
            <ChainSlot
              key={slot.id}
              onChange={change}
              slot={slot}
              tempo={tempo}
            />
          ))}
          <DeviceChainAdd onClick={browse} />
        </DeviceChain>
      </div>
      <div className="flex items-center gap-2 border-t px-2 py-1.5 text-muted-foreground text-xs">
        <span className="min-w-0 flex-1 truncate">
          {`The loop through ${slots.length} ${slots.length === 1 ? "device" : "devices"}`}
        </span>
        <span aria-live="polite" className="sr-only">
          {announced}
        </span>
        <Button
          className="tabular-nums"
          disabled={progress !== null}
          onClick={exportWav}
          size="sm"
          variant="outline"
        >
          <IconPlaceholder
            aria-hidden="true"
            hugeicons="Download01Icon"
            lucide="DownloadIcon"
            phosphor="DownloadSimpleIcon"
            remixicon="RiDownloadLine"
            tabler="IconDownload"
          />
          {progress === null
            ? "Export WAV"
            : `Exporting ${Math.round(progress * 100)}%`}
        </Button>
      </div>
    </div>
  );
}
```

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](/docs/devices) 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

**CLI**

```bash
npx shadcn@latest add @audio-ui/device-chain
```

**Manual**

**1. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/device-chain.tsx"
"use client";

import {
  type ComponentProps,
  createContext,
  type DragEvent,
  type KeyboardEvent,
  type PointerEvent,
  use,
  useCallback,
  useMemo,
  useRef,
  useState,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Toggle } from "@/components/ui/toggle";

/** The type a dragged device carries, with its id as the data: what a library sets and the chain takes. */
export const DEVICE_DRAG_TYPE = "application/x-audio-ui-device";

/** Where a press starts a drag of the whole device: its title bar, outside the controls in it. */
const HEADER = '[data-slot="device-header"], [data-slot="device-chain-strip"]';
const CONTROL =
  'a, button, input, select, textarea, [role="slider"], [role="switch"]';

interface DeviceChainContextValue {
  /** The slot being dragged, if one is. */
  dragging: string | null;
  /** The gap a dragged device would land in: before the slot at this index, or the end of the chain. */
  dropIndex: number | null;
  move: ((id: string, index: number, title: string) => void) | null;
  order: readonly string[];
  remove: ((id: string, title: string) => void) | null;
  startDrag: (event: PointerEvent<HTMLElement>, id: string) => void;
}

const DeviceChainContext = createContext<DeviceChainContextValue | null>(null);

function useDeviceChain() {
  const context = use(DeviceChainContext);
  if (!context) {
    throw new Error("DeviceChain parts must be used within <DeviceChain>.");
  }
  return context;
}

interface SlotContextValue {
  folded: boolean;
  index: number;
  title: string;
  toggleFold: () => void;
  value: string;
}

const SlotContext = createContext<SlotContextValue | null>(null);

function useSlot() {
  const context = use(SlotContext);
  if (!context) {
    throw new Error(
      "DeviceChainSlotControls must be used within <DeviceChainSlot>."
    );
  }
  return context;
}

/** How far to scroll so a pointer held near the start or end edge of a view keeps it going; 0 away from the edges. */
function edgeScrollStep(x: number, left: number, right: number): number {
  if (x < left + 48) {
    return -Math.min(24, Math.ceil((left + 48 - x) / 3));
  }
  if (x > right - 48) {
    return Math.min(24, Math.ceil((x - right + 48) / 3));
  }
  return 0;
}

/** The middle of each slot across the view, in the order they are drawn. */
function slotMiddles(root: HTMLElement): number[] {
  return Array.from(
    root.querySelectorAll<HTMLElement>('[data-slot="device-chain-slot"]')
  ).map((slot) => {
    const box = slot.getBoundingClientRect();
    return (box.left + box.right) / 2;
  });
}

/** The gap a pointer at `x` points at: before the first slot whose middle is past it, or the end. */
function gapAt(middles: number[], x: number): number {
  const index = middles.findIndex((middle) => x < middle);
  return index === -1 ? middles.length : index;
}

function takesDevice(event: DragEvent<HTMLElement>) {
  return Array.from(event.dataTransfer.types).includes(DEVICE_DRAG_TYPE);
}

function gripOf(root: HTMLElement | null, id: string | undefined) {
  if (!(root && id)) {
    return null;
  }
  return root.querySelector<HTMLElement>(
    `[data-device-grip="${CSS.escape(id)}"]`
  );
}

interface DeviceChainProps extends ComponentProps<"div"> {
  /** A device was dropped on the chain from a library: its id, and the index it goes in. Leave it out and nothing can be dropped. */
  onAdd?: (device: string, index: number) => void;
  /** A slot was moved to `index`, counted once it has left its place. Leave it out and the slots stay where they are. */
  onMove?: (id: string, index: number) => void;
  /** A slot was removed. Leave it out and no slot can be. */
  onRemove?: (id: string) => void;
  /** The ids of the slots, in the order the sound goes through them. */
  order: readonly string[];
}

/**
 * The devices of a track in a row, in the order the sound goes through them.
 * Controlled: it holds the ids of its slots, and a move, a removal or a device
 * dropped on it goes out through a callback for the host to apply. It makes no
 * sound and knows no engine. Each slot is a `DeviceChainSlot` with the
 * device's panel in it, and `DeviceChainAdd` ends the row.
 */
function DeviceChain({
  children,
  className,
  onAdd,
  onMove,
  onRemove,
  order,
  ...props
}: DeviceChainProps) {
  const root = useRef<HTMLDivElement>(null);
  const [dragging, setDragging] = useState<string | null>(null);
  const [dropIndex, setDropIndex] = useState<number | null>(null);
  const [message, setMessage] = useState("");
  const live = useRef({ onAdd, onMove, onRemove, order });
  live.current = { onAdd, onMove, onRemove, order };

  const move = useCallback((id: string, index: number, title: string) => {
    const { onMove: apply, order: ids } = live.current;
    const from = ids.indexOf(id);
    if (
      !apply ||
      from === -1 ||
      index === from ||
      index < 0 ||
      index >= ids.length
    ) {
      return;
    }
    apply(id, index);
    setMessage(`${title} moved to ${index + 1} of ${ids.length}.`);
    // The slot is drawn again in its new place: its grip keeps the focus the key was pressed on.
    requestAnimationFrame(() => gripOf(root.current, id)?.focus());
  }, []);

  const remove = useCallback((id: string, title: string) => {
    const { onRemove: apply, order: ids } = live.current;
    if (!apply) {
      return;
    }
    const index = ids.indexOf(id);
    const neighbor = ids[index + 1] ?? ids[index - 1];
    apply(id);
    setMessage(`${title} removed.`);
    // The focus goes to the slot that takes its place, or to the end of the chain.
    requestAnimationFrame(() =>
      (
        gripOf(root.current, neighbor) ??
        root.current?.querySelector<HTMLElement>(
          '[data-slot="device-chain-add"]'
        )
      )?.focus()
    );
  }, []);

  const startDrag = useCallback(
    (event: PointerEvent<HTMLElement>, id: string) => {
      const view = root.current;
      const slot = event.currentTarget.closest<HTMLElement>(
        '[data-slot="device-chain-slot"]'
      );
      const from = live.current.order.indexOf(id);
      if (
        event.button !== 0 ||
        !(view && slot && live.current.onMove) ||
        from === -1
      ) {
        return;
      }
      event.preventDefault();
      const middles = slotMiddles(view);
      const startX = event.clientX;
      const startScroll = view.scrollLeft;
      let lastX = startX;
      let gap = from;
      let frame = 0;

      // Once a frame while the slot is dragged, whether or not the pointer moved: near an edge of the row, it
      // scrolls; the slot follows the pointer, and the slots are where they were at the press, moved by the scroll.
      const tick = () => {
        const box = view.getBoundingClientRect();
        view.scrollLeft += edgeScrollStep(lastX, box.left, box.right);
        const shift = view.scrollLeft - startScroll;
        slot.style.transform = `translateX(${lastX - startX + shift}px)`;
        const next = gapAt(
          middles.map((middle) => middle - shift),
          lastX
        );
        if (next !== gap) {
          gap = next;
          // Its own place and the one after it leave the chain as it is: no line.
          setDropIndex(next === from || next === from + 1 ? null : next);
        }
        frame = requestAnimationFrame(tick);
      };
      const follow = (moveEvent: globalThis.PointerEvent) => {
        lastX = moveEvent.clientX;
      };
      const finish = (commit: boolean) => {
        window.removeEventListener("pointermove", follow);
        window.removeEventListener("pointerup", release);
        window.removeEventListener("pointercancel", cancel);
        cancelAnimationFrame(frame);
        slot.style.transform = "";
        // The gap the slot lands in; one less once the slot has left a place before it.
        const landed = gap > from ? gap - 1 : gap;
        if (commit && landed !== from) {
          live.current.onMove?.(id, landed);
        }
        setDragging(null);
        setDropIndex(null);
      };
      const release = () => finish(true);
      const cancel = () => finish(false);

      setDragging(id);
      window.addEventListener("pointermove", follow);
      window.addEventListener("pointerup", release);
      window.addEventListener("pointercancel", cancel);
      frame = requestAnimationFrame(tick);
    },
    []
  );

  const handleDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
    if (!(live.current.onAdd && takesDevice(event))) {
      return;
    }
    event.preventDefault();
    event.dataTransfer.dropEffect = "copy";
    setDropIndex(gapAt(slotMiddles(event.currentTarget), event.clientX));
  }, []);

  const handleDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
      setDropIndex(null);
    }
  }, []);

  const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
    const add = live.current.onAdd;
    if (!(add && takesDevice(event))) {
      return;
    }
    event.preventDefault();
    setDropIndex(null);
    const device = event.dataTransfer.getData(DEVICE_DRAG_TYPE);
    if (device) {
      add(device, gapAt(slotMiddles(event.currentTarget), event.clientX));
    }
  }, []);

  const context = useMemo<DeviceChainContextValue>(
    () => ({
      dragging,
      dropIndex,
      move: onMove ? move : null,
      order,
      remove: onRemove ? remove : null,
      startDrag,
    }),
    [dragging, dropIndex, move, onMove, onRemove, order, remove, startDrag]
  );

  return (
    <DeviceChainContext value={context}>
      <div
        className={cn(
          "flex min-h-0 min-w-0 items-stretch gap-2 overflow-x-auto overscroll-x-contain p-2",
          className
        )}
        data-slot="device-chain"
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        ref={root}
        role="group"
        {...props}
      >
        {children}
        <span aria-live="polite" className="sr-only">
          {message}
        </span>
      </div>
    </DeviceChainContext>
  );
}

/** The grip of a slot: drag it to move the device, press Left or Right to move it a place, Delete to remove it. */
function SlotGrip() {
  const { dragging, move, remove, startDrag } = useDeviceChain();
  const { index, title, value } = useSlot();
  const handleKey = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>) => {
      const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
      if (step) {
        event.preventDefault();
        move?.(value, index + step, title);
      } else if (
        (event.key === "Delete" || event.key === "Backspace") &&
        remove
      ) {
        event.preventDefault();
        remove(value, title);
      }
    },
    [index, move, remove, title, value]
  );
  const handlePointerDown = useCallback(
    (event: PointerEvent<HTMLButtonElement>) => startDrag(event, value),
    [startDrag, value]
  );
  if (!move) {
    return null;
  }
  return (
    <button
      aria-keyshortcuts={
        remove ? "ArrowLeft ArrowRight Delete" : "ArrowLeft ArrowRight"
      }
      aria-label={`Move ${title}`}
      className={cn(
        "flex size-6 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing",
        dragging === value && "cursor-grabbing bg-muted text-foreground"
      )}
      data-device-grip={value}
      onKeyDown={handleKey}
      onPointerDown={handlePointerDown}
      type="button"
    >
      <svg
        aria-hidden="true"
        className="size-3.5"
        fill="currentColor"
        focusable="false"
        viewBox="0 0 16 16"
      >
        <circle cx="5.5" cy="3.5" r="1.25" />
        <circle cx="10.5" cy="3.5" r="1.25" />
        <circle cx="5.5" cy="8" r="1.25" />
        <circle cx="10.5" cy="8" r="1.25" />
        <circle cx="5.5" cy="12.5" r="1.25" />
        <circle cx="10.5" cy="12.5" r="1.25" />
      </svg>
    </button>
  );
}

function SlotRemove() {
  const { remove } = useDeviceChain();
  const { title, value } = useSlot();
  const removeIt = useCallback(
    () => remove?.(value, title),
    [remove, title, value]
  );
  if (!remove) {
    return null;
  }
  return (
    <Button
      aria-label={`Remove ${title}`}
      className="text-muted-foreground hover:text-destructive"
      onClick={removeIt}
      size="icon-xs"
      variant="ghost"
    >
      <IconPlaceholder
        aria-hidden="true"
        hugeicons="Cancel01Icon"
        lucide="XIcon"
        phosphor="XIcon"
        remixicon="RiCloseLine"
        tabler="IconX"
      />
    </Button>
  );
}

function SlotFold() {
  const { folded, title, toggleFold } = useSlot();
  return (
    <Button
      aria-expanded={!folded}
      aria-label={`${folded ? "Unfold" : "Fold"} ${title}`}
      data-slot="device-chain-slot-fold"
      onClick={toggleFold}
      size="icon-xs"
      variant="ghost"
    >
      <IconPlaceholder
        aria-hidden="true"
        className={cn("transition-transform", folded && "rotate-180")}
        hugeicons="ArrowLeft01Icon"
        lucide="ChevronLeftIcon"
        phosphor="CaretLeftIcon"
        remixicon="RiArrowLeftSLine"
        tabler="IconChevronLeft"
      />
    </Button>
  );
}

/**
 * The slot's grip, a button that removes the device and one that folds it,
 * for the end of the device's own header: the `children` of a panel go there.
 */
function DeviceChainSlotControls({
  className,
  ...props
}: ComponentProps<"div">) {
  return (
    <div
      className={cn("flex items-center", className)}
      data-slot="device-chain-slot-controls"
      {...props}
    >
      <SlotGrip />
      <SlotRemove />
      <SlotFold />
    </div>
  );
}

/** The commands of a slot on a right-click, beside the keys that do them on its grip. */
function SlotMenu() {
  const { move, order, remove } = useDeviceChain();
  const { index, title, value } = useSlot();
  const moveBack = useCallback(
    () => move?.(value, index - 1, title),
    [index, move, title, value]
  );
  const moveOn = useCallback(
    () => move?.(value, index + 1, title),
    [index, move, title, value]
  );
  const removeIt = useCallback(
    () => remove?.(value, title),
    [remove, title, value]
  );
  return (
    <ContextMenuContent className="w-auto min-w-40">
      {move ? (
        <>
          <ContextMenuItem disabled={index <= 0} onClick={moveBack}>
            Move left
            <ContextMenuShortcut>←</ContextMenuShortcut>
          </ContextMenuItem>
          <ContextMenuItem
            disabled={index === order.length - 1}
            onClick={moveOn}
          >
            Move right
            <ContextMenuShortcut>→</ContextMenuShortcut>
          </ContextMenuItem>
        </>
      ) : null}
      {move && remove ? <ContextMenuSeparator /> : null}
      {remove ? (
        <ContextMenuItem onClick={removeIt} variant="destructive">
          Remove
          <ContextMenuShortcut>Del</ContextMenuShortcut>
        </ContextMenuItem>
      ) : null}
    </ContextMenuContent>
  );
}

interface DeviceChainSlotProps extends Omit<ComponentProps<"div">, "title"> {
  /** Whether the device is on, for the square of the folded strip. Leave it out for a device that cannot be switched off. */
  enabled?: boolean;
  /** The square of the folded strip was pressed. */
  onEnabledChange?: (enabled: boolean) => void;
  /** The device's name, down the folded strip and in what the controls are called. */
  title: string;
  /** The slot's id, as it is in the chain's `order`. */
  value: string;
}

/**
 * One device of the chain, drawn by its own panel: put `DeviceChainSlotControls`
 * at the end of the panel's header. Drag the device by its header or by the
 * grip, or press Left or Right on the grip; Delete removes it, and a
 * right-click gives the same commands. Folded, the slot is a narrow strip with
 * the device's name down it, its on and off square, and the same controls.
 */
function DeviceChainSlot({
  children,
  className,
  enabled,
  onEnabledChange,
  title,
  value,
  ...props
}: DeviceChainSlotProps) {
  const { dragging, dropIndex, move, order, remove, startDrag } =
    useDeviceChain();
  const [folded, setFolded] = useState(false);
  const root = useRef<HTMLDivElement>(null);
  const index = order.indexOf(value);

  const toggleFold = useCallback(() => {
    setFolded((current) => !current);
    // The button that was pressed is gone with the face it was on: the focus goes to the one that takes its place.
    requestAnimationFrame(() =>
      root.current
        ?.querySelector<HTMLElement>('[data-slot="device-chain-slot-fold"]')
        ?.focus()
    );
  }, []);
  const handlePointerDown = useCallback(
    (event: PointerEvent<HTMLDivElement>) => {
      const target = event.target as Element;
      if (target.closest(HEADER) && !target.closest(CONTROL)) {
        startDrag(event, value);
      }
    },
    [startDrag, value]
  );
  const slot = useMemo<SlotContextValue>(
    () => ({ folded, index, title, toggleFold, value }),
    [folded, index, title, toggleFold, value]
  );

  const content = folded ? (
    <div
      className="rounded-xl [--device-radius:calc(var(--radius)+4px)] flex h-full w-8 flex-col items-center gap-1 border bg-card p-1 [&_button]:rounded-[max(0px,calc(var(--device-radius,0.5rem)-0.375rem))]"
      data-slot="device-chain-strip"
    >
      <SlotGrip />
      {enabled === undefined ? null : (
        <span className="flex size-6 shrink-0 items-center justify-center">
          <Toggle
            aria-label={`${title} on`}
            className="size-4 min-h-0 min-w-0 rounded-[max(0px,calc(var(--device-radius,0.5rem)-0.5625rem))] p-0 aria-pressed:border-primary aria-pressed:bg-primary"
            onPressedChange={onEnabledChange}
            pressed={enabled}
            size="sm"
            variant="outline"
          />
        </span>
      )}
      <SlotFold />
      <span
        className="min-h-0 flex-1 rotate-180 truncate py-1 font-medium text-xs [writing-mode:vertical-rl]"
        title={title}
      >
        {title}
      </span>
      <SlotRemove />
    </div>
  ) : (
    children
  );

  const rootProps = {
    "aria-label": title,
    className: cn(
      "relative flex shrink-0 flex-col",
      // The line a dragged device would land on, in the middle of the gap before this slot.
      "before:absolute before:inset-y-0 before:inset-s-[-5px] before:hidden before:w-0.5 before:rounded-full before:bg-primary data-drop:before:block",
      "data-dragging:z-10 data-dragging:shadow-lg",
      className
    ),
    "data-dragging": dragging === value ? "" : undefined,
    "data-drop": dropIndex === index ? "" : undefined,
    "data-folded": folded ? "" : undefined,
    "data-slot": "device-chain-slot",
    onPointerDown: move ? handlePointerDown : undefined,
    ref: root,
    role: "group",
    ...props,
  };

  return (
    <SlotContext value={slot}>
      {move || remove ? (
        <ContextMenu>
          <ContextMenuTrigger render={<div {...rootProps} />}>
            {content}
          </ContextMenuTrigger>
          <SlotMenu />
        </ContextMenu>
      ) : (
        <div {...rootProps}>{content}</div>
      )}
    </SlotContext>
  );
}

/**
 * The end of the chain: where a device dragged from a library is dropped, and
 * a button that opens the library, such as `DeviceBrowser`, through `onClick`.
 * Its children replace the words.
 */
function DeviceChainAdd({
  children,
  className,
  ...props
}: ComponentProps<"button">) {
  const { dropIndex, order } = useDeviceChain();
  return (
    <button
      className={cn(
        "rounded-xl [--device-radius:calc(var(--radius)+4px)] flex w-36 shrink-0 flex-col items-center justify-center gap-1 border border-dashed px-3 text-center text-muted-foreground text-xs transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-over:border-primary data-over:bg-primary/10",
        className
      )}
      data-over={dropIndex === order.length ? "" : undefined}
      data-slot="device-chain-add"
      type="button"
      {...props}
    >
      {children ?? (
        <>
          <span className="font-medium">Add a device</span>
          <span>Drop one here, or open the library</span>
        </>
      )}
    </button>
  );
}

export {
  DeviceChain,
  DeviceChainAdd,
  DeviceChainSlot,
  DeviceChainSlotControls,
};
```

**2. Update the import paths to match your project setup.**

**Agent**

Give your agent the skill, and it picks the component, installs it and wires it to your audio:

```bash
npx skills add ouestlabs/audio-ui
```

Then ask it:

```text
Add the device chain from audio/ui to my project and wire it to my audio code.
```

To let the agent read the docs live, connect the [MCP server](/docs/mcp). More in [Agent Skill](/docs/agents).

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

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

```tsx
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](/docs/devices#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:

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

```tsx
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:

```tsx
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:

```ts
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**

| Gesture | Effect |
| --- | --- |
| Drag a device by its header or its grip | Moves it. A line shows where it lands, and the row scrolls near its edges. |
| `ArrowLeft` / `ArrowRight` on the grip | Move the device a place. |
| `Delete` / `Backspace` on the grip | Remove the device. |
| Right-click a device | Move it left or right, or remove it. |
| Fold button | Folds the device to a strip with its name down it; the strip unfolds it. |
| Drop a device from the library | Adds it where it is dropped: between two devices, or at the end. |

**Library**

| Gesture | Effect |
| --- | --- |
| Type in the search | Finds devices by name first, then by what they do, across every shelf. |
| `Enter` in the search | Adds the first device found. |
| `ArrowDown` in the search | Goes into the list. |
| `ArrowUp` / `ArrowDown`, `Home` / `End` in the list | Mark a device. |
| `Enter` / `Space` in the list, double-click, the Add button | Add the marked device. |
| `Escape` in the search | Clears it. |

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `order` | `readonly string[]` |  | The ids of the slots, in the order the sound goes through them. |
| `onMove` | `(id: string, index: number) => void` |  | A slot was moved to `index`, counted once it has left its place. Leave it out and the slots stay where they are. |
| `onRemove` | `(id: string) => void` |  | A slot was removed. Leave it out and no slot can be. |
| `onAdd` | `(device: string, index: number) => void` |  | A device was dropped on the chain from a library: its id, and the index it goes in. Leave it out and nothing can be dropped. |

### DeviceChainSlot

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` |  | The slot's id, as it is in the chain's `order`. |
| `title` | `string` |  | The device's name, down the folded strip and in what the controls are called. |
| `enabled` | `boolean` |  | Whether the device is on, for the square of the folded strip. Leave it out for a device that cannot be switched off. |
| `onEnabledChange` | `(enabled: boolean) => void` |  | The square of the folded strip was pressed. |

### Parts

**Components**

| Name | Description |
| --- | --- |
| `DeviceChainSlotControls` | The slot's grip, a button that removes the device and one that folds it, for the end of the device's own header. |
| `DeviceChainAdd` | The end of the chain: where a device dragged from a library is dropped, and a button that opens the library through `onClick`. Its children replace the words. |

### DeviceBrowser

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `shelves` | `{ label: string; items: { id, name, description? }[] }[]` |  | The devices, by shelf, in the order they are shown. |
| `onPick` | `(id: string) => void` |  | A device was picked: Enter, a double-click or the Add button. |
| `open` | `boolean` | `true` | Whether the library is shown, or folded to a strip that opens it. |
| `onOpenChange` | `(open: boolean) => void` |  | Called to open or fold the library. Leave it out and it stays open, with no button to fold it. |
| `searchRef` | `Ref<HTMLInputElement>` |  | The search field, to focus it from elsewhere. |
