Skip to content
← Components

Step Sequencer

00 layers

Source

step-sequencer.tsx
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { focusQuietly } from "@/design-system";
import { hostTransport, play, type SoundName } from "@/lib/sound";

/*
 * A step sequencer whose drum kit is the player's own interface: the key's
 * thock, the wheel's detent, the bump at the end of a list and the centre
 * button's chime. Four tracks of sixteen steps.
 *
 * Notes are scheduled a moment ahead on the audio clock (play's `delay`), so
 * the groove never drifts with the frame rate, and the lights read the same
 * queue, so they land with the sound. On the home page's player it plays
 * aloud while the tape plays, keeps quiet while the tape is paused (until a
 * hand works it) and stays stopped after STOP. Anywhere else it just plays.
 * Under reduced motion it waits for PLAY.
 *
 * Narrow stages show eight steps at a time and page with the playhead; wide
 * ones show all sixteen. The pattern is one tab stop: arrows move, Space or
 * Enter toggles a step.
 */

type Track = { sound: SoundName; name: string; gain: number; pitch?: (step: number) => number };

/** The chime walks a pentatonic scale, so the bell line reads as a tune. */
const SCALE = [1, 1.125, 1.25, 1.5, 1.6875];

const TRACKS: Track[] = [
  { sound: "press", name: "Press", gain: 0.8 },
  { sound: "tick", name: "Tick", gain: 0.6, pitch: (s) => (s % 4 === 2 ? 1 : 0.94) },
  { sound: "bump", name: "Bump", gain: 0.75 },
  { sound: "select", name: "Select", gain: 0.4, pitch: (s) => SCALE[(s * 3) % SCALE.length] },
];

const STEPS = 16;
const PAGE = 8;
const LOOKAHEAD = 100; // ms of notes queued ahead on the audio clock
const INTERVAL = 25; // ms between scheduling passes
const LATE = 30; // ms past due before a note is dropped rather than played late
const PIN = 3000; // ms a page stays put after a hand turns to it
const WIDE = 528; // px of container (33rem) from which all sixteen steps show
const BPM = { min: 60, max: 180, initial: 112 };

const GROOVE = [
  [0, 3, 8, 10],
  [2, 6, 10, 14, 15],
  [4, 12],
  [0, 7, 11],
];
const groove = () => GROOVE.map((on) => Array.from({ length: STEPS }, (_, i) => on.includes(i)));

const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");
/** A detent's pitch, varied a little so repeated ticks never sound identical. */
const detent = () => 0.97 + Math.random() * 0.06;
const msFromNow = (ms: number) => performance.now() + ms;

/** Lettering on the body: tiny tracked capitals, cut in. */
const engraved = "font-semibold uppercase leading-none tracking-[0.14em] [text-shadow:var(--device-engrave)]";

/** Drawn, what the glass dims is ink at a lower strength. */
const dimmed = "drawn:text-[color-mix(in_oklab,var(--device-draw-ink)_70%,transparent)]!";

/** Focus that follows the hand: no ring, and Safari still hands the keys to the study. */
const focusOnPress = (e: PointerEvent<HTMLElement>) => {
  if (e.button === 0) focusQuietly(e.currentTarget);
};

export function StepSequencer({ className = "" }: { className?: string }) {
  const [pattern, setPattern] = useState(groove);
  const [bpm, setBpm] = useState(BPM.initial);
  const [running, setRunning] = useState(false);
  const [head, setHead] = useState({ step: -1, bar: 0 }); // the step under the playhead
  const [page, setPage] = useState(0);
  const [wide, setWide] = useState(false);
  const [cursor, setCursor] = useState({ row: 0, col: 0 });
  const [announcement, setAnnouncement] = useState("");
  const ids = useId();

  const rootRef = useRef<HTMLDivElement>(null);
  const patternRef = useRef(pattern);
  const bpmRef = useRef(bpm);
  const touched = useRef(false); // a hand has worked it: it may sound even while the host is paused
  const pinned = useRef(0); // until when the page ignores the playhead
  const refocus = useRef(false);
  const drag = useRef<{ id: number; x: number; y: number; from: number; at: number } | null>(null);

  useEffect(() => {
    patternRef.current = pattern;
  }, [pattern]);
  useEffect(() => {
    bpmRef.current = bpm;
  }, [bpm]);

  // Start on the first frame, unless the viewer prefers less motion or the host's tape is stopped.
  // Then follow the host: its PLAY starts the pattern, its STOP stops it.
  useEffect(() => {
    const root = rootRef.current;
    const start = requestAnimationFrame(() => {
      if (!matchMedia("(prefers-reduced-motion: reduce)").matches && hostTransport(root) !== "stop") setRunning(true);
    });
    const host = root?.closest("[data-transport]");
    let last = hostTransport(root);
    const observer = new MutationObserver(() => {
      const next = hostTransport(root);
      if (next === last) return;
      last = next;
      if (next === "play") {
        setHead({ step: -1, bar: 0 });
        setRunning(true);
      } else if (next === "stop") setRunning(false);
    });
    if (host) observer.observe(host, { attributes: true, attributeFilter: ["data-transport"] });
    return () => {
      cancelAnimationFrame(start);
      observer.disconnect();
    };
  }, []);

  // Sixteen steps once there's room for them.
  useLayoutEffect(() => {
    const observer = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= WIDE));
    observer.observe(rootRef.current!);
    return () => observer.disconnect();
  }, []);

  // The scheduler: queue the next notes a moment ahead; the lights follow the same queue.
  useEffect(() => {
    if (!running) return;
    const root = rootRef.current;
    const queue: { step: number; at: number }[] = [];
    let at = performance.now() + 40;
    let step = 0;
    let raf = 0;

    function schedule() {
      const now = performance.now();
      // Back from a hidden tab or a long stall: pick the beat up from here instead of racing to catch up.
      if (at < now - 250) at = now + 20;
      const audible = touched.current || hostTransport(root) === "play";
      while (at < now + LOOKAHEAD) {
        if (audible && at >= now - LATE) {
          for (let t = 0; t < TRACKS.length; t++) {
            if (!patternRef.current[t][step]) continue;
            const track = TRACKS[t];
            play(track.sound, { delay: Math.max(0, at - now) / 1000, gain: track.gain, pitch: track.pitch?.(step) });
          }
        }
        queue.push({ step, at });
        step = (step + 1) % STEPS;
        at += 60000 / bpmRef.current / 4;
      }
    }

    function draw(now: number) {
      let shown = -1;
      while (queue.length && queue[0].at <= now) shown = queue.shift()!.step;
      if (shown >= 0) {
        setHead((h) => ({ step: shown, bar: shown === 0 ? h.bar + 1 : h.bar }));
        if (performance.now() > pinned.current) setPage(Math.floor(shown / PAGE));
      }
      raf = requestAnimationFrame(draw);
    }

    schedule();
    const id = setInterval(schedule, INTERVAL);
    raf = requestAnimationFrame(draw);
    return () => {
      clearInterval(id);
      cancelAnimationFrame(raf);
    };
  }, [running]);

  // Keyboard moves land on the step they moved to, once its page is showing.
  useLayoutEffect(() => {
    if (!refocus.current) return;
    refocus.current = false;
    rootRef.current?.querySelector<HTMLElement>(`[data-cell="${cursor.row}-${cursor.col}"]`)?.focus();
  }, [cursor, page]);

  /* --- Moves -------------------------------------------------------------- */

  function pin() {
    pinned.current = msFromNow(PIN);
  }

  function toggle(row: number, col: number) {
    const on = !pattern[row][col];
    setPattern((p) => p.map((r, i) => (i === row ? r.map((v, j) => (j === col ? on : v)) : r)));
    setCursor({ row, col });
    pin();
    const track = TRACKS[row];
    if (on) play(track.sound, { gain: track.gain, pitch: track.pitch?.(col) });
    else play("release", { gain: 0.6 });
  }

  function onStepKey(e: KeyboardEvent<HTMLButtonElement>, row: number, col: number) {
    let r = row;
    let c = col;
    if (e.key === "ArrowRight") c++;
    else if (e.key === "ArrowLeft") c--;
    else if (e.key === "ArrowDown") r++;
    else if (e.key === "ArrowUp") r--;
    else if (e.key === "Home") c = 0;
    else if (e.key === "End") c = STEPS - 1;
    else return;
    e.preventDefault();
    if (r < 0 || r >= TRACKS.length || c < 0 || c >= STEPS || (r === row && c === col)) return play("bump", { gain: 0.6 });
    play("tick", { pitch: detent() });
    refocus.current = true;
    setCursor({ row: r, col: c });
    setPage(Math.floor(c / PAGE));
    pin();
  }

  function turnPage(p: number) {
    pin();
    if (p === page) return play("bump", { gain: 0.6 });
    play("toggle");
    setPage(p);
  }

  function toggleRun() {
    if (running) {
      setRunning(false);
      play("stop");
      setAnnouncement("Stopped");
    } else {
      setHead({ step: -1, bar: 0 });
      setRunning(true);
      play("start");
      setAnnouncement(`Playing, ${bpm} BPM`);
    }
  }

  function setTempo(next: number) {
    const value = Math.min(BPM.max, Math.max(BPM.min, Math.round(next)));
    if (value === bpm) return play("bump", { gain: 0.6 });
    setBpm(value);
    play("tick", { pitch: 0.85 + ((value - BPM.min) / (BPM.max - BPM.min)) * 0.3 });
  }

  function onTempoKey(e: KeyboardEvent<HTMLDivElement>) {
    const delta = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1, PageUp: 10, PageDown: -10 }[e.key];
    if (delta) setTempo(bpm + delta);
    else if (e.key === "Home") setTempo(BPM.min);
    else if (e.key === "End") setTempo(BPM.max);
    else return;
    e.preventDefault();
  }

  // Drag the screen to set the tempo: right or up is faster, a beat per 4px.
  function onTempoDown(e: PointerEvent<HTMLDivElement>) {
    if (e.button !== 0) return;
    focusOnPress(e);
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY, from: bpm, at: bpm };
  }
  function onTempoMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    const next = Math.min(BPM.max, Math.max(BPM.min, Math.round(d.from + (e.clientX - d.x - (e.clientY - d.y)) / 4)));
    if (next === d.at) return;
    d.at = next;
    setTempo(next);
  }
  function onTempoEnd(e: PointerEvent<HTMLDivElement>) {
    if (drag.current?.id === e.pointerId) drag.current = null;
  }

  /* --- Render ------------------------------------------------------------- */

  const lit = running ? head.step : -1;
  const cols = wide ? Array.from({ length: STEPS }, (_, i) => i) : Array.from({ length: PAGE }, (_, i) => page * PAGE + i);
  // The pattern's one tab stop, kept on a step that is showing.
  const stop = { row: cursor.row, col: cols.includes(cursor.col) ? cursor.col : cols[cursor.col % PAGE] };
  const template = { gridTemplateColumns: `3.9em repeat(${cols.length}, minmax(0, 1fr))` };
  const position = lit >= 0 ? `${String(head.bar).padStart(2, "0")}.${Math.floor(lit / 4) + 1}` : "01.1";

  return (
    <div
      ref={rootRef}
      role="group"
      aria-label="Step sequencer"
      onPointerDownCapture={() => (touched.current = true)}
      onKeyDownCapture={() => (touched.current = true)}
      className={cx("@container w-full max-w-[560px] select-none", className)}
    >
      {/* A plate cut from the player's body: the same bead-blasted finish and lit top edge. */}
      <div data-part="plate" className="relative isolate animate-enter overflow-hidden rounded-[1.25em] p-[0.9em] text-[clamp(11px,4.1cqw,13px)] [background:var(--device-body)] shadow-[var(--device-body-edge),0_1px_2px_rgb(0_0_0/0.06),0_16px_32px_-18px_rgb(0_0_0/0.3)] @[33rem]:p-[1.1em] @[33rem]:text-[clamp(12px,2.5cqw,14px)]">
        <div aria-hidden className="device-grain pointer-events-none absolute inset-0 -z-10 rounded-[inherit]" />

        {/* The tempo screen and the transport key. */}
        <div className="flex items-stretch gap-[0.7em]">
          <div
            role="slider"
            tabIndex={0}
            aria-label="Tempo"
            aria-describedby={`${ids}-tempo`}
            aria-orientation="horizontal"
            aria-valuemin={BPM.min}
            aria-valuemax={BPM.max}
            aria-valuenow={bpm}
            aria-valuetext={`${bpm} BPM`}
            onKeyDown={onTempoKey}
            onPointerDown={onTempoDown}
            onPointerMove={onTempoMove}
            onPointerUp={onTempoEnd}
            onPointerCancel={onTempoEnd}
            data-part="lcd"
            className="relative flex h-[3.7em] min-w-0 flex-1 cursor-ew-resize touch-none flex-col justify-between overflow-hidden rounded-[0.7em] px-[0.8em] pb-[0.55em] pt-[0.6em] text-(--device-lcd-ink) outline-offset-2 [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
          >
            <div className="flex items-center gap-[0.5em]">
              <span data-part="chip" className="inline-flex items-center gap-[0.35em] rounded-[0.4em] bg-white px-[0.42em] py-[0.24em] text-black">
                {running ? (
                  <span className="size-[0.55em] animate-pulse rounded-full bg-(--device-rec) drawn:bg-(--device-rec)!" />
                ) : (
                  <span className="size-[0.5em] rounded-[1px] bg-current drawn:bg-(--device-draw-ink)!" />
                )}
                <span className="text-[0.58em] font-semibold uppercase leading-none tracking-[0.02em]">{running ? "Play" : "Stop"}</span>
              </span>
              <span className={cx("ml-auto text-[0.66em] tabular-nums text-(--device-lcd-dim)", dimmed)}>{position}</span>
            </div>
            <p className="flex items-baseline gap-[0.3em] leading-none">
              <span className="text-[1.55em] font-light tabular-nums tracking-[-0.03em]">{bpm}</span>
              <span className={cx("text-[0.56em] text-(--device-lcd-dim)", dimmed)}>BPM</span>
            </p>
          </div>

          {/* PLAY / STOP: a white cap seated in a collar, like the deck's transport keys. */}
          <button
            type="button"
            aria-label={running ? "Stop" : "Play"}
            aria-pressed={running}
            onPointerDown={focusOnPress}
            onClick={toggleRun}
            data-part="collar"
            className="group/key grid size-[3.7em] shrink-0 place-items-center rounded-full bg-black/[0.035] p-[0.26em] shadow-(--device-recess) outline-offset-2 dark:bg-black/30"
          >
            <span data-part="cap" className="grid size-full place-items-center rounded-full [background:var(--device-wheel-face)] shadow-(--device-key-shadow) transition-[transform,box-shadow] duration-(--duration-exit) ease-out group-active/key:translate-y-[2px] group-active/key:shadow-(--device-key-shadow-pressed) group-active/key:duration-75">
              {running ? (
                <span aria-hidden data-part="lettering" className="size-[0.9em] rounded-[0.14em] bg-(--device-key-ink) drawn:bg-(--device-draw-ink)!" />
              ) : (
                <svg aria-hidden data-part="lettering" viewBox="0 0 16 16" className="ml-[0.12em] size-[1.25em] fill-(--device-rec)">
                  <path d="M4.5 2.4 13.2 8l-8.7 5.6z" />
                </svg>
              )}
            </span>
          </button>
        </div>

        {/* The playhead: a red light over the step that is sounding. Drawn, a light that fires is red, a set one ink and an unlit one only its hairline ring. */}
        <div aria-hidden className="mt-[0.8em] grid items-center gap-x-[0.3em]" style={template}>
          <span />
          {cols.map((col) => (
            <span key={col} className="flex justify-center">
              <span
                data-part="light"
                className={cx(
                  "h-[0.26em] rounded-full transition-[background-color,box-shadow] duration-(--duration-exit)",
                  col % 4 === 0 ? "w-[0.9em]" : "w-[0.5em]",
                  col === lit ? "bg-(--device-rec) shadow-[0_0_0.4em_var(--device-rec)] drawn:bg-(--device-rec)!" : "bg-(--device-meter-off)",
                )}
              />
            </span>
          ))}
        </div>

        {/* The pattern: one row of keys per sound. */}
        <div className="mt-[0.45em] flex flex-col gap-[0.3em]">
          {TRACKS.map((track, row) => {
            const firing = lit >= 0 && pattern[row][lit];
            return (
              <div key={track.sound} role="group" aria-labelledby={`${ids}-${track.sound}`} className="grid items-center gap-x-[0.3em]" style={template}>
                <span className="flex min-w-0 items-center gap-[0.4em]">
                  {/* The channel's light: it fires with the track. */}
                  <span
                    aria-hidden
                    data-part="light"
                    className={cx(
                      "size-[0.36em] shrink-0 rounded-full transition-[background-color,box-shadow] duration-(--duration-exit)",
                      firing ? "bg-(--device-rec) shadow-[0_0_0.4em_var(--device-rec)] drawn:bg-(--device-rec)!" : "bg-(--device-meter-off)",
                    )}
                  />
                  <span id={`${ids}-${track.sound}`} data-part="lettering" className={cx(engraved, "truncate text-[0.6em] text-(--device-label)")}>
                    {track.name}
                  </span>
                </span>
                {cols.map((col) => {
                  const on = pattern[row][col];
                  const fire = on && col === lit;
                  return (
                    <button
                      key={col}
                      type="button"
                      data-cell={`${row}-${col}`}
                      tabIndex={row === stop.row && col === stop.col ? 0 : -1}
                      aria-pressed={on}
                      aria-label={`${track.name}, step ${col + 1}`}
                      onPointerDown={focusOnPress}
                      onClick={() => toggle(row, col)}
                      onKeyDown={(e) => onStepKey(e, row, col)}
                      data-part="key"
                      className="group/key relative h-[2.15em] rounded-[0.45em] outline-offset-1"
                    >
                      <span className="absolute inset-x-[0.12em] inset-y-[0.1em] rounded-[0.4em] [background:var(--device-key-face)] shadow-(--device-key-shadow) transition-[transform,box-shadow] duration-(--duration-exit) ease-out group-active/key:translate-y-[2px] group-active/key:shadow-(--device-key-shadow-pressed) group-active/key:duration-75">
                        <span
                          data-part="light"
                          className={cx(
                            "absolute inset-x-[28%] top-[0.42em] h-[0.26em] rounded-full transition-[background-color,box-shadow] duration-(--duration-exit)",
                            fire ? "bg-(--device-rec) shadow-[0_0_0.45em_var(--device-rec)] drawn:bg-(--device-rec)!" : on ? "bg-(--device-meter-on) drawn:bg-(--device-draw-ink)!" : "bg-(--device-meter-off)",
                          )}
                        />
                      </span>
                    </button>
                  );
                })}
              </div>
            );
          })}
        </div>

        {/* Narrow stages: two pages of eight. Each key lights red while the playhead is on its page. */}
        {!wide && (
          <div className="mt-[0.75em] flex items-center gap-[0.45em]">
            <span data-part="lettering" className={cx(engraved, "mr-auto text-[0.58em] text-(--device-label-quiet)")}>Steps</span>
            {[0, 1].map((p) => (
              <button
                key={p}
                type="button"
                aria-pressed={page === p}
                aria-label={`Steps ${p * PAGE + 1} to ${p * PAGE + PAGE}`}
                onPointerDown={focusOnPress}
                onClick={() => turnPage(p)}
                className="group/key flex h-[2.2em] items-center gap-[0.45em] rounded-[0.6em] px-[0.7em] text-(--device-label) outline-offset-2 transition-[background-color,box-shadow,transform] duration-(--duration-exit) ease-out active:translate-y-px aria-pressed:bg-(--device-well) aria-pressed:shadow-(--device-recess)"
              >
                <span
                  aria-hidden
                  data-part="light"
                  className={cx(
                    "size-[0.36em] rounded-full transition-[background-color,box-shadow] duration-(--duration-exit)",
                    lit >= 0 && Math.floor(lit / PAGE) === p ? "bg-(--device-rec) shadow-[0_0_0.4em_var(--device-rec)] drawn:bg-(--device-rec)!" : "bg-(--device-meter-off)",
                  )}
                />
                <span data-part="lettering" className={cx(engraved, "text-[0.62em] tabular-nums tracking-[0.06em]")}>
                  {p * PAGE + 1}–{p * PAGE + PAGE}
                </span>
              </button>
            ))}
          </div>
        )}
      </div>

      <p id={`${ids}-tempo`} className="sr-only">
        Drag the screen, or use the arrow keys, to set the tempo.
      </p>
      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
    </div>
  );
}

export default function Demo() {
  return <StepSequencer />;
}