# Device browser

> A library of devices by shelf, with a search: pick one with Enter, a double-click or the Add button, or drag it onto a device chain. It keeps what is searched and marked, and nothing else.

```tsx
"use client";

import { type DragEvent, useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import { DeviceBrowser } from "@/components/audio-ui/device-browser";
import { DEVICE_DRAG_TYPE } from "@/components/audio-ui/device-chain";
import { DEVICE_SHELVES } from "@/lib/device-library";

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

const NAMES = new Map(
  SHELVES.flatMap((shelf) => shelf.items).map((device) => [
    device.id,
    device.name,
  ])
);

/** What a pick or a drop handed back: the device's id, and how it came. */
interface Added {
  how: "dropped" | "picked";
  id: string;
  key: number;
}

/**
 * A library of devices by shelf, with a search. A device is picked with Enter,
 * a double-click or the Add button, or dragged: the library keeps nothing but
 * what is searched and marked, and hands the id back. Here the ids land in a
 * list; in a workstation they go onto a track's chain.
 */
export function BlockDeviceBrowser() {
  const [added, setAdded] = useState<Added[]>([]);
  const [over, setOver] = useState(false);
  const add = useCallback((id: string, how: Added["how"]) => {
    setAdded((current) => [
      { how, id, key: (current[0]?.key ?? 0) + 1 },
      ...current.slice(0, 7),
    ]);
  }, []);
  const pick = useCallback((id: string) => add(id, "picked"), [add]);
  const dragOver = useCallback((event: DragEvent<HTMLElement>) => {
    if (Array.from(event.dataTransfer.types).includes(DEVICE_DRAG_TYPE)) {
      event.preventDefault();
      event.dataTransfer.dropEffect = "copy";
      setOver(true);
    }
  }, []);
  const leave = useCallback(() => setOver(false), []);
  const drop = useCallback(
    (event: DragEvent<HTMLElement>) => {
      const id = event.dataTransfer.getData(DEVICE_DRAG_TYPE);
      setOver(false);
      if (id) {
        event.preventDefault();
        add(id, "dropped");
      }
    },
    [add]
  );

  return (
    <div className="rounded-xl @container/browser flex w-full min-w-0 flex-col overflow-hidden border bg-card @2xl/browser:h-96 @2xl/browser:flex-row">
      <DeviceBrowser
        className="h-80 w-full border-e-0 border-b @2xl/browser:h-auto @2xl/browser:w-auto @2xl/browser:border-e @2xl/browser:border-b-0"
        onPick={pick}
        shelves={SHELVES}
      />
      <section
        aria-label="Devices added"
        className={cn(
          "flex min-h-40 min-w-0 flex-1 flex-col gap-2 bg-muted/30 p-3 text-sm transition-colors",
          over && "bg-primary/10 ring-2 ring-primary ring-inset"
        )}
        onDragLeave={leave}
        onDragOver={dragOver}
        onDrop={drop}
      >
        <p className="text-muted-foreground text-xs">
          What the library hands back. Pick a device, or drag one here.
        </p>
        <ol className="flex flex-col gap-1">
          {added.map(({ how, id, key }) => (
            <li
              className="flex items-baseline gap-2 border-b py-1 last:border-b-0"
              key={key}
            >
              <span className="min-w-0 flex-1 truncate">
                {NAMES.get(id) ?? id}
              </span>
              <span className="text-muted-foreground text-xs">{how}</span>
            </li>
          ))}
        </ol>
      </section>
    </div>
  );
}
```

A library of devices, with the shelves down the side, the devices of the open shelf beside them and a search over every shelf. It adds nothing itself: a device picked, or dragged out, comes back as an id, and what to do with it is yours. In a workstation the id goes onto a track's [device chain](/docs/components/base/device-chain).

## Installation

**CLI**

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

**Manual**

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

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

import {
  type ChangeEvent,
  type ComponentProps,
  type DragEvent,
  type KeyboardEvent,
  type Ref,
  useCallback,
  useId,
  useState,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { DEVICE_DRAG_TYPE } from "@/components/audio-ui/device-chain";

/** A device as the library lists it: a device definition is one. */
export interface DeviceBrowserItem {
  description?: string;
  id: string;
  name: string;
}

/** A shelf of the library: devices that do the same kind of job. */
export interface DeviceBrowserShelf {
  items: readonly DeviceBrowserItem[];
  label: string;
}

/** How well a device answers a search: its name first, then what it does; -1 when it does not. */
function rank(item: DeviceBrowserItem, query: string) {
  const name = item.name.toLowerCase();
  if (name.startsWith(query)) {
    return 0;
  }
  if (name.includes(query)) {
    return 1;
  }
  return item.description?.toLowerCase().includes(query) ? 2 : -1;
}

interface DeviceBrowserProps
  extends Omit<ComponentProps<"section">, "onChange"> {
  /** Called to open or fold the library. Leave it out and it stays open, with no button to fold it. */
  onOpenChange?: (open: boolean) => void;
  /** A device was picked: its id. Enter, a double-click or the Add button. */
  onPick: (id: string) => void;
  /** Another shelf was opened. */
  onShelfChange?: (shelf: number) => void;
  /** Whether the library is shown, or folded to a strip that opens it. @default true */
  open?: boolean;
  /** The search field, to focus it from elsewhere: the end of a chain that opens the library. */
  searchRef?: Ref<HTMLInputElement>;
  /** The index of the shelf shown, controlled: to keep it past a reload, say. */
  shelf?: number;
  /** The devices, by shelf, in the order they are shown. */
  shelves: readonly DeviceBrowserShelf[];
}

/**
 * A library of devices: the shelves down the side, the devices of the open
 * shelf beside them, and a search over every shelf. Enter in the search adds
 * the first device found; Down goes into the list, where Up and Down mark a
 * device and Enter adds it, as do a double-click and the Add button. Drag a
 * device to drop it where it goes on a `DeviceChain`. It keeps what is
 * searched and marked, and nothing else: what a pick does is the host's.
 */
function DeviceBrowser({
  className,
  onOpenChange,
  onPick,
  onShelfChange,
  open = true,
  searchRef,
  shelf: shelfProp,
  shelves,
  ...props
}: DeviceBrowserProps) {
  const [ownShelf, setOwnShelf] = useState(0);
  const shelf = shelfProp ?? ownShelf;
  const [query, setQuery] = useState("");
  const [cursor, setCursor] = useState(0);
  const listId = useId();

  const searching = query.trim() !== "";
  const listed = searching
    ? shelves
        .flatMap((entry) => entry.items)
        .map((candidate) => ({
          candidate,
          score: rank(candidate, query.trim().toLowerCase()),
        }))
        .filter(({ score }) => score !== -1)
        .sort((a, b) => a.score - b.score)
        .map(({ candidate }) => candidate)
    : (shelves[shelf]?.items ?? []);
  const marked = Math.min(cursor, listed.length - 1);
  const item = listed[marked];

  /** Marks the device at `index` and moves the focus to it. */
  const markAt = useCallback(
    (index: number) => {
      setCursor(index);
      document.getElementById(`${listId}-${index}`)?.focus();
    },
    [listId]
  );
  const pick = useCallback(() => {
    if (item) {
      onPick(item.id);
    }
  }, [item, onPick]);
  const search = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setQuery(event.target.value);
    setCursor(0);
  }, []);
  const clear = useCallback(() => {
    setQuery("");
    setCursor(0);
  }, []);
  const handleSearchKey = useCallback(
    (event: KeyboardEvent<HTMLInputElement>) => {
      if (event.key === "ArrowDown" && item) {
        event.preventDefault();
        markAt(marked);
      } else if (event.key === "Enter" && item) {
        event.preventDefault();
        onPick(item.id);
      } else if (event.key === "Escape" && query) {
        event.preventDefault();
        clear();
      }
    },
    [clear, item, markAt, marked, onPick, query]
  );
  const handleOptionKey = useCallback(
    (event: KeyboardEvent<HTMLElement>) => {
      const index = Number(event.currentTarget.dataset.index);
      const last = listed.length - 1;
      const to = {
        ArrowDown: Math.min(last, index + 1),
        ArrowUp: Math.max(0, index - 1),
        End: last,
        Home: 0,
      }[event.key];
      if (to !== undefined) {
        event.preventDefault();
        markAt(to);
      } else if (event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        onPick(listed[index].id);
      }
    },
    [listed, markAt, onPick]
  );
  const openShelf = useCallback(
    (event: { currentTarget: HTMLElement }) => {
      const next = Number(event.currentTarget.dataset.shelf);
      setOwnShelf(next);
      onShelfChange?.(next);
      setQuery("");
      setCursor(0);
    },
    [onShelfChange]
  );
  const mark = useCallback((event: { currentTarget: HTMLElement }) => {
    setCursor(Number(event.currentTarget.dataset.index));
  }, []);
  const pickThis = useCallback(
    (event: { currentTarget: HTMLElement }) => {
      const id = event.currentTarget.dataset.device;
      if (id) {
        onPick(id);
      }
    },
    [onPick]
  );
  const carry = useCallback((event: DragEvent<HTMLElement>) => {
    event.dataTransfer.setData(
      DEVICE_DRAG_TYPE,
      event.currentTarget.dataset.device ?? ""
    );
    event.dataTransfer.effectAllowed = "copy";
  }, []);
  const fold = useCallback(() => onOpenChange?.(false), [onOpenChange]);
  const unfold = useCallback(() => onOpenChange?.(true), [onOpenChange]);

  if (!open) {
    return (
      <button
        aria-expanded={false}
        aria-label="Open the library"
        className={cn(
          "flex w-8 shrink-0 flex-col items-center gap-2 border-e bg-muted/40 py-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
          className
        )}
        data-folded=""
        data-slot="device-browser"
        onClick={unfold}
        type="button"
      >
        <IconPlaceholder
          aria-hidden="true"
          hugeicons="ArrowRight01Icon"
          lucide="ChevronRightIcon"
          phosphor="CaretRightIcon"
          remixicon="RiArrowRightSLine"
          tabler="IconChevronRight"
        />
        <span className="rotate-180 font-medium text-xs [writing-mode:vertical-rl]">
          Library
        </span>
      </button>
    );
  }

  return (
    <section
      aria-label="Library"
      className={cn(
        "flex min-h-0 w-72 shrink-0 flex-col border-e bg-card",
        className
      )}
      data-slot="device-browser"
      {...props}
    >
      {/* A press anywhere on this bar folds the library: its button reaches across it. */}
      <div className="relative flex shrink-0 items-center gap-1 border-b bg-muted/40 p-1 ps-2.5">
        <span className="pointer-events-none relative flex-1 font-medium text-xs">
          Library
        </span>
        {onOpenChange ? (
          <button
            aria-expanded
            aria-label="Fold the library"
            className="flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none after:absolute after:inset-0 hover:text-foreground hover:after:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-3.5"
            onClick={fold}
            type="button"
          >
            <IconPlaceholder
              aria-hidden="true"
              hugeicons="ArrowLeft01Icon"
              lucide="ChevronLeftIcon"
              phosphor="CaretLeftIcon"
              remixicon="RiArrowLeftSLine"
              tabler="IconChevronLeft"
            />
          </button>
        ) : null}
      </div>
      <div className="shrink-0 border-b p-1.5">
        <InputGroup className="h-7">
          <InputGroupAddon>
            <IconPlaceholder
              aria-hidden="true"
              hugeicons="Search01Icon"
              lucide="SearchIcon"
              phosphor="MagnifyingGlassIcon"
              remixicon="RiSearchLine"
              tabler="IconSearch"
            />
          </InputGroupAddon>
          <InputGroupInput
            aria-controls={listId}
            aria-label="Search the devices"
            onChange={search}
            onKeyDown={handleSearchKey}
            placeholder="Search"
            ref={searchRef}
            type="search"
            value={query}
          />
        </InputGroup>
      </div>
      <div className="flex min-h-0 flex-1">
        <div
          aria-label="Shelves"
          className="flex w-28 shrink-0 flex-col gap-px overflow-y-auto border-e bg-muted/30 py-1"
          role="group"
        >
          {shelves.map((entry, index) => (
            <button
              aria-pressed={!searching && index === shelf}
              className="border-transparent border-s-2 px-2 py-1 text-start text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset aria-pressed:border-primary aria-pressed:bg-primary/10 aria-pressed:text-foreground"
              data-shelf={index}
              key={entry.label}
              onClick={openShelf}
              type="button"
            >
              {entry.label}
            </button>
          ))}
        </div>
        <div className="flex min-w-0 flex-1 flex-col">
          {searching && listed.length === 0 ? (
            <div className="flex flex-col items-start gap-1 p-2 text-muted-foreground text-xs">
              <span>No device matches "{query.trim()}".</span>
              <Button onClick={clear} size="xs" variant="link">
                Clear the search
              </Button>
            </div>
          ) : null}
          <div
            aria-label={searching ? "Devices found" : shelves[shelf]?.label}
            className="min-h-0 flex-1 overflow-y-auto py-0.5"
            id={listId}
            role="listbox"
          >
            {listed.map((entry, index) => (
              <div
                aria-selected={index === marked}
                className="flex cursor-default flex-col gap-0.5 px-2 py-1.5 outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset aria-selected:bg-primary/10"
                data-device={entry.id}
                data-index={index}
                draggable
                id={`${listId}-${index}`}
                key={entry.id}
                onClick={mark}
                onDoubleClick={pickThis}
                onDragStart={carry}
                onKeyDown={handleOptionKey}
                role="option"
                tabIndex={index === marked ? 0 : -1}
              >
                <span className="text-xs">{entry.name}</span>
                {entry.description ? (
                  <span className="line-clamp-2 text-[11px] text-muted-foreground leading-snug">
                    {entry.description}
                  </span>
                ) : null}
              </div>
            ))}
          </div>
          <div className="shrink-0 border-t p-1.5">
            <Button
              className="w-full"
              disabled={!item}
              onClick={pick}
              size="sm"
              variant="outline"
            >
              {item ? `Add ${item.name}` : "Add"}
            </Button>
          </div>
        </div>
      </div>
    </section>
  );
}

export { DeviceBrowser };
```

**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 browser 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 block above is `@audio-ui/block-device-browser`. It installs the [device chain](/docs/components/base/device-chain) with it, for the drag type the two share.

## Anatomy

```tsx
<DeviceBrowser />
```

## Usage

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

## Interaction

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

The library's list is a list box, and its shelves are toggle buttons.

## API reference

### 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. |
| `shelf` | `number` |  | The index of the shelf shown, controlled: to keep it past a reload, say. |
| `onShelfChange` | `(shelf: number) => void` |  | Another shelf was opened. |
| `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. |
