Sharing drum kits without losing who made them
When a producer DMs me for my drum kit, they aren't asking for a folder. They're asking for my taste. Openkit is built so the taste still arrives with the files.

The ask
Producers DM me for my drum kit all the time. "Can you send the drums?" They could get sounds anywhere. They came to me because they want my choices.
Sending it is slow. Every sound has to leave Ableton as its own file: isolate the track, export, name it, save it, then do it again. A serious kit holds about fifty sounds, which is a few hundred clicks before it leaves my computer. Then it goes out as a Drive link or a zip in a DM, and the record of who made it stops there.
The formal answer is a subscription library like Splice. It solved access: millions of sounds, searchable and cleared. It also made them anonymous. Producers say "the Splice sound" as an insult. You hear it and you know nobody's hand is on it, the same way you know AI slop.
A kit is worth asking for because of who made it. Anything that moves kits has to move the maker with them.
From export tool to home
Openkit was my ArtCenter capstone, and it started small: a faster way to get a kit out of Ableton. Building it changed the question. A faster export still ends in an anonymous folder. The producer and the kit had to organize the whole product, not ride along as metadata.
The model came from code. Code used to move the way kits move now, as zip files and "DM me the source." Git made authorship, history and forking the shape of the thing. So a kit is a repository, a profile is the contribution history, and a fork keeps its lineage. Forking is the chop: taking someone's kit and making it yours, with their name still on it.
Paths not taken
I borrowed the structure and left the language behind. Producers never see a commit or a pull request. The repository model holds the architecture up and stays out of the interface.
The maker stays attached
Every sound carries its maker. Fork one into your own kit and the credit goes with it, so a kit can change completely without going anonymous.
Audition by playing
You judge a kit by hearing it in a beat, so the preview is an instrument: eight pads, a step grid and three pattern presets. Two clicks take a kit from its card to a playing pattern.
Loading preview…
Show code
"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>
);
}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;
}I kept it small on purpose. No per-step velocity, no swing, one bar. It answers one question, "do I want these drums?", and real production still happens in a DAW.
Three faders shape the kit before you take it: EQ tilt, compression and saturation. The rack was built for the instrument but never made the shipping app, so this version is redesigned around a stem-mixer fader.
Loading preview…
Show code
"use client";
import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { DspParams } from "@/lib/openkit/audio-engine";
import { cn } from "@/lib/utils";
// Live DSP: EQ tilt, compression and saturation as three grip faders. Each
// fader is a label and readout over a tick-marked track with a rounded grip.
// The grip follows the pointer 1:1 and keyboard steps snap: no spring, no
// lag, no overshoot. Audio and readout update on the same frame as the hand.
// EQ is bipolar (warm ← flat → bright), so its centre tick marks unity.
interface SliderDef {
key: keyof DspParams;
label: string;
/** Centre is the neutral value (marked with a taller tick). */
bipolar?: boolean;
/** Human-readable readout for the current 0..1 value. */
format: (v: number) => string;
}
const SLIDERS: SliderDef[] = [
{
key: "eq",
label: "EQ",
bipolar: true,
format: (v) => {
const tilt = Math.round((v - 0.5) * 200);
if (tilt === 0) return "Flat";
return tilt > 0 ? `+${tilt} bright` : `${-tilt} warm`;
},
},
{ key: "comp", label: "Compression", format: (v) => `${Math.round(v * 100)}%` },
{ key: "sat", label: "Saturation", format: (v) => `${Math.round(v * 100)}%` },
];
const DEFAULTS: DspParams = { eq: 0.5, comp: 0, sat: 0 };
const STEP = 0.05;
const BIG_STEP = 0.2;
const TICKS = 17; // odd, so the bipolar centre lands on a tick
const GRIP_W = 14;
const INSET = 8; // grip travel stays clear of the track's rounded ends
export function DspRack({
onChange,
initial,
controls,
}: {
onChange: (params: Partial<DspParams>) => void;
/** Starting values (0..1); EQ 0.5 is flat. */
initial?: Partial<DspParams>;
/** Which faders to show, in order. Defaults to all three. */
controls?: readonly (keyof DspParams)[];
}) {
const sliders = controls
? controls.flatMap((key) => SLIDERS.filter((def) => def.key === key))
: SLIDERS;
return (
<div className="flex w-full flex-col gap-4">
{sliders.map((def) => (
<Fader
key={def.key}
def={def}
initial={initial?.[def.key] ?? DEFAULTS[def.key]}
onChange={(v) => onChange({ [def.key]: v })}
/>
))}
</div>
);
}
function Fader({
def,
initial,
onChange,
}: {
def: SliderDef;
initial: number;
onChange: (value: number) => void;
}) {
const trackRef = useRef<HTMLDivElement>(null);
const [value, setValue] = useState(initial);
const [dragging, setDragging] = useState(false);
const [travel, setTravel] = useState(0);
// Grip travel in px, so the grip moves with transform (no layout per frame).
useEffect(() => {
const el = trackRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setTravel(Math.max(0, entry.contentRect.width - INSET * 2 - GRIP_W));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const setTo = (next: number) => {
const clamped = Math.round(Math.min(1, Math.max(0, next)) * 100) / 100;
if (clamped === value) return;
setValue(clamped);
onChange(clamped);
};
const fromClientX = (clientX: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setTo((clientX - rect.left - INSET - GRIP_W / 2) / (rect.width - INSET * 2 - GRIP_W));
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
fromClientX(e.clientX);
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (dragging) fromClientX(e.clientX);
};
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
setDragging(false);
e.currentTarget.releasePointerCapture?.(e.pointerId);
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? BIG_STEP : STEP;
const keys: Record<string, number> = {
ArrowRight: value + step,
ArrowUp: value + step,
ArrowLeft: value - step,
ArrowDown: value - step,
PageUp: value + BIG_STEP,
PageDown: value - BIG_STEP,
Home: 0,
End: 1,
};
if (!(e.key in keys)) return;
e.preventDefault();
setTo(keys[e.key]);
};
const centre = (TICKS - 1) / 2;
return (
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between px-0.5">
<span className="text-sm text-foreground">{def.label}</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">{def.format(value)}</span>
</div>
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-label={def.label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
aria-valuetext={def.format(value)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
onDoubleClick={() => setTo(DEFAULTS[def.key])}
data-dragging={dragging ? "" : undefined}
className={cn(
"group relative h-9 w-full cursor-ew-resize touch-none select-none rounded-xl bg-muted outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{/* Ticks: evenly spaced across the grip's travel. */}
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 flex items-center justify-between"
style={{ left: INSET + GRIP_W / 2 - 1, right: INSET + GRIP_W / 2 - 1 }}
>
{Array.from({ length: TICKS }, (_, i) => (
<span
key={i}
className={cn(
"w-0.5 rounded-full",
def.bipolar && i === centre ? "h-4 bg-muted-foreground/70" : "h-2.5 bg-muted-foreground/30",
)}
/>
))}
</span>
{/* Grip: a rounded bar with a hairline edge; brightens while held. */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1.5 bottom-1.5 left-0 rounded-[5px] shadow-[inset_0_0_0_1px_rgb(255_255_255/0.25),0_1px_2px_rgb(0_0_0/0.35)]",
"bg-muted-foreground transition-colors duration-150 group-hover:bg-foreground/80 group-data-[dragging]:bg-foreground",
)}
style={{ width: GRIP_W, transform: `translateX(${INSET + value * travel}px)` }}
/>
</div>
</div>
);
}One motion vocabulary
In a tool for auditioning sound, motion should confirm, not perform. Every surface shares one vocabulary: a spring that never bounces, and three fixed durations that CSS mirrors. A 150ms press is half a 300ms enter, so everything moves in proportion.
Exits are 0ms by rule. Closing anything is instant, and there's no exit state to get stuck in.
Gestures that keep momentum
I built swipe rows for the radio queue: save, add or hide a track. The first version decided on position and needed a 500px/s fling, so light flicks died and a half-open row could re-open as you flung it shut. Now it decides on velocity at 110px/s, hands that velocity to the spring, and projects where the row would come to rest.
Loading preview…
Show code
"use client";
import {
useEffect,
useRef,
useState,
type ComponentType,
type FocusEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
type SVGProps,
} from "react";
import { animate } from "animejs";
import { prefersReducedMotion, spring } from "@/lib/openkit/anime/tokens";
import { nearestSnap, project } from "@/lib/openkit/anime/project";
import { cn } from "@/lib/utils";
// Swipe-to-reveal row actions. A drag gesture is rare on the web but right for
// acting on a single item: drag a row to reveal save / add-to-kit on one edge
// and a constructive action on the other, then tap. Pointer events drive
// translateX, constrained to one axis with a stiff edge so the row lands on a
// snap point we choose, not where a fling throws it, and anime.js springs it
// home on release. Progressive reveal arms each action at a pixel threshold.
type SwipeTone = "default" | "accent" | "destructive";
export interface SwipeAction {
id: string;
label: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
tone?: SwipeTone;
onAction: () => void;
/** When true, swap to the active glyph/label (e.g. saved). */
active?: boolean;
activeIcon?: ComponentType<SVGProps<SVGSVGElement>>;
activeLabel?: string;
}
const ACTION_W = 76; // px per action; comfortably past the 44px touch target
const REVEAL_1 = 44; // first action arms here
const REVEAL_2 = 88; // second action arms here
const OPEN_THRESHOLD = 38; // past this on release, snap open instead of closed
const FLING_VELOCITY = 110; // px/s, Apple's fling threshold
const EDGE_RESIST = 0.2; // rubber-band past the bounds
const toneClass: Record<SwipeTone, string> = {
default: "bg-muted font-medium text-foreground",
accent: "bg-primary font-medium text-primary-foreground",
destructive: "bg-destructive font-medium text-white",
};
export function SwipeRow({
leading,
trailing,
initialOpen,
children,
className,
}: {
/** Revealed by swiping right; sits on the left edge. */
leading?: SwipeAction;
/** Revealed by swiping left; sits on the right edge (up to two, progressive). */
trailing?: SwipeAction[];
/** Rest open on mount without animating (docs states, onboarding hints). */
initialOpen?: "leading" | "trailing";
children: ReactNode;
className?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const trailingWidth = Math.min(trailing?.length ?? 0, 2) * ACTION_W;
const leadingWidth = leading ? ACTION_W : 0;
const restX =
initialOpen === "trailing" ? -trailingWidth : initialOpen === "leading" ? leadingWidth : 0;
// Current translate, the running snap animation, and the live drag session.
const x = useRef(restX);
const anim = useRef<ReturnType<typeof animate> | null>(null);
const drag = useRef({ active: false, startX: 0, base: 0, lastX: 0, lastT: 0, v: 0 });
// Progressive reveal: arm each action once the drag passes its threshold.
const [tLevel, setTLevel] = useState(() =>
restX <= -REVEAL_2 ? 2 : restX <= -REVEAL_1 ? 1 : 0,
);
const [lArmed, setLArmed] = useState(() => restX >= REVEAL_1);
const applyArms = (v: number) => {
if (trailing?.length) {
const lvl = v <= -REVEAL_2 ? 2 : v <= -REVEAL_1 ? 1 : 0;
setTLevel((p) => (p === lvl ? p : lvl));
}
if (leading) {
const armed = v >= REVEAL_1;
setLArmed((p) => (p === armed ? p : armed));
}
};
const render = () => {
const el = contentRef.current;
if (el) el.style.transform = `translateX(${x.current}px)`;
applyArms(x.current);
};
const stop = () => {
anim.current?.pause();
anim.current = null;
};
const snap = (target: number, velocity = 0) => {
stop();
if (prefersReducedMotion()) {
x.current = target;
render();
return;
}
anim.current = animate(x, {
current: target,
ease: spring({ velocity }),
onUpdate: render,
});
};
const close = (velocity = 0) => snap(0, velocity);
const openTrailing = (velocity = 0) => snap(-trailingWidth, velocity);
const openLeading = (velocity = 0) => snap(leadingWidth, velocity);
useEffect(() => stop, []);
const onPointerDown = (e: ReactPointerEvent) => {
if (!e.isPrimary) return;
if (e.pointerType === "mouse" && e.button !== 0) return;
stop();
contentRef.current?.setPointerCapture(e.pointerId);
drag.current = {
active: true,
startX: e.clientX,
base: x.current,
lastX: e.clientX,
lastT: performance.now(),
v: 0,
};
};
const onPointerMove = (e: ReactPointerEvent) => {
const d = drag.current;
if (!d.active) return;
let next = d.base + (e.clientX - d.startX);
if (next < -trailingWidth) next = -trailingWidth + (next + trailingWidth) * EDGE_RESIST;
else if (next > leadingWidth) next = leadingWidth + (next - leadingWidth) * EDGE_RESIST;
x.current = next;
render();
const now = performance.now();
const dt = now - d.lastT;
if (dt > 0) d.v = ((e.clientX - d.lastX) / dt) * 1000; // px/s
d.lastX = e.clientX;
d.lastT = now;
};
const endDrag = (e: ReactPointerEvent) => {
const d = drag.current;
if (!d.active) return;
d.active = false;
contentRef.current?.releasePointerCapture?.(e.pointerId);
const pos = x.current;
const fling = d.v;
// Fling: project resting point, then snap to nearest open/closed target.
if (Math.abs(fling) >= FLING_VELOCITY) {
const points = [0];
if (leading) points.push(leadingWidth);
if (trailing?.length) points.push(-trailingWidth);
snap(nearestSnap(pos + project(fling), points), fling);
return;
}
// Slow release: position threshold.
if (trailing?.length && pos <= -OPEN_THRESHOLD) openTrailing(fling);
else if (leading && pos >= OPEN_THRESHOLD) openLeading(fling);
else close(fling);
};
const fire = (action: SwipeAction) => {
action.onAction();
close();
};
// Keyboard parity: a swipe is pointer-only, so when an action button takes
// focus we open its side, and close once focus leaves the row entirely.
const handleBlur = (e: FocusEvent) => {
if (!rootRef.current?.contains(e.relatedTarget as Node | null)) close();
};
return (
<div ref={rootRef} className={cn("relative overflow-clip", className)} onBlur={handleBlur}>
{leading ? (
<div className="absolute inset-y-0 left-0 flex">
<ActionButton
action={leading}
armed={lArmed}
width={ACTION_W}
origin="left"
onFocus={openLeading}
onClick={() => fire(leading)}
/>
</div>
) : null}
{trailing?.length ? (
<div className="absolute inset-y-0 right-0 flex">
{trailing.slice(0, 2).map((action, i) => (
<ActionButton
key={action.id}
action={action}
armed={tLevel >= i + 1}
width={ACTION_W}
origin="right"
onFocus={openTrailing}
onClick={() => fire(action)}
/>
))}
</div>
) : null}
<div
ref={contentRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
style={restX ? { transform: `translateX(${restX}px)` } : undefined}
className="relative z-10 cursor-grab touch-pan-y bg-background active:cursor-grabbing"
>
{children}
</div>
</div>
);
}
function ActionButton({
action,
armed,
width,
origin,
onFocus,
onClick,
}: {
action: SwipeAction;
armed: boolean;
width: number;
origin: "left" | "right";
onFocus: () => void;
onClick: () => void;
}) {
const tone = action.tone ?? "default";
const Glyph = action.active && action.activeIcon ? action.activeIcon : action.Icon;
const label = action.active && action.activeLabel ? action.activeLabel : action.label;
return (
<button
type="button"
onClick={onClick}
onFocus={onFocus}
aria-label={label}
style={{ width, transformOrigin: origin === "right" ? "right center" : "left center" }}
// Arm with opacity + scale, growing from the revealing edge. CSS
// transitions keep it interruptible as the drag arms and disarms.
className={cn(
"flex h-full flex-col items-center justify-center gap-1 text-sm outline-none",
"transition-[opacity,scale] duration-200 ease-out active:scale-[0.96]",
armed ? "scale-100 opacity-100" : "scale-[0.8] opacity-35",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70",
toneClass[tone],
)}
>
<Glyph width={18} height={18} className="shrink-0" />
<span>{label}</span>
</button>
);
}The radio page later moved off swipe rows, so the tuned row lives here and in the registry, not in the app.
Proof
Openkit is live at openkit.space as a working prototype: the feed, library, profiles, radio, the instrument, and breakdowns that open a released song into its sound tree.
The song is a link, never an upload, so Openkit doesn't host the track. Each sound in the tree is either confirmed by lineage Openkit holds or stated by the producer, and the two look different, so a claim never passes as a fact.
Decisions live in the repo as numbered plans with before and after numbers. Caching three reads took the profile page from 249ms to 10.5ms of server time.
Prototype is the honest word. There's no takedown or moderation path yet, and dropping Motion for anime.js means every convenience it gave me for free is now a hook I own.
Learnings
Borrow the structure, not the vocabulary.
GitHub already proved attribution can be infrastructure instead of etiquette. Porting it to sound meant keeping the repository model underneath and speaking producer on top. The analogy breaks where sound differs from code: a kit has commercial value and a scene behind it, so "open" can't mean free-for-all. That break is where the design work was.
I set out to make my own export faster. The project got good when I stopped solving my chore and started designing for the ask.
One question is still open. Part of why the DM works is that it's private and off the record. Will a producer still hand over a kit when the exchange is visible, stored and permanent? I don't know yet.
Components
npx shadcn@latest add \
https://benjaminuribe.com/r/swipe-row.json \
https://benjaminuribe.com/r/dsp-rack.jsonA paperless intake pipeline for a fitness business
AJ was printing waivers, chasing signatures and filing health screenings by hand. He asked for a waiver link on his site. I built him the whole intake.