# Block drum rack

> Drum rack: sixteen pads, each its own sound, shaped with tune, decay and level knobs

A shadcn/ui block from the [@audio-ui](https://www.audio-ui.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/audio-ui/block-drum-rack).

- Page, with a live preview: https://shadcn.exchange/r/audio-ui/block-drum-rack
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/audio-ui/block-drum-rack-light-44e74beb11b2af4d.png
- Registry item: https://www.audio-ui.dev/r/new-york-v4/block-drum-rack.json
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/audio-ui/block-drum-rack.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@audio-ui/pad`, `@audio-ui/knob`, `@audio-ui/use-master-bus`, `@audio-ui/web-audio`, `@audio-ui/drum-synthesis`

## Files

### `block-drum-rack.tsx`

```tsx
"use client";

import type { Pad as PadPrimitive } from "@audio-ui/react";
import { useCallback, useRef, useState } from "react";
import { Knob } from "@/components/audio-ui/knob";
import { Pad, PadItem } from "@/components/audio-ui/pad";
import { useMasterBus } from "@/hooks/use-master-bus";
import {
  type DrumOptions,
  triggerHihat,
  triggerKick,
  triggerSnare,
} from "@/lib/drum-synthesis";
import { webAudio } from "@/lib/web-audio";

const VOLUME = 0.7;
/** How fast a choked sound fades out, as a time constant in seconds. */
const CHOKE_FADE = 0.01;

const VOICES = { hat: triggerHihat, kick: triggerKick, snare: triggerSnare };

interface Sound {
  chokeGroup?: string;
  /** Seconds. */
  decay: number;
  id: string;
  label: string;
  /** Gain, from 0 to 1. */
  level: number;
  /** Semitones from the voice's own pitch. */
  tune: number;
  voice: keyof typeof VOICES;
}

/** Sixteen sounds from three voices, four to a row. The hats share a choke group. */
const KIT: Sound[] = [
  { decay: 0.2, id: "kick", label: "Kick", level: 0.9, tune: 0, voice: "kick" },
  { decay: 0.45, id: "sub", label: "Sub", level: 0.8, tune: -5, voice: "kick" },
  {
    decay: 0.15,
    id: "snare",
    label: "Snare",
    level: 0.8,
    tune: 0,
    voice: "snare",
  },
  { decay: 0.04, id: "rim", label: "Rim", level: 0.7, tune: 7, voice: "snare" },
  {
    decay: 0.3,
    id: "low-tom",
    label: "Low tom",
    level: 0.8,
    tune: 7,
    voice: "kick",
  },
  {
    decay: 0.25,
    id: "mid-tom",
    label: "Mid tom",
    level: 0.8,
    tune: 12,
    voice: "kick",
  },
  {
    decay: 0.22,
    id: "clap",
    label: "Clap",
    level: 0.8,
    tune: -3,
    voice: "snare",
  },
  {
    decay: 0.06,
    id: "snap",
    label: "Snap",
    level: 0.7,
    tune: 12,
    voice: "snare",
  },
  {
    chokeGroup: "hat",
    decay: 0.05,
    id: "closed-hat",
    label: "Closed hat",
    level: 0.7,
    tune: 0,
    voice: "hat",
  },
  {
    chokeGroup: "hat",
    decay: 0.03,
    id: "pedal-hat",
    label: "Pedal hat",
    level: 0.6,
    tune: -3,
    voice: "hat",
  },
  {
    chokeGroup: "hat",
    decay: 0.6,
    id: "open-hat",
    label: "Open hat",
    level: 0.6,
    tune: 0,
    voice: "hat",
  },
  {
    decay: 0.09,
    id: "shaker",
    label: "Shaker",
    level: 0.5,
    tune: 5,
    voice: "hat",
  },
  {
    decay: 0.2,
    id: "high-tom",
    label: "High tom",
    level: 0.8,
    tune: 17,
    voice: "kick",
  },
  {
    decay: 0.05,
    id: "perc",
    label: "Perc",
    level: 0.7,
    tune: 24,
    voice: "kick",
  },
  { decay: 1.2, id: "ride", label: "Ride", level: 0.4, tune: -7, voice: "hat" },
  {
    decay: 1.8,
    id: "crash",
    label: "Crash",
    level: 0.45,
    tune: -12,
    voice: "hat",
  },
];

const formatTune = (tune: number) => `${tune > 0 ? "+" : ""}${tune} st`;
const formatDecay = (seconds: number) =>
  seconds < 1 ? `${Math.round(seconds * 1000)} ms` : `${seconds.toFixed(2)} s`;
const formatLevel = (level: number) => `${Math.round(level * 100)}%`;

/**
 * A drum rack: sixteen pads, each its own sound. Strike a pad to play it and
 * select it, then shape it with the knobs: tune, decay and level. Strikes are
 * harder near the top of a pad; the hats cut each other short.
 */
export function BlockDrumRack() {
  const [kit, setKit] = useState(KIT);
  const kitRef = useRef(kit);
  kitRef.current = kit;
  const [selected, setSelected] = useState(0);
  const master = useMasterBus(VOLUME);
  // The hits each pad still has ringing, so a choke can silence them.
  const ringing = useRef(new Map<string, GainNode[]>());

  const strike = useCallback(
    (id: string, details: PadPrimitive.Root.OnEventDetails) => {
      const ctx = webAudio.getContext();
      const index = kitRef.current.findIndex((item) => item.id === id);
      const sound = kitRef.current[index];
      if (!(ctx && master && sound)) {
        return;
      }
      if (ctx.state === "suspended") {
        ctx.resume();
      }
      setSelected(index);
      // Each hit goes through a gain of its own: how hard it was struck, at the pad's level.
      const hit = ctx.createGain();
      hit.gain.value = details.velocity * sound.level;
      hit.connect(master);
      const options: DrumOptions = { decay: sound.decay, tune: sound.tune };
      VOICES[sound.voice](ctx, hit, ctx.currentTime, options);
      if (sound.chokeGroup) {
        ringing.current.set(id, [...(ringing.current.get(id) ?? []), hit]);
      }
    },
    [master]
  );

  // Drum hits ring on after the pad is released: only a choke stops them.
  const choke = useCallback(
    (id: string, details: PadPrimitive.Root.OffEventDetails) => {
      const ctx = webAudio.getContext();
      if (!ctx || details.reason !== "choke") {
        return;
      }
      for (const hit of ringing.current.get(id) ?? []) {
        hit.gain.setTargetAtTime(0, ctx.currentTime, CHOKE_FADE);
      }
      ringing.current.delete(id);
    },
    []
  );

  const edit = useCallback(
    (change: Partial<Pick<Sound, "decay" | "level" | "tune">>) =>
      setKit((current) =>
        current.map((item, index) =>
          index === selected ? { ...item, ...change } : item
        )
      ),
    [selected]
  );
  const setTune = useCallback((tune: number) => edit({ tune }), [edit]);
  const setDecay = useCallback((decay: number) => edit({ decay }), [edit]);
  const setLevel = useCallback((level: number) => edit({ level }), [edit]);

  const sound = kit[selected] ?? KIT[0];
  const factory = KIT[selected] ?? KIT[0];

  return (
    <div className="rounded-xl @container/panel w-full min-w-0 border bg-card p-4">
      <div className="mx-auto flex w-full max-w-96 flex-col gap-4 @2xl/panel:max-w-none @2xl/panel:flex-row">
        <section
          aria-label={`${sound.label} settings`}
          className="flex min-w-0 flex-1 flex-col"
        >
          <div className="flex flex-wrap items-start justify-around gap-4">
            <div className="flex flex-col items-center gap-1.5">
              <span
                aria-hidden="true"
                className="text-muted-foreground text-xs"
              >
                Tune
              </span>
              <Knob
                anchor={0}
                aria-label={`${sound.label} tune`}
                defaultValue={factory.tune}
                formatValue={formatTune}
                max={12}
                min={-12}
                onValueChange={setTune}
                step={1}
                value={sound.tune}
                valueLabel="always"
              />
            </div>
            <div className="flex flex-col items-center gap-1.5">
              <span
                aria-hidden="true"
                className="text-muted-foreground text-xs"
              >
                Decay
              </span>
              <Knob
                aria-label={`${sound.label} decay`}
                defaultValue={factory.decay}
                formatValue={formatDecay}
                max={2}
                min={0.02}
                onValueChange={setDecay}
                scale="log"
                step={0.001}
                value={sound.decay}
                valueLabel="always"
              />
            </div>
            <div className="flex flex-col items-center gap-1.5">
              <span
                aria-hidden="true"
                className="text-muted-foreground text-xs"
              >
                Level
              </span>
              <Knob
                aria-label={`${sound.label} level`}
                defaultValue={factory.level}
                formatValue={formatLevel}
                max={1}
                min={0}
                onValueChange={setLevel}
                step={0.01}
                value={sound.level}
                valueLabel="always"
              />
            </div>
          </div>
        </section>
        <Pad
          aria-label="Drum rack"
          className="w-full shrink-0 @2xl/panel:w-96"
          columns={4}
          onPadOff={choke}
          onPadOn={strike}
        >
          {kit.map((item, index) => (
            <PadItem
              chokeGroup={item.chokeGroup}
              className="data-[selected]:ring-2 data-[selected]:ring-primary/40"
              data-selected={index === selected ? "" : undefined}
              key={item.id}
              value={item.id}
            >
              {item.label}
            </PadItem>
          ))}
        </Pad>
      </div>
    </div>
  );
}
```
