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.

WhenJul –– Sep '26
ForOpenkit
DisciplineProduct design, design engineering, sound
ToolsNext.js, React, anime.js, Web Audio, Base UI, Tailwind, Supabase, Clerk, Drizzle

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.

The line under the title names the kit this one was forked from. Row 1 came in from another producer and still shows who made it.
The library leads with who made a kit, then what's in it.
A profile reads as a body of work, not an audience size.

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.

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)}>
        &minus;
      </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;
}
Four of the app's eight pads. Press one or type A through F; sound waits for that first press, because the audio engine only starts inside a gesture.
Where the instrument lives in the app: the kit's preview, one click from Download and Fork kit.

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.

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>
  );
}
The grip follows your hand 1:1, with no spring to lag behind it. The centre tick marks flat EQ, and a double-click puts a fader back.

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.

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>
  );
}
A flick lands on the snap point your hand was heading for. Keyboard users reach the same actions with Tab.

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.

A breakdown links the song and credits every sound in it, including ones that never touched Openkit.

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

shadcn CLI
npx shadcn@latest add \
  https://benjaminuribe.com/r/swipe-row.json \
  https://benjaminuribe.com/r/dsp-rack.json