Pad instrument
The playable sampler: eight keyboard-mapped pads, a transport with BPM and pattern presets, a step grid, and beat-synced glows on one audio engine.
Loading preview…
Show code
pad-instrument.tsx
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
type SVGProps,
} from "react";
import { animate } from "animejs";
import { EASE_OUT, prefersReducedMotion } from "@/lib/openkit/anime/tokens";
import { cn } from "@/lib/utils";
import type { AudioEngine } from "@/lib/openkit/audio-engine";
import type { Pad, Pattern } from "@/lib/openkit/sampler-types";
import { usePlayhead } from "@/hooks/openkit/use-audio-engine";
import { StepGrid } from "@/components/openkit/step-grid";
import { SCROLL_FADE_CLASS, useScrollFade } from "@/hooks/use-scroll-fade";
import { PADS, PATTERN_PRESETS, presetToPattern, STEP_COUNT } from "./patterns";
const MIN_BPM = 60;
const MAX_BPM = 180;
export interface PadKit {
title: string;
maker: string;
/** Up to eight pads; falls back to the default home-row layout. */
pads?: Pad[];
}
// The audition surface: tap a pad to hear a one-shot and run the kit through a
// pattern to hear it in a beat. The sequencer is a full grid (StepGrid); this
// owns the live sequence and pushes it to the engine.
export function PadInstrument({
kit,
engine,
defaultPreset = PATTERN_PRESETS[0].id,
accent = "var(--primary)",
}: {
kit: PadKit;
/** From useAudioEngine(). Pass null to render without sound. */
engine: AudioEngine | null;
/** Preset id to start on, or "" for an empty bar. */
defaultPreset?: string;
/** Fill for pads, steps and the play button. Defaults to the theme's primary. */
accent?: string;
}) {
const pads = useMemo(
() => (kit.pads?.length ? kit.pads.slice(0, PADS.length) : PADS),
[kit],
);
const [pattern, setPattern] = useState<Pattern>(() => {
const preset = PATTERN_PRESETS.find((p) => p.id === defaultPreset);
return preset ? presetToPattern(preset) : {};
});
const [presetId, setPresetId] = useState(defaultPreset);
const [variants, setVariants] = useState<Record<string, number>>({});
const [bpm, setBpm] = useState(90);
const [playing, setPlaying] = useState(false);
const [flash, setFlash] = useState<Record<string, number>>({});
const playStep = usePlayhead(engine);
const padStrip = useScrollFade<HTMLDivElement>();
// Push the live sequence to the engine whenever it changes. setSequence does
// not create an AudioContext, so this is safe before any gesture.
useEffect(() => {
if (!engine) return;
engine.setSequence({
bpm,
stepCount: STEP_COUNT,
tracks: pads.map((p) => ({
type: p.type,
variant: variants[p.id] ?? 1,
steps: pattern[p.id] ?? [],
})),
});
}, [engine, bpm, pads, pattern, variants]);
// Stop the sequence when the instrument unmounts.
useEffect(() => () => engine?.stop(), [engine]);
const fire = useCallback(
(pad: Pad) => {
engine?.trigger(pad.type, variants[pad.id] ?? 1);
setFlash((f) => ({ ...f, [pad.id]: Date.now() }));
},
[engine, variants],
);
const togglePlay = useCallback(() => {
if (!engine) return;
if (engine.isPlaying()) {
engine.stop();
setPlaying(false);
} else {
engine.play();
setPlaying(true);
}
}, [engine]);
// Keyboard-mapped pads, scoped to the instrument: keys play only while focus
// is inside it, so the host page keeps its own shortcuts and Space-to-scroll.
const byKey = useMemo(() => new Map(pads.map((p) => [p.key, p])), [pads]);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
if ((e.key === " " || e.code === "Space") && target === e.currentTarget) {
e.preventDefault();
togglePlay();
return;
}
const pad = byKey.get(e.key.toUpperCase());
if (pad) {
e.preventDefault();
fire(pad);
}
};
function toggleStep(padId: string, step: number) {
setPattern((prev) => {
const active = prev[padId] ?? [];
const next = active.includes(step)
? active.filter((s) => s !== step)
: [...active, step].sort((a, b) => a - b);
return { ...prev, [padId]: next };
});
setPresetId("");
}
function applyPreset(id: string) {
const preset = PATTERN_PRESETS.find((p) => p.id === id);
if (!preset) return;
setPattern(presetToPattern(preset));
setPresetId(id);
}
function swap(padId: string) {
setVariants((v) => ({ ...v, [padId]: ((v[padId] ?? 1) + 1) % 3 }));
}
return (
<div
role="group"
aria-label={`${kit.title} by ${kit.maker}. Keys ${pads.map((p) => p.key).join(" ")} play pads; Space plays the pattern.`}
tabIndex={-1}
onKeyDown={onKeyDown}
className="flex min-w-0 flex-col gap-5 outline-none"
>
<Transport
playing={playing}
onToggle={togglePlay}
disabled={!engine}
bpm={bpm}
onBpm={setBpm}
accent={accent}
presetId={presetId}
onPreset={applyPreset}
/>
<div
ref={padStrip}
className={cn("flex gap-2 overflow-x-auto px-px py-1 [scrollbar-width:none]", SCROLL_FADE_CLASS)}
>
{pads.map((pad) => {
const steps = pattern[pad.id] ?? [];
const onBeat = playStep >= 0 && steps.includes(playStep);
return (
<PadCell
key={pad.id}
pad={pad}
accent={accent}
variant={variants[pad.id] ?? 1}
flashedAt={flash[pad.id]}
onBeat={onBeat}
onFire={() => fire(pad)}
onSwap={() => swap(pad.id)}
/>
);
})}
</div>
<StepGrid
pads={pads}
pattern={pattern}
stepCount={STEP_COUNT}
playStep={playStep}
accent={accent}
flash={flash}
onToggleStep={toggleStep}
/>
</div>
);
}
function Transport({
playing,
onToggle,
disabled,
bpm,
onBpm,
accent,
presetId,
onPreset,
}: {
playing: boolean;
onToggle: () => void;
disabled: boolean;
bpm: number;
onBpm: (bpm: number) => void;
accent: string;
presetId: string;
onPreset: (id: string) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-label={playing ? "Stop" : "Play kit"}
className="flex size-11 shrink-0 items-center justify-center rounded-full text-primary-foreground outline-none transition-[filter,scale] duration-150 ease-out hover:brightness-110 active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50"
style={{ background: accent }}
>
{playing ? <StopIcon width={18} height={18} /> : <PlayIcon width={18} height={18} className="ml-0.5" />}
</button>
<Stepper
label="BPM"
value={bpm}
onChange={(d) => onBpm(Math.min(MAX_BPM, Math.max(MIN_BPM, bpm + d)))}
/>
<div className="ml-auto flex items-center gap-1">
{PATTERN_PRESETS.map((p) => {
const on = presetId === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => onPreset(p.id)}
aria-pressed={on}
className={cn(
"relative inline-flex h-auto items-center gap-1.5 rounded-lg px-2.5 py-2 text-xs font-medium leading-none outline-none",
"after:absolute after:-inset-x-1 after:-inset-y-1.5 after:content-['']",
"transition-[scale,background-color,color] duration-150 ease-out active:scale-[0.96]",
"focus-visible:ring-2 focus-visible:ring-ring",
on
? "bg-primary/15 text-primary hover:bg-primary/25"
: "text-muted-foreground hover:bg-primary/10 hover:text-primary",
)}
>
{p.name}
</button>
);
})}
</div>
</div>
);
}
function Stepper({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (delta: number) => void;
}) {
return (
<div className="flex h-10 items-center rounded-lg bg-background ring-1 ring-border">
<StepButton ariaLabel={`Decrease ${label}`} onClick={() => onChange(-1)}>
−
</StepButton>
<span className="flex min-w-[58px] items-center justify-center gap-1 text-sm tabular-nums text-foreground">
{value}
<span className="text-sm text-muted-foreground">{label}</span>
</span>
<StepButton ariaLabel={`Increase ${label}`} onClick={() => onChange(1)}>
+
</StepButton>
</div>
);
}
function StepButton({
children,
ariaLabel,
onClick,
}: {
children: ReactNode;
ariaLabel: string;
onClick: () => void;
}) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className="flex size-10 items-center justify-center rounded-lg text-base text-muted-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</button>
);
}
// One-shot flash overlay when a pad is hand-fired. Mounts fresh per hit (keyed
// on the fire timestamp) so anime.js can run the fade from scratch each time.
function PadFlash({ accent }: { accent: string }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion()) return;
animate(el, { opacity: [0.55, 0], duration: 300, ease: EASE_OUT });
}, []);
return (
<span
ref={ref}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-xl"
style={{ background: accent, opacity: 0 }}
/>
);
}
function PadCell({
pad,
accent,
variant,
flashedAt,
onBeat,
onFire,
onSwap,
}: {
pad: Pad;
accent: string;
variant: number;
flashedAt?: number;
onBeat: boolean;
onFire: () => void;
onSwap: () => void;
}) {
return (
<div className="flex w-[84px] shrink-0 flex-col gap-2">
<button
type="button"
onClick={onFire}
aria-label={`${pad.name} (${pad.key})`}
className="relative flex aspect-square flex-col items-center justify-center gap-1.5 rounded-xl bg-background outline-none ring-1 ring-border transition-[background-color,scale] duration-150 ease-out hover:bg-muted active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-ring"
>
{/* Beat glow: lit when the playhead lands on this pad's active step. A
CSS transition keeps it interruptible; on is snappy, off decays. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-xl transition-opacity ease-out"
style={{
background: accent,
opacity: onBeat ? 0.5 : 0,
transitionDuration: onBeat ? "50ms" : "180ms",
}}
/>
{/* Hand-fire flash, keyed on timestamp so each hit re-triggers. */}
{flashedAt ? <PadFlash key={`flash-${flashedAt}`} accent={accent} /> : null}
<span
aria-hidden
className="relative flex size-7 items-center justify-center rounded-md bg-muted text-sm text-foreground ring-1 ring-border"
>
{pad.key}
</span>
<span
aria-hidden
className="relative w-full min-w-0 truncate px-2 text-center text-xs text-foreground"
title={pad.name}
>
{pad.name}
</span>
</button>
<button
type="button"
onClick={onSwap}
aria-label={`Swap ${pad.name} sound, variant ${variant + 1} of 3`}
className="flex h-9 items-center justify-center gap-1.5 rounded-lg bg-muted text-xs tabular-nums text-muted-foreground outline-none transition-colors duration-150 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ShuffleIcon width={13} height={13} />
{variant + 1}/3
</button>
</div>
);
}
function PlayIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="1.45 -0.05 24.1 24.1" aria-hidden {...props}>
<path
fill="currentColor"
d="M21.409 9.353a2.998 2.998 0 0 1 0 5.294L8.597 21.614C6.534 22.737 4 21.277 4 18.968V5.033c0-2.31 2.534-3.769 4.597-2.648z"
/>
</svg>
);
}
function StopIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="1.16 1.16 21.69 21.69" aria-hidden {...props}>
<path
fill="currentColor"
d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z"
/>
</svg>
);
}
function ShuffleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="-0.05 -0.06 24.1 24.1" aria-hidden {...props}>
<path
fill="currentColor"
d="M21.924 6.617a1 1 0 0 0-.217-.324l-3-3a1 1 0 1 0-1.414 1.414L18.586 6h-3.321a5 5 0 0 0-4.288 2.428l-3.67 6.115A3 3 0 0 1 4.736 16H3a1 1 0 1 0 0 2h1.735a5 5 0 0 0 4.288-2.428l3.67-6.115A3 3 0 0 1 15.264 8h3.32l-1.292 1.293a1 1 0 0 0 1.414 1.414l3-3A1 1 0 0 0 22 7m-.076-.383a1 1 0 0 1 .076.38z"
/>
<path
fill="currentColor"
d="m21.706 17.708l-2.999 3a1 1 0 0 1-1.414-1.415L18.586 18h-3.321a5 5 0 0 1-4.288-2.428l-3.67-6.115A3 3 0 0 0 4.736 8H3a1 1 0 0 1 0-2h1.735a5 5 0 0 1 4.288 2.428l3.67 6.115A3 3 0 0 0 15.264 16h3.32l-1.292-1.293a1 1 0 0 1 1.414-1.414l3 3c.195.194.292.45.293.704V17a1 1 0 0 1-.294.708z"
/>
</svg>
);
}patterns.ts
import type { Pad, Pattern, PatternPreset } from "@/lib/openkit/sampler-types";
// The eight pads every kit exposes, on the home-row keys so a kit plays from the
// keyboard without reaching. Pads without a sample fall back to the synth
// voice, so the layout never changes shape.
export const PADS: Pad[] = [
{ id: "kick", name: "Kick", key: "A", type: "kick" },
{ id: "snare", name: "Snare", key: "S", type: "snare" },
{ id: "hat", name: "Hat", key: "D", type: "hat" },
{ id: "openhat", name: "Open Hat", key: "F", type: "openhat" },
{ id: "clap", name: "Clap", key: "G", type: "clap" },
{ id: "rim", name: "Rim", key: "H", type: "rim" },
{ id: "tom", name: "Tom", key: "J", type: "tom" },
{ id: "perc", name: "Perc", key: "K", type: "perc" },
];
export const STEP_COUNT = 16;
// Patterns are stored as the active step indices per pad, which reads far more
// clearly than a boolean[16]. Three presets so "change the pattern" is one tap,
// then editable per step.
export const PATTERN_PRESETS: PatternPreset[] = [
{
id: "boom-bap",
name: "Boom bap",
steps: {
kick: [0, 6, 10],
snare: [4, 12],
hat: [0, 2, 4, 6, 8, 10, 12, 14],
openhat: [14],
perc: [7],
},
},
{
id: "trap",
name: "Trap",
steps: {
kick: [0, 3, 8, 11],
snare: [8],
clap: [8],
hat: [0, 2, 4, 6, 8, 10, 11, 12, 13, 14],
openhat: [15],
},
},
{
id: "broken",
name: "Broken",
steps: {
kick: [0, 10],
snare: [4, 12],
rim: [2, 7, 9],
hat: [1, 3, 5, 7, 9, 11, 13, 15],
tom: [14],
},
},
];
export function presetToPattern(preset: PatternPreset): Pattern {
// Return a deep copy so editing the live pattern never mutates the preset.
const out: Pattern = {};
for (const pad of PADS) out[pad.id] = [...(preset.steps[pad.id] ?? [])];
return out;
}