# Timeline

> A zoomable timeline for arrangement views and editors, with a ruler that seeks, lanes and a playhead.

```tsx
"use client";

import type { Breakpoint } from "@audio-ui/core/envelope";
import {
  adjacentDivision,
  barLabelInterval,
  barsBeatsWith,
  barsOf,
  DEFAULT_TEMPO,
  divisionAt,
  GRID_DIVISIONS,
  type GridDivision,
  gridInterval,
  gridLabel,
  type MetronomeRhythm,
  secondsPerBar,
  type Tempo,
  TRIPLET_FACTOR,
} from "@audio-ui/core/time";
import { snapMove } from "@audio-ui/core/timeline";
import {
  adjacentPoint,
  type Timeline as TimelinePrimitive,
} from "@audio-ui/react";
import {
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type ReactNode,
  useCallback,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { createPortal } from "react-dom";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import {
  ArrangedClip,
  ArrangedClipGhost,
} from "@/components/audio-ui/arranged-clip";
import { NumberControl } from "@/components/audio-ui/bpm-control";
import { Meter } from "@/components/audio-ui/meter";
import {
  COMPACT_TRACK_HEIGHT,
  MAX_TRACK_HEIGHT,
  MIN_TRACK_HEIGHT,
  Timeline,
  TimelineGridMenuItems,
  TimelineLane,
  TimelineMark,
  TimelineNavigator,
  TimelinePlayhead,
  TimelineRange,
  TimelineRangeHandle,
  TimelineRuler,
  TimelineTrack,
  TimelineTrackControls,
  TimelineTrackHeader,
  TimelineViewport,
  TRACK_COLORS,
  TRACK_HEIGHT_STEP,
} from "@/components/audio-ui/timeline";
import {
  TimelineLocator,
  TimelineSignature,
} from "@/components/audio-ui/timeline-markers";
import {
  AUTOMATED_PARAMETERS,
  TrackAutomation,
  TrackAutomationChooser,
} from "@/components/audio-ui/track-automation";
import { Transport } from "@/components/audio-ui/transport";
import {
  ClipPanel,
  type ClipSettings,
} from "@/components/audio-ui/clip-panel";
import {
  MidiClipPanel,
  type MidiClipRegion,
} from "@/components/audio-ui/midi-clip-panel";
import {
  type ClipAction,
  useArrangement,
} from "@/hooks/use-arrangement";
import { useMarquee } from "@/hooks/use-marquee-selection";
import {
  type LevelsAt,
  useMidiPlayback,
} from "@/hooks/use-midi-playback";
import { useSimulatedClock } from "@/hooks/use-simulated-clock";
import { useSimulatedLevels } from "@/hooks/use-simulated-levels";
import { useTrackReorder } from "@/hooks/use-track-reorder";
import {
  type Arranged,
  type ArrangementTrack,
  type AutomatedParameter,
  type Clipping,
  type Mix,
  mixAt,
  type TimeSelection,
} from "@/lib/arrangement";

const BARS = 32;
/** The arrangement is laid out at the first tempo; a tempo change keeps its musical positions. */
const BAR = secondsPerBar(DEFAULT_TEMPO);
/** The width of a track header, which the lanes start after. */
const LEAD_PX = 160;
const MAX_ZOOM = 240;
/** How close an adaptive grid's lines may be, in px: Cmd/Ctrl + 1 and 2 halve and double it. */
const DENSITIES = [6, 12, 24, 48, 96] as const;
/** Cmd/Ctrl + a digit changes the grid, read from the key's place so it works on any keyboard layout. */
const GRID_KEYS: Record<
  string,
  "finer" | "fixed" | "snap" | "triplet" | "wider"
> = {
  Digit1: "finer",
  Digit2: "wider",
  Digit3: "triplet",
  Digit4: "snap",
  Digit5: "fixed",
};

/** The width of the track headers, which the lanes start after. */
function leadWidth(viewport: HTMLElement) {
  return (
    viewport.querySelector<HTMLElement>('[data-slot="timeline-track-header"]')
      ?.offsetWidth ?? LEAD_PX
  );
}

/** Whether a key went to a field you type in, where it is the field's own. */
function typing(target: EventTarget) {
  return (
    (target instanceof HTMLInputElement && target.type !== "range") ||
    target instanceof HTMLTextAreaElement ||
    (target instanceof HTMLElement && target.isContentEditable)
  );
}
/** The zoom presets, as shares of the fitted view: Fit shows everything. */
const ZOOM_PRESETS = [1, 2, 4, 8];
/** The zoom's steps are loupes, not a minus and a plus. */
const ZOOM_STEPPERS = {
  decrement: "Zoom out",
  icons: {
    decrement: (
      <IconPlaceholder
        hugeicons="ZoomOutAreaIcon"
        lucide="ZoomOutIcon"
        phosphor="MagnifyingGlassMinusIcon"
        remixicon="RiZoomOutLine"
        tabler="IconZoomOut"
      />
    ),
    increment: (
      <IconPlaceholder
        hugeicons="ZoomInAreaIcon"
        lucide="ZoomInIcon"
        phosphor="MagnifyingGlassPlusIcon"
        remixicon="RiZoomInLine"
        tabler="IconZoomIn"
      />
    ),
  },
  increment: "Zoom in",
};

/** How the zoom is shown: as a share of the fitted view. */
const PERCENT: Intl.NumberFormatOptions = {
  maximumFractionDigits: 0,
  style: "percent",
};

const bars = (count: number) => count * BAR;

/** The named points the timeline starts with. */
const LOCATORS = [
  { at: bars(4), name: "Verse" },
  { at: bars(20), name: "Chorus" },
];

/** The loop it starts with: bars 8 to 16, switched on. */
const LOOP = { on: true, range: [bars(8), bars(16)] } as const;

const TRACKS: ArrangementTrack[] = [
  { color: 0, id: "drums", name: "Drums" },
  { color: 1, id: "bass", kind: "midi", name: "Bass" },
  { color: 2, id: "keys", kind: "midi", name: "Keys" },
  { color: 3, id: "vocals", name: "Vocals" },
] as const;

/** What a track meter shows while nothing plays: one channel. */
const SILENCE_MONO = {
  getSnapshot: () => ({ levels: [-60], peaks: [-60] }),
  subscribe: () => () => undefined,
};

/** What the master meter shows while nothing plays. */
const SILENCE = {
  getSnapshot: () => ({ levels: [-60, -60], peaks: [-60, -60] }),
  subscribe: () => () => undefined,
};

const arranged = (
  id: string,
  name: string,
  track: string,
  from: number,
  to: number
): Arranged => ({
  id,
  name,
  sourceLength: bars(to - from + 2),
  track,
  value: {
    fadeIn: 0,
    fadeOut: 0,
    length: bars(to - from),
    offset: 0,
    start: bars(from),
  },
});

const BEAT = BAR / 4;

/** Notes from `[pitch, beat, beats]`: where each starts and how long it lasts, in beats of the first tempo. */
const notesOf = (pattern: readonly (readonly [number, number, number])[]) =>
  pattern.map(([pitch, at, beats], index) => ({
    id: `n${index}`,
    length: beats * BEAT,
    pitch,
    start: at * BEAT,
    velocity: 0.8,
  }));

/** Two bars of bass: A, then F, on the beat and the off-beats around it. */
const BASSLINE = notesOf([
  [33, 0, 0.75],
  [33, 1.5, 0.5],
  [45, 2, 0.5],
  [33, 3, 0.75],
  [29, 4, 0.75],
  [29, 5.5, 0.5],
  [41, 6, 0.5],
  [31, 7, 0.75],
]);

/** Four bars of chords, one a bar: A minor, F, C, G. */
const CHORDS = notesOf(
  [
    [57, 60, 64],
    [53, 57, 60],
    [55, 60, 64],
    [55, 59, 62],
  ].flatMap((chord, bar) =>
    chord.map((pitch) => [pitch, bar * 4, 3.75] as const)
  )
);

/** A MIDI clip that loops `notes` over `loopBars`, from bar `from` to bar `to`. */
const midiArranged = (
  id: string,
  name: string,
  track: string,
  from: number,
  to: number,
  loopBars: number,
  notes: Arranged["notes"]
): Arranged => ({
  id,
  loop: [0, bars(loopBars)],
  name,
  notes,
  sourceLength: bars(loopBars),
  track,
  value: {
    fadeIn: 0,
    fadeOut: 0,
    length: bars(to - from),
    offset: 0,
    start: bars(from),
  },
});

const CLIPS: Arranged[] = [
  arranged("d1", "Intro", "drums", 0, 4),
  arranged("d2", "Groove", "drums", 4, 16),
  arranged("d3", "Groove", "drums", 16, 28),
  arranged("d4", "Fill", "drums", 28, 32),
  midiArranged("b1", "Sub", "bass", 4, 12, 2, BASSLINE),
  midiArranged("b2", "Sub", "bass", 16, 28, 2, BASSLINE),
  midiArranged("k1", "Pad", "keys", 0, 8, 4, CHORDS),
  midiArranged("k2", "Chords", "keys", 8, 16, 4, CHORDS),
  midiArranged("k3", "Pad", "keys", 20, 32, 4, CHORDS),
  arranged("v1", "Verse", "vocals", 8, 16),
  arranged("v2", "Hook", "vocals", 16, 24),
];

function Row({
  children,
  className,
  height,
  lead,
  onResize,
  selected,
  trackId,
}: {
  children: ReactNode;
  className?: string;
  /** A height set by hand: the row keeps it instead of sharing what is left. */
  height?: number;
  lead?: ReactNode;
  onResize?: (trackId: string, height: number | undefined) => void;
  selected?: boolean;
  trackId?: string;
}) {
  const handleHeight = useCallback(
    (next: number | undefined) => {
      if (trackId !== undefined) {
        onResize?.(trackId, next);
      }
    },
    [onResize, trackId]
  );
  return (
    <TimelineTrack
      className={className}
      height={height}
      maxHeight={MAX_TRACK_HEIGHT}
      minHeight={MIN_TRACK_HEIGHT}
      onHeightChange={handleHeight}
      selected={selected}
      step={TRACK_HEIGHT_STEP}
    >
      <TimelineTrackHeader className="relative flex items-stretch">
        {lead}
      </TimelineTrackHeader>
      <div className="flex flex-1 flex-col">{children}</div>
    </TimelineTrack>
  );
}

/** Adaptive follows the zoom; the others stay put however far you zoom. */
const DIVISIONS: { label: string; value: GridDivision }[] = [
  { label: "Grid: Adaptive", value: "adaptive" },
  ...GRID_DIVISIONS.map((entry) => ({
    label:
      "bars" in entry
        ? `Grid: ${entry.bars} ${entry.bars === 1 ? "bar" : "bars"}`
        : `Grid: ${entry.id}`,
    value: entry.id as GridDivision,
  })),
];

/** What the keyboard does, for the shortcuts popover: the keys, then what they do. */
const SHORTCUTS: { action: string; keys: string[] }[] = [
  { action: "Split at the cursor", keys: ["S"] },
  { action: "Split at the cursor", keys: ["Cmd/Ctrl", "E"] },
  { action: "Duplicate the clip", keys: ["Cmd/Ctrl", "D"] },
  { action: "Remove the clip, or the tracks picked", keys: ["Delete"] },
  { action: "Add a track to the tracks picked", keys: ["Shift", "Click"] },
  { action: "Duplicate the tracks picked", keys: ["Cmd/Ctrl", "D"] },
  { action: "Add a locator at the cursor", keys: ["M"] },
  { action: "Loop the selection", keys: ["Cmd/Ctrl", "L"] },
  { action: "Rename a locator", keys: ["Enter"] },
  {
    action: "Jump to the previous or next locator",
    keys: ["Alt", "Left", "Right"],
  },
  { action: "Nudge a clip or an edge", keys: ["Left", "Right"] },
  { action: "Nudge further", keys: ["Shift", "Left", "Right"] },
  { action: "Copy a clip while dragging", keys: ["Alt", "Drag"] },
  { action: "Move or trim off the grid", keys: ["Cmd/Ctrl", "Drag"] },
  { action: "Zoom around the pointer", keys: ["Cmd/Ctrl", "Scroll"] },
  { action: "Resize a track", keys: ["Up", "Down"] },
  { action: "Add a clip to the selection", keys: ["Shift", "Click"] },
  { action: "Undo", keys: ["Cmd/Ctrl", "Z"] },
  { action: "Redo", keys: ["Cmd/Ctrl", "Shift", "Z"] },
  { action: "Select every clip", keys: ["Cmd/Ctrl", "A"] },
  { action: "Show or hide the automation", keys: ["A"] },
  { action: "Select by dragging a rectangle", keys: ["Shift", "Drag"] },
  { action: "Clear the selection", keys: ["Esc"] },
];

/** What a piece of a clip adds to its id: the recording is the id before it. */
const LEADING_ID = /-.*$/;

/** How many peaks the clip panel draws of a source: enough to zoom well in before they turn to blocks. */
const PEAKS = 4096;

/**
 * A stand-in for the peaks of a clip's recording, which the block does not
 * have: a shape of its own for each recording (the clip's id before any piece
 * suffix), drawn over the stretch of it the clip's source holds.
 */
function standInPeaks(clip: Arranged): number[] {
  const seed = [...clip.id.replace(LEADING_ID, "")].reduce(
    (sum, char) => sum + char.charCodeAt(0),
    0
  );
  const from = clip.sourceFrom ?? 0;
  return Array.from({ length: PEAKS }, (_, index) => {
    const time = from + (index / (PEAKS - 1)) * clip.sourceLength;
    const swell = 0.55 + 0.3 * Math.sin(time * 0.9 + seed);
    const beat = 0.6 + 0.4 * Math.abs(Math.cos(time * Math.PI));
    const grain = Math.abs(
      (Math.sin((time + seed) * 12.9898) * 43_758.5453) % 1
    );
    return Math.min(1, swell * beat * (0.5 + 0.5 * grain) + 0.05);
  });
}

/** Closes the clip panel. */
function CloseClipView({ onClose }: { onClose: () => void }) {
  return (
    <Button
      aria-label="Close the clip panel"
      onClick={onClose}
      size="icon-sm"
      variant="ghost"
    >
      <IconPlaceholder
        aria-hidden="true"
        className="size-4"
        hugeicons="Cancel01Icon"
        lucide="XIcon"
        phosphor="XIcon"
        remixicon="RiCloseLine"
        tabler="IconX"
      />
    </Button>
  );
}

/** The clip panel stays in view at the bottom of the window while the tracks scroll past above it. */
const DOCKED = "sticky bottom-0 z-40 shadow-lg";

/** The clip panel under the tracks, for the clip being edited, or a word on how to pick one. */
function ClipDock({
  bar,
  beatsPerBar,
  clip,
  collapsed,
  format,
  midi,
  onActiveChange,
  onClose,
  onCollapsedChange,
  onCrop,
  onValueChange,
  onValueCommitted,
  peaks,
  foldedBoxes,
  onFoldedBoxesChange,
  onRename,
}: {
  bar: number;
  beatsPerBar: number;
  clip: Arranged | undefined;
  /** Whether the panel is collapsed to its title: held here, so it stays so from clip to clip. */
  collapsed: boolean;
  format: (seconds: number) => string;
  /** What a MIDI clip's panel edits and sounds. */
  midi: Pick<
    ComponentProps<typeof MidiClipPanel>,
    "onNoteOff" | "onNoteOn" | "onNotesChange" | "onRegionChange"
  >;
  onActiveChange: (active: boolean) => void;
  onClose: () => void;
  onCollapsedChange: (collapsed: boolean) => void;
  onCrop: () => void;
  onValueChange: (next: ClipSettings) => void;
  onValueCommitted: () => void;
  /** The ids of the boxes folded in the panel: held here, so they stay folded from clip to clip. */
  foldedBoxes: readonly string[];
  onFoldedBoxesChange: (folded: string[]) => void;
  onRename: (id: string, name: string) => void;
  peaks: number[];
}) {
  const rename = useCallback(
    (name: string) => clip && onRename(clip.id, name),
    [clip, onRename]
  );
  if (!clip) {
    return null;
  }
  if (clip.notes) {
    return (
      <MidiClipPanel
        active={!clip.disabled}
        bar={bar}
        beat={bar / beatsPerBar}
        className={DOCKED}
        collapsed={collapsed}
        foldedBoxes={foldedBoxes}
        key={clip.id}
        name={clip.name}
        notes={clip.notes}
        onActiveChange={onActiveChange}
        onCollapsedChange={onCollapsedChange}
        onFoldedBoxesChange={onFoldedBoxesChange}
        onNotesCommitted={onValueCommitted}
        onRename={rename}
        region={{
          length: clip.value.length,
          loop: clip.loop ?? null,
          offset: clip.value.offset,
          sourceLength: clip.sourceLength,
        }}
        {...midi}
      >
        <CloseClipView onClose={onClose} />
      </MidiClipPanel>
    );
  }
  return (
    <ClipPanel
      active={!clip.disabled}
      bar={bar}
      beatsPerBar={beatsPerBar}
      className={DOCKED}
      collapsed={collapsed}
      foldedBoxes={foldedBoxes}
      format={format}
      name={clip.name}
      onActiveChange={onActiveChange}
      onCollapsedChange={onCollapsedChange}
      onCrop={onCrop}
      onFoldedBoxesChange={onFoldedBoxesChange}
      onRename={rename}
      onValueChange={onValueChange}
      onValueCommitted={onValueCommitted}
      value={{
        gain: clip.gain ?? 0,
        length: clip.value.length,
        loop: clip.loop ?? null,
        offset: clip.value.offset,
        reversed: Boolean(clip.reversed),
        sourceLength: clip.sourceLength,
        transpose: clip.transpose ?? 0,
        warp: clip.warp ?? null,
      }}
      values={peaks}
    >
      <CloseClipView onClose={onClose} />
    </ClipPanel>
  );
}

/** A track's level, from a stand-in source: the block plays nothing, so it moves while the track would be heard. */
function TrackLevel({ active, name }: { active: boolean; name: string }) {
  const simulated = useSimulatedLevels(1);
  return (
    <Meter
      aria-label={`${name} level`}
      className="data-[orientation=vertical]:h-full [--meter-thickness:--spacing(2)]"
      orientation="vertical"
      peakMarks="hidden"
      size="sm"
      source={active ? simulated : SILENCE_MONO}
      warnThreshold={-12}
    />
  );
}

/**
 * An arrangement: a bar ruler that seeks on the grid, a loop strip, a lane per
 * track with clips to move between tracks, trim and fade, a cursor to put
 * anywhere on a track and split at, and a playhead following a transport
 * clock. Cmd/Ctrl + scroll zooms around the pointer, and the bar below scrolls
 * and zooms. Made to be opened at full size, where the tracks take the screen.
 */
export function BlockTimeline() {
  const [editingSignature, setEditingSignature] = useState<string | null>(null);
  // The arrangement reads the playhead, where a split cuts, and the playhead reads the arrangement's loop and length: a ref ties the two.
  const clockRef = useRef<{ getSnapshot: () => number } | null>(null);
  const getPosition = useCallback(
    () => clockRef.current?.getSnapshot() ?? 0,
    []
  );
  const {
    addClip,
    dispatch,
    duration,
    locators,
    loop: { on: looping, range: loop },
    signatures,
    tempo,
    addTrack,
    canRedo,
    canUndo,
    changeAutomation,
    changeClip,
    changeMix,
    clearSelection,
    clearTimeSelection,
    clipAction,
    clips,
    commitClip,
    consolidateTime,
    copying,
    copyOf,
    crop,
    editClip,
    fadeTimeSelection,
    duplicateSelection,
    duplicateTrack,
    folded,
    heights,
    insertTimeSpan,
    paste,
    toggleFold,
    toggleSelected,
    mixOf,
    redo,
    moveTrack,
    removeClip,
    removeTimeSpan,
    removeTrack,
    renameClip,
    renameTrack,
    setFadeShape,
    resizeTrack,
    selectAll,
    selectClip,
    selectClips,
    selectedIds,
    selectedTracks,
    selection,
    selectTrack,
    settleClipEdit,
    splitAtCursor,
    tracks,
    undo,
  } = useArrangement({
    colors: TRACK_COLORS.length,
    duration: BARS * BAR,
    getPosition,
    initialClips: CLIPS,
    initialLocators: LOCATORS,
    initialLoop: LOOP,
    initialTracks: TRACKS,
    tempo: DEFAULT_TEMPO,
  });
  const bar = secondsPerBar(tempo);
  const describe = useCallback(
    (seconds: number) => {
      const { bar: number, beat } = barsBeatsWith(seconds, tempo, signatures);
      return `Bar ${number}, beat ${beat}`;
    },
    [signatures, tempo]
  );
  // A stretch of time on some tracks, drawn by dragging across bare lanes, and what was last copied.
  const [timeSelection, setTimeSelection] = useState<TimeSelection | null>(
    null
  );
  const clipboard = useRef<Clipping | null>(null);
  // The track a press on bare lane began on: a time selection spans from it to the track under the pointer.
  const pressTrackRef = useRef<string | null>(null);
  const [metronome, setMetronome] = useState(false);
  const [rhythm, setRhythm] = useState<MetronomeRhythm>("auto");
  const { clock, playing, setPlaying } = useSimulatedClock({
    duration,
    loop,
    looping,
  });
  // The timeline fits the width it is given until it is zoomed.
  const viewportRef = useRef<HTMLDivElement>(null);
  const [fitPx, setFitPx] = useState(0);
  // Whether the bar has room for the zoom control; the menu lists the zoom steps only when it does not.
  const zoomSlotRef = useRef<HTMLDivElement>(null);
  const [zoomInBar, setZoomInBar] = useState(true);
  // Below the bar's room, the tempo and the undo and redo move into the more-controls menu.
  const narrowProbeRef = useRef<HTMLDivElement>(null);
  const [narrow, setNarrow] = useState(false);
  useLayoutEffect(() => {
    const element = viewportRef.current;
    if (!element) {
      return;
    }
    // The header column is narrower in a narrow panel: fit what is left beside it.
    const measure = () => {
      const lead =
        element.querySelector<HTMLElement>(
          '[data-slot="timeline-track-header"]'
        )?.offsetWidth ?? LEAD_PX;
      setFitPx(Math.max(0, (element.clientWidth - lead) / duration));
      if (zoomSlotRef.current) {
        setZoomInBar(getComputedStyle(zoomSlotRef.current).display !== "none");
      }
      if (narrowProbeRef.current) {
        setNarrow(getComputedStyle(narrowProbeRef.current).display !== "none");
      }
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [duration]);
  useLayoutEffect(() => {
    clockRef.current = clock;
  }, [clock]);
  const [zoomed, setZoom] = useState<number | null>(null);
  const zoom = Math.max(zoomed ?? fitPx, fitPx) || 8;
  // A change of tempo, or the undo of one, changes the bar: the playhead and the zoom keep their places in it.
  const lastBar = useRef(bar);
  useLayoutEffect(() => {
    if (lastBar.current === bar) {
      return;
    }
    const ratio = bar / lastBar.current;
    lastBar.current = bar;
    clock.set(clock.getSnapshot() * ratio);
    setZoom((current) => (current === null ? null : current / ratio));
  }, [bar, clock]);
  const handleAddTrack = useCallback(() => addTrack(), [addTrack]);
  const handleAddMidiTrack = useCallback(
    () => addTrack(undefined, "midi"),
    [addTrack]
  );
  // Multi-select: a press on a clip adds it to the selection, and a drag on bare lane draws a rectangle. For touch, which has no Shift.
  const [multi, setMulti] = useState(false);
  // A touch screen has no Shift: in multi-select mode every press on a header adds to the tracks picked.
  const pickTrack = useCallback(
    (id: string, additive: boolean) => selectTrack(id, additive || multi),
    [multi, selectTrack]
  );
  const marquee = useMarquee(selectedIds, selectClips, multi);
  // A clip of a selection being dragged up or down, and how far: the rest of the selection follows it.
  const [lifting, setLifting] = useState<{ id: string; offset: number } | null>(
    null
  );
  const lift = useCallback(
    (id: string, offset: number) =>
      setLifting(offset === 0 ? null : { id, offset }),
    []
  );
  const [division, setDivision] = useState<GridDivision>("adaptive");
  const [density, setDensity] = useState<number>(24);
  const [triplet, setTriplet] = useState(false);
  const [snap, setSnap] = useState(true);
  // A stand-in for the mix's levels: the block plays nothing, so they move while it runs.
  const simulated = useSimulatedLevels(2);
  // What is heard: a muted track is silent, and while any track is soloed so is every other.
  const anySolo = tracks.some((track) => mixOf(track).soloed);
  const audible = useCallback(
    (trackId: string) => {
      const track = tracks.find((item) => item.id === trackId);
      if (!track) {
        return false;
      }
      const mix = mixOf(track);
      return !mix.muted && (!anySolo || mix.soloed);
    },
    [anySolo, mixOf, tracks]
  );
  // Automation mode: each track's lane shows the envelope of one of its parameters over its clips.
  const [automating, setAutomating] = useState(false);
  const [shownParameters, setShownParameters] = useState<
    Record<string, AutomatedParameter>
  >({});
  const showParameter = useCallback(
    (trackId: string, parameter: AutomatedParameter) =>
      setShownParameters((all) => ({ ...all, [trackId]: parameter })),
    []
  );
  // The automated parameters moved by hand: they play their mix until the automation is enabled again.
  const [overridden, setOverridden] = useState<
    Record<string, AutomatedParameter[]>
  >({});
  const anyOverridden = Object.values(overridden).some(
    (parameters) => parameters.length > 0
  );
  const reenableAutomation = useCallback(() => setOverridden({}), []);
  const toggleAutomating = useCallback(() => setAutomating((on) => !on), []);
  const changeTrackMix = useCallback(
    (trackId: string, patch: Partial<Mix>) => {
      const automation = tracks.find(
        (track) => track.id === trackId
      )?.automation;
      const moved = AUTOMATED_PARAMETERS.filter(
        (parameter) =>
          automation?.[parameter] &&
          (parameter === "volume" ? "gain" : "pan") in patch
      );
      if (moved.length > 0) {
        setOverridden((all) => ({
          ...all,
          [trackId]: [...new Set([...(all[trackId] ?? []), ...moved])],
        }));
      }
      changeMix(trackId, patch);
    },
    [changeMix, tracks]
  );
  // Drawing an envelope enables it again: what is drawn is what plays.
  const editAutomation = useCallback(
    (
      trackId: string,
      parameter: AutomatedParameter,
      points: readonly Breakpoint[]
    ) => {
      setOverridden((all) =>
        all[trackId]?.includes(parameter)
          ? {
              ...all,
              [trackId]: all[trackId].filter((item) => item !== parameter),
            }
          : all
      );
      changeAutomation(trackId, parameter, points);
    },
    [changeAutomation]
  );
  const clearAutomation = useCallback(
    (trackId: string, parameter: AutomatedParameter) => {
      changeAutomation(trackId, parameter, null);
      settleClipEdit();
    },
    [changeAutomation, settleClipEdit]
  );
  const levelsAt = useCallback<LevelsAt>(
    (trackId, time) => {
      const track = tracks.find((item) => item.id === trackId);
      return track
        ? mixAt(track, mixOf(track), time / duration, overridden[trackId])
        : { gain: 0, pan: 0 };
    },
    [duration, mixOf, overridden, tracks]
  );
  const midi = useMidiPlayback({
    audible,
    clips,
    clock,
    levelsAt,
    loop,
    looping,
    playing,
    tracks,
  });

  const playPause = useCallback(() => setPlaying((on) => !on), [setPlaying]);
  // Stop goes back to the top, as a DAW's does.
  const stop = useCallback(() => {
    setPlaying(false);
    clock.set(0);
  }, [clock, setPlaying]);
  // The zoom as a share of the fitted view: 100% shows everything.
  const zoomRatio = fitPx > 0 ? zoom / fitPx : 1;
  const maxRatio = fitPx > 0 ? MAX_ZOOM / fitPx : 1;
  const zoomPreset = ZOOM_PRESETS.find(
    (ratio) => Math.abs(ratio - zoomRatio) < 0.01
  );
  const changeZoom = useCallback(
    (ratio: number) => setZoom(ratio <= 1 ? null : ratio * fitPx),
    [fitPx]
  );
  // A track moves by dragging its grip, or with Up and Down on it.
  const trackOrder = useMemo(() => tracks.map((track) => track.id), [tracks]);
  const reorder = useTrackReorder({ onMove: moveTrack, order: trackOrder });
  const ghostTrack = tracks.find((track) => track.id === reorder.ghost?.id);
  const changeZoomPreset = useCallback(
    (value: string) => changeZoom(Number(value)),
    [changeZoom]
  );
  const handleDivisionValue = useCallback(
    (value: string) => setDivision(value as GridDivision),
    []
  );
  const seek = useCallback((seconds: number) => clock.set(seconds), [clock]);
  // A press on a track's empty lane puts the cursor there (the lane does it) and picks the track, letting go of the clips; on the surface under the last track it lets go of everything, as nothing is there.
  // The shortcuts are caught on the panel, so a press on the lane, which takes no focus, hands it over.
  const panelRef = useRef<HTMLDivElement>(null);
  // The closed clip panel comes back on a click of a clip's head, or a double-click on its body: not on a press, which may begin a move, a trim or a fade, and the panel takes room from the tracks under the pointer.
  const pressRef = useRef<{ x: number; y: number } | null>(null);
  const notePress = useCallback((event: PointerEvent<HTMLDivElement>) => {
    pressRef.current = { x: event.clientX, y: event.clientY };
  }, []);
  const showClipView = useCallback((event: MouseEvent<HTMLDivElement>) => {
    const press = pressRef.current;
    // A click is a press that let go where it began, give or take a hand's shake.
    const dragged =
      press !== null &&
      Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4;
    if (
      !dragged &&
      (event.target as HTMLElement).closest(
        '[data-slot="timeline-clip-header"]'
      )
    ) {
      setClipViewOpen(true);
    }
  }, []);
  const showClipViewOfBody = useCallback(
    (event: MouseEvent<HTMLDivElement>) => {
      const target = event.target as HTMLElement;
      // On the head a double-click renames the clip.
      if (
        target.closest("[data-clip-id]") &&
        !target.closest('[data-slot="timeline-clip-header"]')
      ) {
        setClipViewOpen(true);
      }
    },
    []
  );
  const focusPanel = useCallback(
    (event: PointerEvent<HTMLDivElement>) => {
      if (event.target === event.currentTarget && !event.shiftKey && !multi) {
        const { track } = event.currentTarget.dataset;
        pressTrackRef.current = track ?? null;
        setTimeSelection(null);
        if (track) {
          selectTrack(track);
        } else {
          clearSelection();
        }
        // `focusVisible` is missing from older DOM typings.
        panelRef.current?.focus({
          focusVisible: false,
          preventScroll: true,
        } as FocusOptions);
      }
    },
    [clearSelection, multi, selectTrack]
  );
  const addLocator = useCallback(
    () => dispatch({ type: "add-locator", at: clock.getSnapshot() }),
    [clock, dispatch]
  );
  const jumpToLocator = useCallback((at: number) => clock.set(at), [clock]);

  /** Puts the loop around the selected clip, or around the clips of the selected track. */
  const loopSelection = useCallback(() => {
    const chosen = clips.filter(
      (clip) =>
        selectedIds.includes(clip.id) || selectedTracks.includes(clip.track)
    );
    if (chosen.length === 0) {
      return;
    }
    dispatch({
      type: "set-loop",
      loop: {
        on: true,
        range: [
          Math.min(...chosen.map((clip) => clip.value.start)),
          Math.max(
            ...chosen.map((clip) => clip.value.start + clip.value.length)
          ),
        ],
      },
    });
  }, [clips, dispatch, selectedIds, selectedTracks]);

  /** M adds a locator, Alt + arrows jump to the next locator or clip edge; returns whether the key was one of these. */
  const handleLocatorKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (
        event.key.toLowerCase() === "m" &&
        !(event.metaKey || event.ctrlKey || event.altKey)
      ) {
        event.preventDefault();
        addLocator();
        return true;
      }
      const direction = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
      if (!(event.altKey && direction)) {
        return false;
      }
      // The locators, and the edges of the clips on the picked tracks (on every track when none is picked).
      const edges = clips
        .filter(
          (clip) =>
            selectedTracks.length === 0 || selectedTracks.includes(clip.track)
        )
        .flatMap((clip) => [
          clip.value.start,
          clip.value.start + clip.value.length,
        ]);
      const target = adjacentPoint(
        [...locators.map((locator) => locator.at), ...edges],
        clock.getSnapshot(),
        direction === 1 ? 1 : -1
      );
      if (target === null) {
        return false;
      }
      event.preventDefault();
      event.stopPropagation();
      clock.set(target);
      return true;
    },
    [addLocator, clips, clock, locators, selectedTracks]
  );
  // The copies are there to be seen: bring them into view once they are drawn.
  const duplicateAndShow = useCallback(() => {
    const copies = duplicateSelection();
    requestAnimationFrame(() => {
      document
        .querySelector(`[data-clip-id="${copies[0]?.id}"]`)
        ?.scrollIntoView({ block: "nearest", inline: "nearest" });
    });
  }, [duplicateSelection]);
  /** Cmd/Ctrl + A selects every clip, Cmd/Ctrl + D duplicates the selection; returns whether the key was one of these. */
  const handleSelectionKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const key = event.key.toLowerCase();
      if (!(event.metaKey || event.ctrlKey)) {
        return false;
      }
      if (key === "z" || key === "y") {
        event.preventDefault();
        if (key === "y" || event.shiftKey) {
          redo();
        } else {
          undo();
        }
        return true;
      }
      if (key === "a") {
        event.preventDefault();
        selectAll();
        return true;
      }
      if (key === "d" && selectedIds.length > 0) {
        event.preventDefault();
        duplicateAndShow();
        return true;
      }
      if (key === "d" && selectedTracks.length > 0) {
        event.preventDefault();
        for (const id of selectedTracks) {
          duplicateTrack(id);
        }
        return true;
      }
      return false;
    },
    [
      duplicateAndShow,
      duplicateTrack,
      redo,
      selectAll,
      selectedIds,
      selectedTracks,
      undo,
    ]
  );
  /** Delete takes away the tracks picked by their headers (a clip handles its own Delete); returns whether the key was it. */
  const handleTrackKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      const del = event.key === "Delete" || event.key === "Backspace";
      if (!(del && selectedTracks.length > 0 && selectedIds.length === 0)) {
        return false;
      }
      event.preventDefault();
      removeTrack(selectedTracks[0] ?? "");
      return true;
    },
    [removeTrack, selectedIds.length, selectedTracks]
  );
  /** A drag across bare lanes selects time: from the track it began on to the track under the pointer. */
  const selectTime = useCallback(
    (
      range: readonly [number, number],
      details: TimelinePrimitive.Lane.SelectionChangeEventDetails
    ) => {
      const order = tracks.map((track) => track.id);
      const first = order.indexOf(pressTrackRef.current ?? "");
      if (first === -1) {
        return;
      }
      const under = document
        .elementFromPoint(details.event.clientX, details.event.clientY)
        ?.closest<HTMLElement>("[data-track]")?.dataset.track;
      const last = under ? order.indexOf(under) : -1;
      const [top, bottom] =
        last === -1
          ? [first, first]
          : [Math.min(first, last), Math.max(first, last)];
      setTimeSelection({ range, tracks: order.slice(top, bottom + 1) });
    },
    [tracks]
  );
  /** Picking a clip lets go of the time selection. */
  // The clip panel shows the clip picked, as a DAW's detail view follows the selection, for as long as it is open. It starts closed, and picking a clip does not open it: a click of a clip's head does.
  const [clipViewOpen, setClipViewOpen] = useState(false);
  // Collapsed to its title, the panel leaves the tracks the room, from clip to clip.
  const [clipViewCollapsed, setClipViewCollapsed] = useState(false);
  const [clipBoxesFolded, setClipBoxesFolded] = useState<string[]>([]);
  const pickClip = useCallback(
    (id: string, additive: boolean) => {
      setTimeSelection(null);
      selectClip(id, additive);
    },
    [selectClip]
  );
  // The clip panel, under the tracks: it edits the first selected clip.
  const editedClip = clips.find((clip) => clip.id === selectedIds[0]);
  const editedPeaks = useMemo(
    () => (editedClip ? standInPeaks(editedClip) : []),
    [editedClip]
  );
  const handleClipAction = useCallback(
    (action: ClipAction, id: string) => {
      if (action === "edit") {
        pickClip(id, false);
        setClipViewOpen(true);
      } else {
        clipAction(action, id);
      }
    },
    [clipAction, pickClip]
  );
  // A MIDI clip's notes, from the clip panel's piano roll: a drag is one undo step.
  const changeNotes = useCallback(
    (notes: NonNullable<Arranged["notes"]>) => {
      if (editedClip) {
        editClip(editedClip.id, (clip) => ({ ...clip, notes }));
      }
    },
    [editClip, editedClip]
  );
  // A double-click on bare MIDI lane makes an empty clip a bar long there, looping, and opens it.
  const midiClipCount = useRef(0);
  const addMidiClipAt = useCallback(
    (event: MouseEvent<HTMLDivElement>) => {
      const lane = event.currentTarget;
      const trackId = lane.dataset.track;
      if (
        !trackId ||
        (event.target as HTMLElement).closest(
          "[data-clip-id], [data-slot=automation-envelope]"
        )
      ) {
        return;
      }
      const rect = lane.getBoundingClientRect();
      const time = ((event.clientX - rect.left) / rect.width) * duration;
      const start = Math.max(
        0,
        Math.min(Math.floor(time / bar) * bar, duration - bar)
      );
      midiClipCount.current += 1;
      addClip({
        id: `midi-${midiClipCount.current}`,
        loop: [0, bar],
        name: "MIDI clip",
        notes: [],
        sourceLength: bar,
        track: trackId,
        value: { fadeIn: 0, fadeOut: 0, length: bar, offset: 0, start },
      });
      setTimeSelection(null);
      setClipViewOpen(true);
    },
    [addClip, bar, duration]
  );
  const changeClipSettings = useCallback(
    (next: ClipSettings) => {
      if (!editedClip) {
        return;
      }
      editClip(editedClip.id, (clip) => {
        // The clip keeps its place: what it plays changes, so it ends as early as the timeline needs.
        const length = Math.min(next.length, duration - clip.value.start);
        const fadeIn = Math.min(clip.value.fadeIn, length);
        return {
          ...clip,
          gain: next.gain,
          loop: next.loop ?? undefined,
          reversed: next.reversed,
          transpose: next.transpose,
          warp: next.warp ?? undefined,
          value: {
            ...clip.value,
            fadeIn,
            fadeOut: Math.min(clip.value.fadeOut, length - fadeIn),
            length,
            offset: next.offset,
          },
        };
      });
    },
    [duration, editClip, editedClip]
  );
  // Where a MIDI clip plays, from its clip panel: it keeps its place, and ends where the timeline does at most.
  const changeMidiRegion = useCallback(
    (region: MidiClipRegion) => {
      if (!editedClip) {
        return;
      }
      editClip(editedClip.id, (clip) => ({
        ...clip,
        loop: region.loop ?? undefined,
        value: {
          ...clip.value,
          length: Math.min(region.length, duration - clip.value.start),
          offset: region.offset,
        },
      }));
    },
    [duration, editClip, editedClip]
  );
  const midiEditing = useMemo(
    () => ({
      onNoteOff: midi.onNoteOff,
      onNoteOn: midi.onNoteOn,
      onNotesChange: changeNotes,
      onRegionChange: changeMidiRegion,
    }),
    [changeMidiRegion, changeNotes, midi.onNoteOff, midi.onNoteOn]
  );
  // The clip panel's switch: off, the clip stays on its track and is not heard.
  const setEditedActive = useCallback(
    (active: boolean) => {
      if (editedClip) {
        editClip(editedClip.id, (clip) => ({ ...clip, disabled: !active }));
        settleClipEdit();
      }
    },
    [editClip, editedClip, settleClipEdit]
  );
  const cropEdited = useCallback(() => {
    if (editedClip) {
      crop(editedClip.id);
    }
  }, [crop, editedClip]);
  const closeClipView = useCallback(() => setClipViewOpen(false), []);
  /** Where a paste lands: the picked track, the top of the time selection, the track of a picked clip, or the first track. */
  const pasteTrack = () =>
    selectedTracks[0] ??
    timeSelection?.tracks[0] ??
    clips.find((clip) => selectedIds.includes(clip.id))?.track ??
    tracks[0]?.id;
  /** Cmd/Ctrl + D: the time selection again right after itself, which the selection moves onto; with Shift, time is opened for it first. */
  const duplicateTime = (time: TimeSelection, opening: boolean) => {
    const copied = copyOf(time);
    const [from, to] = time.range;
    if (opening) {
      insertTimeSpan(to, to - from);
    }
    paste(copied, to, time.tracks[0] ?? "");
    setTimeSelection({ ...time, range: [to, to + (to - from)] });
  };
  /** Cmd/Ctrl + V: the last copy at the cursor; with Shift, time is opened for it first. */
  const pasteClipboard = (opening: boolean) => {
    const copied = clipboard.current;
    if (!copied) {
      return;
    }
    const at = clock.getSnapshot();
    if (opening) {
      insertTimeSpan(at, copied.length);
    }
    paste(copied, at, pasteTrack() ?? "");
  };
  /** Cmd/Ctrl + X: copies, then empties the time selection (takes the time out, with Shift), or removes the selected clips. */
  const cutSelection = (removing: boolean) => {
    clipboard.current = copyOf(timeSelection);
    if (timeSelection && removing) {
      removeTimeSpan(...timeSelection.range);
      setTimeSelection(null);
    } else if (timeSelection) {
      clearTimeSelection(timeSelection);
    } else if (selectedIds[0]) {
      removeClip(selectedIds[0]);
    }
  };
  /** What Cmd/Ctrl + a letter does to the time selection, the clipboard or the cursor, or undefined when it does nothing here. */
  const timeCommand = (key: string, shift: boolean) => {
    const length = timeSelection
      ? timeSelection.range[1] - timeSelection.range[0]
      : bar;
    const time = timeSelection;
    const commands: Record<string, (() => void) | undefined> = {
      c: () => {
        clipboard.current = copyOf(time);
      },
      d: time ? () => duplicateTime(time, shift) : undefined,
      i: () => insertTimeSpan(clock.getSnapshot(), length),
      j: time ? () => consolidateTime(time) : undefined,
      l: time
        ? () => {
            dispatch({
              type: "set-loop",
              loop: { on: true, range: [...time.range] },
            });
          }
        : undefined,
      v: clipboard.current ? () => pasteClipboard(shift) : undefined,
      x: () => cutSelection(shift),
    };
    return commands[key];
  };
  /** What Delete does: empties the time selection, or with Cmd/Ctrl + Shift takes the time out of every track. */
  const deleteCommand = (event: KeyboardEvent<HTMLDivElement>) => {
    const time = timeSelection;
    if (!time) {
      return;
    }
    if (event.metaKey || event.ctrlKey) {
      return event.shiftKey
        ? () => {
            removeTimeSpan(...time.range);
            setTimeSelection(null);
          }
        : undefined;
    }
    return () => clearTimeSelection(time);
  };
  /**
   * Cmd/Ctrl + Alt + 3 shows or hides the clip panel, and Cmd/Ctrl + Alt + F
   * makes fades from the time selection. Read from the key's place: Alt
   * changes the letter on a Mac. Returns whether the key was one of these.
   */
  const handleAltCommand = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!((event.metaKey || event.ctrlKey) && event.altKey)) {
      return false;
    }
    if (event.code === "Digit3") {
      setClipViewOpen((open) => !open);
    } else if (event.code === "KeyF" && timeSelection) {
      fadeTimeSelection(timeSelection);
    } else {
      return false;
    }
    event.preventDefault();
    return true;
  };
  /**
   * Cmd/Ctrl + C and X copy and cut the time selection (the selected clips
   * when there is none), V pastes at the cursor, I opens a stretch of silence
   * there; with a time selection, D duplicates it after itself, J makes one
   * clip per track of it and L loops it. With Shift too, X, V, D and Delete
   * work on the time itself, on every track. Delete empties the time
   * selection, and 0 switches the selected clips off or on. Cmd/Ctrl + Alt + F
   * fades the clips whose edges the time selection covers, crossfading two that
   * meet. Returns whether the key was one of these.
   */
  const handleTimeKey = (event: KeyboardEvent<HTMLDivElement>) => {
    // In a field, copying and deleting are the field's own.
    if (typing(event.target)) {
      return false;
    }
    if (handleAltCommand(event)) {
      return true;
    }
    if (event.altKey) {
      return false;
    }
    const key = event.key.toLowerCase();
    let command: (() => void) | undefined;
    if (event.key === "Delete" || event.key === "Backspace") {
      command = deleteCommand(event);
    } else if (event.metaKey || event.ctrlKey) {
      command = timeCommand(key, event.shiftKey);
    } else if (
      (key === "0" || event.code === "Digit0") &&
      selectedIds.length > 0
    ) {
      command = toggleSelected;
    }
    if (!command) {
      return false;
    }
    event.preventDefault();
    command();
    return true;
  };
  /** In automation mode, what a track shows: the parameter chooser in its header, and the envelope over its lane. */
  const automationOf = (track: ArrangementTrack) => {
    if (!automating) {
      return { chooser: null, envelope: null };
    }
    const parameter = shownParameters[track.id] ?? "volume";
    return {
      chooser: (
        <TrackAutomationChooser
          onClear={clearAutomation}
          onParameterChange={showParameter}
          parameter={parameter}
          track={track}
        />
      ),
      envelope: (
        <TrackAutomation
          describe={describe}
          duration={duration}
          mix={mixOf(track)}
          onChange={editAutomation}
          onCommit={settleClipEdit}
          overridden={overridden[track.id]}
          parameter={parameter}
          snapStep={snapStep}
          track={track}
        />
      ),
    };
  };
  /** A shows or hides the automation; returns whether the key was it. */
  const handleAutomationKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (
      event.key.toLowerCase() !== "a" ||
      event.metaKey ||
      event.ctrlKey ||
      event.altKey ||
      event.shiftKey ||
      typing(event.target)
    ) {
      return false;
    }
    event.preventDefault();
    toggleAutomating();
    return true;
  };
  // S, or Cmd/Ctrl + E, splits at the cursor, Cmd/Ctrl + D duplicates the clip; Escape drops the selection.
  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    if (event.key === "Escape") {
      clearSelection();
      setTimeSelection(null);
      return;
    }
    const key = event.key.toLowerCase();
    if (
      handleTimeKey(event) ||
      handleTrackKey(event) ||
      handleLocatorKey(event) ||
      handleAutomationKey(event)
    ) {
      return;
    }
    if (key === "l" && (event.metaKey || event.ctrlKey) && selection) {
      event.preventDefault();
      loopSelection();
      return;
    }
    if (handleSelectionKey(event)) {
      return;
    }
    const split =
      (key === "s" && !(event.metaKey || event.ctrlKey || event.altKey)) ||
      (key === "e" && (event.metaKey || event.ctrlKey));
    if (split && selection) {
      event.preventDefault();
      splitAtCursor();
    }
  };

  // The grid the lanes draw is the grid everything snaps to.
  // Snap off draws no lines and snaps nothing: a step of 0 is no grid.
  const gridSeconds = gridInterval(division, zoom, tempo, {
    minSpacing: density,
    triplet,
  });
  const snapStep = snap ? gridSeconds : 0;
  const labelEvery = barLabelInterval(zoom, tempo);
  // Whole bars, counted rather than divided, so no tempo leaves a label like 6.9999.
  const labelBars = Math.max(1, Math.round(labelEvery / bar));
  // The bars through the signature changes: the ruler numbers every few, and hatches the ones a change cuts short.
  const songBars = useMemo(
    () => barsOf(tempo, signatures, duration),
    [duration, signatures, tempo]
  );
  const labels = songBars.filter((_, index) => index % labelBars === 0);

  /** Adds a signature change at the cursor, in the signature already in force there, and opens it to be typed. */
  const addSignature = useCallback(() => {
    const at = clock.getSnapshot();
    const next = dispatch({ type: "add-signature", at });
    const added = next.doc.signatures.find((change) => change.at === at);
    if (added) {
      setEditingSignature(added.id);
    }
  }, [clock, dispatch]);
  // What the time strips report, as the commands that change the arrangement.
  const changeTempo = useCallback(
    (next: Tempo) => dispatch({ type: "set-tempo", tempo: next }),
    [dispatch]
  );
  const changeLooping = useCallback(
    (on: boolean) => dispatch({ type: "set-loop", loop: { on } }),
    [dispatch]
  );
  const changeLoop = useCallback(
    (range: readonly [number, number]) =>
      dispatch({ type: "change-loop", range }),
    [dispatch]
  );
  const removeLocator = useCallback(
    (at: number) => dispatch({ type: "remove-locator", at }),
    [dispatch]
  );
  const renameLocator = useCallback(
    (at: number, name: string) =>
      dispatch({ type: "rename-locator", at, name }),
    [dispatch]
  );
  const moveSignature = useCallback(
    (id: string, to: number) => dispatch({ type: "move-signature", id, to }),
    [dispatch]
  );
  const removeSignature = useCallback(
    (id: string) => dispatch({ type: "remove-signature", id }),
    [dispatch]
  );
  const setSignature = useCallback(
    (id: string, beatsPerBar: number, beatUnit: number) =>
      dispatch({ type: "set-signature", id, beatsPerBar, beatUnit }),
    [dispatch]
  );

  // A dragged clip or loop is pulled onto the edges of the other clips, the locators and the loop.
  const snapPoints = useMemo(() => {
    if (!snap) {
      return;
    }
    const points = new Set<number>(loop);
    for (const clip of clips) {
      points.add(clip.value.start);
      points.add(clip.value.start + clip.value.length);
    }
    for (const locator of locators) {
      points.add(locator.at);
    }
    return [...points];
  }, [clips, locators, loop, snap]);

  /** A finer or wider grid: a denser or sparser adaptive grid, or the next fixed division. */
  const stepGrid = useCallback(
    (finer: boolean) => {
      if (division !== "adaptive") {
        setDivision(adjacentDivision(division, finer ? 1 : -1));
        return;
      }
      // Denser lines are closer together: finer halves how close they may be.
      const index = DENSITIES.indexOf(density as (typeof DENSITIES)[number]);
      const next = DENSITIES[index + (finer ? -1 : 1)];
      if (next !== undefined) {
        setDensity(next);
      }
    },
    [density, division]
  );
  /** Cmd/Ctrl + 1 to 5: a finer or wider grid, triplets, snapping, and a grid fixed where it is or following the zoom again. */
  const changeGrid = useCallback(
    (change: (typeof GRID_KEYS)[string]) => {
      if (change === "triplet") {
        setTriplet((on) => !on);
      } else if (change === "snap") {
        setSnap((on) => !on);
      } else if (change === "fixed") {
        setDivision(
          division === "adaptive"
            ? divisionAt(gridSeconds / (triplet ? TRIPLET_FACTOR : 1), tempo)
            : "adaptive"
        );
      } else {
        stepGrid(change === "finer");
      }
    },
    [division, gridSeconds, stepGrid, tempo, triplet]
  );

  // Z zooms onto the selection, + and - zoom around the cursor, X steps back through the zooms they left behind.
  const zoomHistory = useRef<{ scroll: number; zoom: number | null }[]>([]);
  /** Zooms to `next` px per second, with `value` seconds `offset` px from the viewport's left edge. */
  const zoomOnto = useCallback(
    (next: number, value: number, offset: number) => {
      const element = viewportRef.current;
      if (!element) {
        return;
      }
      zoomHistory.current.push({ scroll: element.scrollLeft, zoom: zoomed });
      const clamped = Math.min(MAX_ZOOM, Math.max(fitPx, next));
      setZoom(clamped <= fitPx ? null : clamped);
      // After the timeline has drawn the new zoom, which keeps its middle still.
      requestAnimationFrame(() => {
        element.scrollLeft = leadWidth(element) + value * clamped - offset;
      });
    },
    [fitPx, zoomed]
  );
  const zoomAroundCursor = useCallback(
    (factor: number) => {
      const element = viewportRef.current;
      if (!element) {
        return;
      }
      const lead = leadWidth(element);
      const at = clock.getSnapshot();
      const x = lead + at * zoom - element.scrollLeft;
      // A cursor out of view gives way to the middle of the view.
      const offset =
        x >= lead && x <= element.clientWidth
          ? x
          : (lead + element.clientWidth) / 2;
      const value =
        x >= lead && x <= element.clientWidth
          ? at
          : (element.scrollLeft + offset - lead) / zoom;
      zoomOnto(zoom * factor, value, offset);
    },
    [clock, zoom, zoomOnto]
  );
  const zoomToSelection = useCallback(() => {
    const element = viewportRef.current;
    const chosen = clips.filter(
      (clip) =>
        selectedIds.includes(clip.id) || selectedTracks.includes(clip.track)
    );
    const [start, end] =
      chosen.length > 0
        ? [
            Math.min(...chosen.map((clip) => clip.value.start)),
            Math.max(
              ...chosen.map((clip) => clip.value.start + clip.value.length)
            ),
          ]
        : loop;
    if (!element || end <= start) {
      return;
    }
    const lead = leadWidth(element);
    zoomOnto((element.clientWidth - lead) / (end - start), start, lead);
  }, [clips, loop, selectedIds, selectedTracks, zoomOnto]);
  const zoomBack = useCallback(() => {
    const element = viewportRef.current;
    const previous = zoomHistory.current.pop();
    if (!(element && previous)) {
      return;
    }
    setZoom(previous.zoom);
    requestAnimationFrame(() => {
      element.scrollLeft = previous.scroll;
    });
  }, []);

  /** Up and Down pick the track above or below the one picked, or the first or last. */
  const pickNextTrack = useCallback(
    (direction: 1 | -1) => {
      const current =
        selectedTracks[0] ??
        clips.find((clip) => selectedIds.includes(clip.id))?.track;
      const index = tracks.findIndex((track) => track.id === current);
      const next =
        index === -1
          ? tracks.at(direction === 1 ? 0 : -1)
          : tracks[Math.min(tracks.length - 1, Math.max(0, index + direction))];
      if (next) {
        selectTrack(next.id);
      }
    },
    [clips, selectTrack, selectedIds, selectedTracks, tracks]
  );

  /** On the panel itself: arrows move the cursor on the grid, Home and End go to the ends of the arrangement. */
  const handleCursorKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) {
        return false;
      }
      const at = clock.getSnapshot();
      const lastEnd = Math.max(
        0,
        ...clips.map((clip) => clip.value.start + clip.value.length)
      );
      const step = gridSeconds;
      const targets: Record<string, number> = {
        ArrowLeft: snapMove(at - step, at, { offset: 0, step }),
        ArrowRight: snapMove(at + step, at, { offset: 0, step }),
        End: lastEnd,
        Home: 0,
      };
      const target = targets[event.key];
      if (target !== undefined) {
        event.preventDefault();
        clock.set(Math.min(duration, Math.max(0, target)));
        return true;
      }
      if (event.key === "ArrowUp" || event.key === "ArrowDown") {
        event.preventDefault();
        pickNextTrack(event.key === "ArrowDown" ? 1 : -1);
        return true;
      }
      return false;
    },
    [clips, clock, duration, gridSeconds, pickNextTrack]
  );

  /** The navigation keys: the grid with Cmd/Ctrl + 1 to 5, the zoom with +, -, Z and X, and the cursor from the panel. */
  const handleNavigationKey = useCallback(
    (event: KeyboardEvent<HTMLDivElement>) => {
      if (event.metaKey || event.ctrlKey) {
        const change = GRID_KEYS[event.code];
        if (!(change && !event.altKey && !event.shiftKey)) {
          return false;
        }
        event.preventDefault();
        changeGrid(change);
        return true;
      }
      if (typing(event.target) || event.altKey) {
        return false;
      }
      const key = event.key.toLowerCase();
      const zoomKeys: Record<string, () => void> = {
        "+": () => zoomAroundCursor(2),
        "-": () => zoomAroundCursor(0.5),
        "=": () => zoomAroundCursor(2),
        x: zoomBack,
        z: zoomToSelection,
      };
      const zoomKey = event.shiftKey && key !== "+" ? undefined : zoomKeys[key];
      if (zoomKey) {
        event.preventDefault();
        zoomKey();
        return true;
      }
      // Anywhere else in the panel, a clip's or the loop's own input takes its arrows.
      return event.target === event.currentTarget && handleCursorKey(event);
    },
    [changeGrid, handleCursorKey, zoomAroundCursor, zoomBack, zoomToSelection]
  );
  const handlePanelKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!handleNavigationKey(event)) {
      handleKeyDown(event);
    }
  };

  return (
    // biome-ignore lint/a11y/noStaticElementInteractions: the editor's shortcuts, caught from the controls inside it
    <div
      className="rounded-xl @container/panel flex w-full min-w-0 flex-col gap-4 border bg-card p-4 outline-none"
      onKeyDown={handlePanelKey}
      ref={panelRef}
      tabIndex={-1}
    >
      <div className="flex items-center justify-between gap-2 p-1">
        <div className="flex min-w-0 flex-1 items-center gap-2">
          <div className="@3xl/panel:hidden" ref={narrowProbeRef} />
          <Transport
            className="min-w-0 flex-1 flex-nowrap"
            looping={looping}
            metronome={metronome}
            onLoopingChange={changeLooping}
            onMetronomeChange={setMetronome}
            onPlayPause={playPause}
            onRhythmChange={setRhythm}
            onStop={stop}
            onTempoChange={changeTempo}
            playing={playing}
            rhythm={rhythm}
            signatures={signatures}
            source={clock}
            tempo={tempo}
          />
          <div className="hidden h-7 w-10 shrink-0 items-center @3xl/panel:flex">
            <Meter
              aria-label="Master output level"
              className="data-[orientation=horizontal]:[--meter-thickness:--spacing(3)]"
              orientation="horizontal"
              peakMarks="hidden"
              size="sm"
              source={playing ? simulated : SILENCE}
              warnThreshold={-12}
            />
          </div>
        </div>
        <div className="flex min-w-0 items-center gap-2">
          {anyOverridden ? (
            <Button
              aria-label="Re-enable automation"
              className="text-primary"
              onClick={reenableAutomation}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="RotateLeft01Icon"
                lucide="RotateCcwIcon"
                phosphor="ArrowCounterClockwiseIcon"
                remixicon="RiArrowGoBackLine"
                tabler="IconRotate"
              />
            </Button>
          ) : null}
          <Button
            aria-label="Automation mode"
            aria-pressed={automating}
            className="hidden @3xl/panel:inline-flex"
            onClick={toggleAutomating}
            size="icon"
            variant={automating ? "default" : "outline"}
          >
            {/* An envelope, drawn as the selects draw their shapes: what automation draws over a lane. */}
            <svg
              aria-hidden="true"
              className="size-4"
              fill="none"
              stroke="currentColor"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              viewBox="0 0 24 24"
            >
              <path d="M2 17h4l5-10h4c3 0 4 10 7 10" />
            </svg>
          </Button>
          <ButtonGroup className="hidden @3xl/panel:flex">
            <Button
              aria-label="Undo"
              disabled={!canUndo}
              onClick={undo}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="Undo02Icon"
                lucide="Undo2Icon"
                phosphor="ArrowUUpLeftIcon"
                remixicon="RiArrowGoBackLine"
                tabler="IconArrowBackUp"
              />
            </Button>
            <Button
              aria-label="Redo"
              disabled={!canRedo}
              onClick={redo}
              size="icon"
              variant="outline"
            >
              <IconPlaceholder
                hugeicons="Redo02Icon"
                lucide="Redo2Icon"
                phosphor="ArrowUUpRightIcon"
                remixicon="RiArrowGoForwardLine"
                tabler="IconArrowForwardUp"
              />
            </Button>
          </ButtonGroup>
          <div
            className="hidden items-center gap-2 @7xl/panel:flex"
            ref={zoomSlotRef}
          >
            <NumberControl
              format={PERCENT}
              label="Zoom"
              largeStep={1}
              max={maxRatio}
              min={1}
              onValueChange={changeZoom}
              step={0.25}
              steppers={ZOOM_STEPPERS}
              unit="Zoom"
              value={zoomRatio}
            />
          </div>
          <DropdownMenu>
            <DropdownMenuTrigger
              render={
                <Button
                  aria-label="More controls"
                  size="icon"
                  variant="outline"
                />
              }
            >
              <IconPlaceholder
                hugeicons="MoreHorizontalIcon"
                lucide="EllipsisIcon"
                phosphor="DotsThreeIcon"
                remixicon="RiMoreLine"
                tabler="IconDots"
              />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="w-auto min-w-48">
              {narrow ? (
                <>
                  <DropdownMenuItem disabled={!canUndo} onClick={undo}>
                    Undo
                  </DropdownMenuItem>
                  <DropdownMenuItem disabled={!canRedo} onClick={redo}>
                    Redo
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                </>
              ) : null}
              <DropdownMenuCheckboxItem
                checked={multi}
                onCheckedChange={setMulti}
              >
                Multi-select
              </DropdownMenuCheckboxItem>
              <DropdownMenuCheckboxItem
                checked={automating}
                onCheckedChange={setAutomating}
              >
                Automation
              </DropdownMenuCheckboxItem>
              <DropdownMenuSeparator />
              <DropdownMenuSub>
                <DropdownMenuSubTrigger>
                  Keyboard shortcuts
                </DropdownMenuSubTrigger>
                <DropdownMenuSubContent className="flex max-h-(--available-height) w-auto flex-col">
                  <div className="scrollbar flex min-h-0 flex-col gap-2 overflow-y-auto p-2 text-sm whitespace-nowrap">
                    {SHORTCUTS.map((shortcut) => (
                      <div
                        className="flex items-center justify-between gap-3"
                        key={`${shortcut.action}-${shortcut.keys.join("+")}`}
                      >
                        <span>{shortcut.action}</span>
                        <KbdGroup>
                          {shortcut.keys.map((key) => (
                            <Kbd key={key}>{key}</Kbd>
                          ))}
                        </KbdGroup>
                      </div>
                    ))}
                  </div>
                </DropdownMenuSubContent>
              </DropdownMenuSub>
              {zoomInBar ? null : (
                <>
                  <DropdownMenuSeparator />
                  <DropdownMenuSub>
                    <DropdownMenuSubTrigger>Zoom</DropdownMenuSubTrigger>
                    <DropdownMenuSubContent className="w-auto">
                      <div className="p-2">
                        <NumberControl
                          format={PERCENT}
                          label="Zoom"
                          largeStep={1}
                          max={maxRatio}
                          min={1}
                          onValueChange={changeZoom}
                          step={0.25}
                          steppers={ZOOM_STEPPERS}
                          unit="Zoom"
                          value={zoomRatio}
                        />
                      </div>
                      <DropdownMenuSeparator />
                      <DropdownMenuRadioGroup
                        onValueChange={changeZoomPreset}
                        value={
                          zoomPreset === undefined ? "" : String(zoomPreset)
                        }
                      >
                        {ZOOM_PRESETS.map((ratio) => (
                          <DropdownMenuRadioItem
                            disabled={ratio > maxRatio}
                            key={ratio}
                            value={String(ratio)}
                          >
                            {ratio === 1 ? "Fit" : `${ratio * 100} %`}
                          </DropdownMenuRadioItem>
                        ))}
                      </DropdownMenuRadioGroup>
                    </DropdownMenuSubContent>
                  </DropdownMenuSub>
                </>
              )}
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </div>
      <Timeline
        className="min-h-0 flex-1 gap-2"
        duration={duration}
        maxZoom={MAX_ZOOM}
        minZoom={fitPx || undefined}
        onPositionChange={seek}
        onZoomChange={setZoom}
        snapPoints={snapPoints}
        snapStep={snapStep}
        source={clock}
        zoom={zoomed === null ? undefined : zoom}
      >
        <TimelineNavigator getAriaValueText={describe} />
        <TimelineViewport
          className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-muted/40"
          ref={viewportRef}
          scrollbar="hidden"
        >
          {/* As wide as the lanes, so the stuck headers stay put however far it scrolls. */}
          <div
            className={cn(
              "flex min-h-full w-max min-w-full shrink-0 flex-col",
              marquee.rect && "select-none"
            )}
            {...marquee.handlers}
          >
            {/* One sticky block, so what is stuck stays stacked whatever each row measures. */}
            <div className="sticky top-0 z-40 flex min-w-full shrink-0 flex-col bg-background">
              <Row
                className="shrink-0 border-foreground/25 border-b"
                lead={
                  <span className="flex h-full items-center px-3 text-xs text-muted-foreground">
                    Loop
                  </span>
                }
              >
                <TimelineLane className="h-6 cursor-cell border-b-0">
                  <TimelineRange
                    aria-label="Loop"
                    drawable
                    getAriaValueText={describe}
                    onValueChange={changeLoop}
                    onValueCommitted={settleClipEdit}
                    value={loop}
                  >
                    <TimelineRangeHandle aria-label="Loop start" edge="start" />
                    <TimelineRangeHandle aria-label="Loop end" edge="end" />
                  </TimelineRange>
                  <TimelinePlayhead />
                </TimelineLane>
              </Row>
              <Row
                className="shrink-0 border-foreground/25 border-b bg-background"
                lead={
                  <DropdownMenu>
                    <DropdownMenuTrigger
                      render={
                        <Button
                          aria-label="Grid"
                          className="h-full w-full justify-start rounded-none border-0 tabular-nums"
                          variant="ghost"
                        />
                      }
                    >
                      {snap ? gridLabel(gridSeconds, tempo) : "Snap off"}
                    </DropdownMenuTrigger>
                    <DropdownMenuContent
                      align="start"
                      className="w-auto min-w-48"
                    >
                      <TimelineGridMenuItems
                        division={division}
                        divisions={DIVISIONS}
                        onDivisionChange={handleDivisionValue}
                        onSnapChange={setSnap}
                        onTripletChange={setTriplet}
                        snap={snap}
                        triplet={triplet}
                        variant="dropdown"
                      />
                    </DropdownMenuContent>
                  </DropdownMenu>
                }
              >
                <TimelineRuler
                  aria-label="Playhead"
                  className="border-b-0"
                  getAriaValueText={describe}
                >
                  {songBars
                    .filter((item) => item.partial)
                    .map((item) => (
                      <div
                        aria-hidden="true"
                        className="pointer-events-none absolute inset-y-0 bg-[repeating-linear-gradient(135deg,var(--muted-foreground)_0_1px,transparent_1px_6px)] opacity-30"
                        data-slot="timeline-partial-bar"
                        key={item.start}
                        style={{
                          left: `calc(var(--timeline-unit) * ${item.start})`,
                          width: `calc(var(--timeline-unit) * ${item.length})`,
                        }}
                      />
                    ))}
                  {labels.map((item) => (
                    <TimelineMark key={item.start} value={item.start}>
                      {item.number}
                    </TimelineMark>
                  ))}
                  <TimelinePlayhead />
                </TimelineRuler>
              </Row>
              <Row
                className="shrink-0 border-foreground/25 border-b bg-background"
                lead={
                  <span className="flex h-full items-center px-3 text-xs text-muted-foreground">
                    Markers
                  </span>
                }
              >
                <TimelineLane className="h-6 border-b-0">
                  {locators.map((locator) => (
                    <TimelineMark key={locator.at} value={locator.at}>
                      <TimelineLocator
                        locator={locator}
                        onJump={jumpToLocator}
                        onRemove={removeLocator}
                        onRename={renameLocator}
                      />
                    </TimelineMark>
                  ))}
                </TimelineLane>
              </Row>
              <Row
                className="shrink-0 border-foreground/25 border-b bg-background"
                lead={
                  <div className="flex h-full items-center justify-between ps-3 pe-1 text-muted-foreground text-xs">
                    Signature
                    <Button
                      aria-label="Add a time signature at the cursor"
                      className="size-5"
                      onClick={addSignature}
                      size="icon"
                      title="Add a time signature at the cursor"
                      variant="ghost"
                    >
                      <IconPlaceholder
                        aria-hidden="true"
                        className="size-3"
                        hugeicons="PlusSignIcon"
                        lucide="PlusIcon"
                        phosphor="PlusIcon"
                        remixicon="RiAddLine"
                        tabler="IconPlus"
                      />
                    </Button>
                  </div>
                }
              >
                <TimelineLane className="h-6 border-b-0">
                  {signatures.map((change) => (
                    <TimelineMark key={change.id} value={change.at}>
                      <TimelineSignature
                        change={change}
                        editing={editingSignature === change.id}
                        onEditingChange={setEditingSignature}
                        onJump={jumpToLocator}
                        onMove={moveSignature}
                        onRemove={removeSignature}
                        onSet={setSignature}
                        step={snapStep}
                      />
                    </TimelineMark>
                  ))}
                </TimelineLane>
              </Row>
            </div>
            {tracks.map((track) => (
              <div
                aria-label={track.name}
                className="contents"
                data-track-id={track.id}
                key={track.id}
                role="group"
              >
                <Row
                  className={cn(
                    "border-foreground/25 border-b",
                    heights[track.id] === undefined && "flex-1",
                    // The track being moved stays in place, faded; the line marks the gap it would land in.
                    reorder.draggingId === track.id && "opacity-50",
                    reorder.drop?.id === track.id &&
                      reorder.drop.edge === "top" &&
                      "shadow-[inset_0_2px_0_0_var(--primary)]",
                    reorder.drop?.id === track.id &&
                      reorder.drop.edge === "bottom" &&
                      "shadow-[inset_0_-2px_0_0_var(--primary)]"
                  )}
                  height={heights[track.id]}
                  lead={
                    <div className="flex min-w-0 flex-1">
                      <button
                        aria-label={`Move ${track.name}`}
                        className={cn(
                          "absolute inset-y-0 inset-s-1 z-10 flex w-3 cursor-grab items-center justify-center text-muted-foreground/60 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:outline-none active:cursor-grabbing",
                          reorder.draggingId === track.id &&
                            "cursor-grabbing bg-muted text-foreground"
                        )}
                        {...reorder.gripProps(track.id)}
                        type="button"
                      >
                        <svg
                          aria-hidden="true"
                          className="size-3.5"
                          fill="currentColor"
                          focusable="false"
                          viewBox="0 0 16 16"
                        >
                          <circle cx="5.5" cy="3.5" r="1.25" />
                          <circle cx="10.5" cy="3.5" r="1.25" />
                          <circle cx="5.5" cy="8" r="1.25" />
                          <circle cx="10.5" cy="8" r="1.25" />
                          <circle cx="5.5" cy="12.5" r="1.25" />
                          <circle cx="10.5" cy="12.5" r="1.25" />
                        </svg>
                      </button>
                      <TimelineTrackControls
                        compact={
                          heights[track.id] !== undefined &&
                          heights[track.id] < COMPACT_TRACK_HEIGHT
                        }
                        folded={folded[track.id]}
                        meter={
                          <TrackLevel
                            active={
                              playing &&
                              !mixOf(track).muted &&
                              (!anySolo || mixOf(track).soloed)
                            }
                            name={track.name}
                          />
                        }
                        mix={mixOf(track)}
                        name={track.name}
                        onDuplicate={duplicateTrack}
                        onInsert={addTrack}
                        onMix={changeTrackMix}
                        onMixCommit={settleClipEdit}
                        onRemove={removeTrack}
                        onRename={renameTrack}
                        onSelect={pickTrack}
                        onToggleFold={toggleFold}
                        selected={selectedTracks.includes(track.id)}
                        trackId={track.id}
                      >
                        {automationOf(track).chooser}
                      </TimelineTrackControls>
                    </div>
                  }
                  onResize={resizeTrack}
                  selected={selectedTracks.includes(track.id)}
                  trackId={track.id}
                >
                  <ContextMenu>
                    <ContextMenuTrigger render={<div className="contents" />}>
                      <TimelineLane
                        className={cn(
                          "h-auto flex-1 border-b-0",
                          heights[track.id] === undefined
                            ? "min-h-14"
                            : "min-h-0",
                          // A short track keeps its clips thin, so they fit.
                          heights[track.id] !== undefined &&
                            heights[track.id] < COMPACT_TRACK_HEIGHT &&
                            "**:data-clip-id:inset-y-0.5",
                          selectedTracks.includes(track.id) && "bg-muted/40",
                          // A silent track keeps its clips on the page, drained.
                          (mixOf(track).muted ||
                            (anySolo && !mixOf(track).soloed)) &&
                            "**:data-clip-id:opacity-40"
                        )}
                        data-track={track.id}
                        gridStep={snapStep}
                        onClick={showClipView}
                        onDoubleClick={
                          track.kind === "midi" ? addMidiClipAt : undefined
                        }
                        onDoubleClickCapture={showClipViewOfBody}
                        onPointerDown={focusPanel}
                        onPointerDownCapture={notePress}
                        onSelectionChange={selectTime}
                        seekable
                      >
                        {copying
                          .filter((original) => original.track === track.id)
                          .map((original) => (
                            <ArrangedClipGhost
                              clip={original}
                              key={original.id}
                            />
                          ))}
                        {clips
                          .filter((clip) => clip.track === track.id)
                          .map((clip) => (
                            <ArrangedClip
                              clip={clip}
                              copying={copying.some(
                                (original) => original.id === clip.id
                              )}
                              describe={describe}
                              key={clip.id}
                              lift={
                                lifting &&
                                lifting.id !== clip.id &&
                                selectedIds.includes(clip.id) &&
                                selectedIds.includes(lifting.id)
                                  ? lifting.offset
                                  : 0
                              }
                              multi={multi}
                              onAction={handleClipAction}
                              onChange={changeClip}
                              onCommit={commitClip}
                              onFadeShape={setFadeShape}
                              onLift={lift}
                              onRemove={removeClip}
                              onRename={renameClip}
                              onSelect={pickClip}
                              selected={selectedIds.includes(clip.id)}
                              swatch={mixOf(track).color}
                              trackName={track.name}
                            />
                          ))}
                        {timeSelection?.tracks.includes(track.id) ? (
                          <div
                            aria-hidden="true"
                            className="pointer-events-none absolute inset-y-0 z-10 bg-primary/15 ring-1 ring-primary/40 ring-inset"
                            data-slot="timeline-time-selection"
                            style={{
                              left: `calc(var(--timeline-unit) * ${timeSelection.range[0]})`,
                              width: `calc(var(--timeline-unit) * ${timeSelection.range[1] - timeSelection.range[0]})`,
                            }}
                          />
                        ) : null}
                        {automationOf(track).envelope}
                        <TimelinePlayhead />
                      </TimelineLane>
                    </ContextMenuTrigger>
                    <ContextMenuContent className="w-auto min-w-48">
                      <TimelineGridMenuItems
                        division={division}
                        divisions={DIVISIONS}
                        onDivisionChange={handleDivisionValue}
                        onSnapChange={setSnap}
                        onTripletChange={setTriplet}
                        snap={snap}
                        triplet={triplet}
                      />
                    </ContextMenuContent>
                  </ContextMenu>
                </Row>
              </div>
            ))}
            <Row
              className="sticky bottom-0 z-30 border-foreground/25 border-b bg-background"
              lead={
                <div className="m-1 flex min-w-0 flex-1 gap-1">
                  <Button
                    className="min-w-0 flex-1 justify-start gap-1.5"
                    onClick={handleAddTrack}
                    size="sm"
                    variant="ghost"
                  >
                    <IconPlaceholder
                      hugeicons="PlusSignIcon"
                      lucide="PlusIcon"
                      phosphor="PlusIcon"
                      remixicon="RiAddLine"
                      tabler="IconPlus"
                    />
                    Audio
                  </Button>
                  <Button
                    className="min-w-0 flex-1 justify-start gap-1.5"
                    onClick={handleAddMidiTrack}
                    size="sm"
                    variant="ghost"
                  >
                    <IconPlaceholder
                      hugeicons="PlusSignIcon"
                      lucide="PlusIcon"
                      phosphor="PlusIcon"
                      remixicon="RiAddLine"
                      tabler="IconPlus"
                    />
                    MIDI
                  </Button>
                </div>
              }
            >
              <TimelineLane
                className="h-10 border-b-0 bg-background"
                gridStep={snapStep}
              />
            </Row>
            {/* The surface carries on to the bottom of the view, so the arrangement does not stop at the last track. */}
            <Row className="flex-1" lead={<div className="h-full" />}>
              <TimelineLane
                className="h-auto flex-1 border-b-0 bg-background"
                gridStep={snapStep}
                onPointerDown={focusPanel}
                seekable
              >
                <TimelinePlayhead />
              </TimelineLane>
            </Row>
          </div>
        </TimelineViewport>
        {marquee.rect
          ? // In the page's body: a dialog is transformed, which would make a fixed rectangle its own.
            createPortal(
              <div
                aria-hidden="true"
                className="pointer-events-none fixed z-50 border border-primary bg-primary/10"
                style={marquee.rect}
              />,
              document.body
            )
          : null}
      </Timeline>
      {clipViewOpen ? (
        <ClipDock
          bar={bar}
          beatsPerBar={tempo.beatsPerBar}
          clip={editedClip}
          collapsed={clipViewCollapsed}
          foldedBoxes={clipBoxesFolded}
          format={describe}
          midi={midiEditing}
          onActiveChange={setEditedActive}
          onClose={closeClipView}
          onCollapsedChange={setClipViewCollapsed}
          onCrop={cropEdited}
          onFoldedBoxesChange={setClipBoxesFolded}
          onRename={renameClip}
          onValueChange={changeClipSettings}
          onValueCommitted={settleClipEdit}
          peaks={editedPeaks}
        />
      ) : null}
      {reorder.ghost && ghostTrack
        ? createPortal(
            <div
              aria-hidden="true"
              className={cn(
                "pointer-events-none fixed z-50 flex items-center border border-s-4 bg-card px-3 text-sm shadow-lg",
                TRACK_COLORS[mixOf(ghostTrack).color]?.edge
              )}
              ref={reorder.ghostRef}
              style={{
                height: reorder.ghost.height,
                left: reorder.ghost.left,
                top: reorder.ghost.top,
                width: reorder.ghost.width,
              }}
            >
              {ghostTrack.name}
            </div>,
            document.body
          )
        : null}
    </div>
  );
}
```

The base for an arrangement view, a clip editor or a piano roll. The registry component wraps the `Timeline` primitive from `@audio-ui/react`. The timeline has no notion of tempo: its units are whatever you count in (seconds, beats, pulses or steps). Musical time comes from the `musical-time` lib.

## Overview

- **Units and zoom:** `duration` is the length in your units, and `zoom` is the number of pixels per unit. Without `zoom`, the timeline fits its width. With it, every lane is as long as the timeline and the viewport scrolls.
- **Zooming:** Cmd/Ctrl + scroll, or a trackpad pinch, zooms around the pointer. Every zoom change keeps its anchor point in place, or the middle of the view when there is none.
- **Grid:** with a `snapStep`, the ruler snaps to the grid. Hold Cmd/Ctrl to place the playhead off the grid, and Shift to scrub four times finer.
- **Playback:** pass a live `source` and every playhead follows it in the DOM, without a React render per frame.
- **Ranges:** a `TimelineRange` is a stretch of the timeline, such as a loop, a selection or a clip. Drag it to move it, drag a handle to resize it, or, with `drawable`, drag across an empty lane to draw a new one. Ranges snap to the grid.
- **Navigator:** `TimelineNavigator` shows the whole timeline as one bar, with the visible part inside it. Drag the window to scroll, drag an edge to zoom, and double-click to show everything. With `scrollbar="hidden"` on the viewport, it replaces the scrollbar.
- **Accessibility:** the ruler, each range and range handle, and the navigator's window and edges hold a native `<input type="range">`, so every pointer action has a keyboard equivalent. The ruler's input spans the timeline: it takes focus, carries `aria-label` and announces the position (or the text from `getAriaValueText`, such as "Bar 3, beat 2"). The arrow keys move by `step`, <kbd>Page Up</kbd> / <kbd>Page Down</kbd> and Shift by `largeStep`, and <kbd>Home</kbd> / <kbd>End</kbd> go to the ends.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/timeline
npm install @audio-ui/core
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/timeline.tsx"
"use client";

import {
  FADE_SHAPES,
  type FadeShape,
  fadeGain,
  Timeline as TimelinePrimitive,
} from "@audio-ui/react";
import {
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type MouseEvent,
  type ReactNode,
  useCallback,
  useRef,
  useState,
} from "react";
import { IconPlaceholder } from "@/components/icon-placeholder";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
  DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Fader } from "@/components/audio-ui/fader";
import { Knob } from "@/components/audio-ui/knob";
import type { Mix } from "@/lib/arrangement";

function Timeline({ className, ...props }: TimelinePrimitive.Root.Props) {
  return (
    <TimelinePrimitive.Root
      className={cn(
        "[--timeline-grid-color:color-mix(in_oklab,var(--border)_70%,transparent)] [--timeline-grid-accent-color:color-mix(in_oklab,var(--foreground)_16%,transparent)] flex w-full min-w-0 flex-col data-disabled:opacity-50",
        className
      )}
      data-slot="timeline"
      {...props}
    />
  );
}

type TimelineViewportProps = TimelinePrimitive.Viewport.Props & {
  /** `hidden` drops the native scrollbar, for a `TimelineNavigator` to scroll with instead. */
  scrollbar?: "hidden" | "native";
};

function TimelineViewport({
  className,
  scrollbar = "native",
  ...props
}: TimelineViewportProps) {
  return (
    <TimelinePrimitive.Viewport
      className={cn(
        "bg-background relative min-h-0 min-w-0 overscroll-x-contain",
        scrollbar === "hidden" &&
          "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
        className
      )}
      data-slot="timeline-viewport"
      {...props}
    />
  );
}

function TimelineRuler({ className, ...props }: TimelinePrimitive.Ruler.Props) {
  return (
    <TimelinePrimitive.Ruler
      className={cn(
        "h-7 border-b bg-background text-muted-foreground text-xs has-focus-visible:bg-muted shrink-0 cursor-pointer touch-none select-none",
        "data-disabled:pointer-events-none",
        className
      )}
      data-slot="timeline-ruler"
      {...props}
    />
  );
}

type TimelineLaneProps = Omit<TimelinePrimitive.Lane.Props, "style"> & {
  /** Distance between grid lines, in the timeline's units. Leave it out for no lines. */
  gridStep?: number;
  style?: CSSProperties;
};

/** A line every `gridStep` units, sized from the lane's own unit so it follows the zoom. */
function gridLines(gridStep: number | undefined): CSSProperties | undefined {
  if (!(gridStep && gridStep > 0)) {
    return;
  }
  return {
    backgroundImage:
      "repeating-linear-gradient(to right, var(--timeline-grid-color) 0 1px, transparent 1px 100%)",
    backgroundSize: `calc(var(--timeline-unit) * ${gridStep}) 100%`,
  };
}

function TimelineLane({
  className,
  gridStep,
  style,
  ...props
}: TimelineLaneProps) {
  return (
    <TimelinePrimitive.Lane
      className={cn("h-14 border-b bg-muted/30", className)}
      data-slot="timeline-lane"
      style={{ ...gridLines(gridStep), ...style }}
      {...props}
    />
  );
}

function TimelineTrack({ className, ...props }: TimelinePrimitive.Track.Props) {
  return (
    <TimelinePrimitive.Track
      className={className}
      data-slot="timeline-track"
      {...props}
    />
  );
}

function TimelineTrackHeader({
  className,
  ...props
}: TimelinePrimitive.TrackHeader.Props) {
  return (
    <TimelinePrimitive.TrackHeader
      className={cn(
        "w-44 border-e bg-background text-sm data-[selected]:bg-muted hover:[--timeline-track-header-z:30] focus-within:[--timeline-track-header-z:30]",
        className
      )}
      data-slot="timeline-track-header"
      {...props}
    />
  );
}

function TimelineTrackResizeHandle({
  className,
  ...props
}: TimelinePrimitive.TrackResizeHandle.Props) {
  return (
    <TimelinePrimitive.TrackResizeHandle
      className={cn(
        "-bottom-px inset-x-0 z-10 h-0.5 cursor-row-resize bg-primary/0 hover:bg-primary/40 has-focus-visible:bg-primary/60 data-[resizing]:bg-primary/60 absolute touch-none outline-none before:absolute before:inset-x-0 before:-inset-y-1 before:content-['']",
        className
      )}
      data-slot="timeline-track-resize-handle"
      {...props}
    />
  );
}

function TimelineMark({ className, ...props }: TimelinePrimitive.Mark.Props) {
  return (
    <TimelinePrimitive.Mark
      className={cn(
        "border-s ps-1.5 tabular-nums pointer-events-none inset-y-0 flex items-center",
        className
      )}
      data-slot="timeline-mark"
      {...props}
    />
  );
}

function TimelinePlayhead({
  className,
  ...props
}: TimelinePrimitive.Playhead.Props) {
  return (
    <TimelinePrimitive.Playhead
      className={cn(
        "bg-primary pointer-events-none inset-y-0 w-px",
        className
      )}
      data-slot="timeline-playhead"
      {...props}
    />
  );
}

function TimelineRange({ className, ...props }: TimelinePrimitive.Range.Props) {
  return (
    <TimelinePrimitive.Range
      className={cn(
        "rounded-[min(var(--radius-sm),6px)] bg-primary/20 ring-1 ring-primary/50 ring-inset has-focus-visible:ring-2 has-focus-visible:ring-ring inset-y-0 cursor-grab touch-none data-dragging:cursor-grabbing data-disabled:cursor-not-allowed",
        className
      )}
      data-slot="timeline-range"
      {...props}
    />
  );
}

function TimelineRangeHandle({
  className,
  ...props
}: TimelinePrimitive.RangeHandle.Props) {
  return (
    <TimelinePrimitive.RangeHandle
      className={cn(
        "rounded-[min(var(--radius-sm),6px)] border-ring border bg-card transition-[color,box-shadow] hover:ring-2 hover:ring-ring/40 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 motion-reduce:transition-none absolute inset-y-1 w-2 cursor-ew-resize data-[edge=end]:right-1 data-[edge=start]:left-1",
        "after:absolute after:-inset-x-1.5 after:inset-y-0 pointer-coarse:after:-inset-x-3",
        className
      )}
      data-slot="timeline-range-handle"
      {...props}
    />
  );
}

/** Where a fade being dragged stops: the other fade's start, as a dashed line. */
const FADE_LIMIT =
  "pointer-events-none absolute inset-y-0 z-30 hidden border-foreground/60 border-l border-dashed";

function TimelineClip({
  children,
  className,
  ...props
}: TimelinePrimitive.Clip.Props) {
  return (
    <TimelinePrimitive.Clip
      className={cn(
        "rounded-[min(var(--radius-sm),6px)] overflow-hidden bg-primary/25 ring-1 ring-primary/60 ring-inset data-[selected]:bg-primary/40 data-[selected]:ring-2 data-[selected]:ring-primary has-focus-visible:ring-2 has-focus-visible:ring-ring group/clip isolate inset-y-0 touch-none data-dragging:z-10 data-disabled:cursor-not-allowed",
        className
      )}
      data-slot="timeline-clip"
      {...props}
    >
      {children}
      <span
        aria-hidden="true"
        className={cn(FADE_LIMIT, "group-data-[fading=in]/clip:block")}
        style={{
          insetInlineStart:
            "calc(var(--timeline-unit) * var(--clip-fade-in-max))",
        }}
      />
      <span
        aria-hidden="true"
        className={cn(FADE_LIMIT, "group-data-[fading=out]/clip:block")}
        style={{
          insetInlineEnd:
            "calc(var(--timeline-unit) * var(--clip-fade-out-max))",
        }}
      />
    </TimelinePrimitive.Clip>
  );
}

/** The clip's title bar: drag it to move the clip. Put the clip's name in it. */
function TimelineClipHeader({
  className,
  ...props
}: TimelinePrimitive.ClipHeader.Props) {
  return (
    <TimelinePrimitive.ClipHeader
      className={cn(
        "bg-primary/30 data-[selected]:bg-primary/50 flex h-5 cursor-grab items-center truncate px-2 text-xs data-dragging:cursor-grabbing data-disabled:cursor-not-allowed",
        className
      )}
      data-slot="timeline-clip-header"
      {...props}
    />
  );
}

function TimelineClipHandle({
  className,
  ...props
}: TimelinePrimitive.ClipHandle.Props) {
  return (
    <TimelinePrimitive.ClipHandle
      className={cn(
        "bg-primary/0 hover:bg-primary/60 has-focus-visible:bg-primary absolute inset-y-0 z-10 w-2 cursor-ew-resize data-[edge=end]:right-0 data-[edge=start]:left-0",
        "after:absolute after:-inset-x-1.5 after:inset-y-0 pointer-coarse:after:-inset-x-3",
        className
      )}
      data-slot="timeline-clip-handle"
      {...props}
    />
  );
}

/** Shades what a fade attenuates, over the clip's content. */
function TimelineClipFade({
  className,
  ...props
}: TimelinePrimitive.ClipFade.Props) {
  return (
    <TimelinePrimitive.ClipFade
      className={cn("text-foreground/80 [--clip-fade-shade:color-mix(in_oklab,var(--background)_55%,transparent)]", className)}
      data-slot="timeline-clip-fade"
      {...props}
    />
  );
}

const FADE_SHAPE_LABELS: Record<FadeShape, string> = {
  fast: "Fast",
  linear: "Linear",
  slow: "Slow",
  smooth: "Smooth",
};

const CURVE_STEPS = 12;

/** A fade-in's curve as an SVG path, silent at the bottom left to full at the top right. */
function curvePath(shape: FadeShape): string {
  return Array.from({ length: CURVE_STEPS + 1 }, (_, step) => {
    const progress = step / CURVE_STEPS;
    const x = 2 + progress * 20;
    const y = 14 - fadeGain(shape, progress) * 12;
    return `${step === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;
  }).join(" ");
}

type TimelineClipFadeHandleProps = TimelinePrimitive.ClipFadeHandle.Props & {
  /** The fade's curve. With `onShapeChange`, a right-click on the handle lists the curves. */
  shape?: FadeShape;
  onShapeChange?: (shape: FadeShape) => void;
};

function TimelineClipFadeHandle({
  className,
  onShapeChange,
  shape = "linear",
  ...props
}: TimelineClipFadeHandleProps) {
  const handleShape = useCallback(
    (value: string) => onShapeChange?.(value as FadeShape),
    [onShapeChange]
  );
  const handle = (
    <TimelinePrimitive.ClipFadeHandle
      className={cn(
        "rounded-[min(var(--radius-sm),6px)] border-ring border bg-card transition-[color,box-shadow] hover:ring-2 hover:ring-ring/40 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 motion-reduce:transition-none top-1 z-20 size-3 cursor-ew-resize data-[edge=in]:ms-1 data-[edge=out]:me-1 opacity-0 transition-opacity",
        "group-hover/clip:opacity-100 group-data-[selected]/clip:opacity-100 data-dragging:opacity-100 has-focus-visible:opacity-100",
        "after:absolute after:-inset-1 pointer-coarse:opacity-100 pointer-coarse:after:-inset-2",
        className
      )}
      data-slot="timeline-clip-fade-handle"
      {...props}
    />
  );
  if (!onShapeChange) {
    return handle;
  }
  return (
    <ContextMenu>
      <ContextMenuTrigger render={handle} />
      <ContextMenuContent className="w-auto min-w-32">
        <ContextMenuRadioGroup onValueChange={handleShape} value={shape}>
          {FADE_SHAPES.map((item) => (
            <ContextMenuRadioItem key={item} value={item}>
              <svg
                aria-hidden="true"
                className="h-4 w-6 shrink-0 text-muted-foreground"
                fill="none"
                stroke="currentColor"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth="1.5"
                viewBox="0 0 24 16"
              >
                <path d={curvePath(item)} />
              </svg>
              {FADE_SHAPE_LABELS[item]}
            </ContextMenuRadioItem>
          ))}
        </ContextMenuRadioGroup>
      </ContextMenuContent>
    </ContextMenu>
  );
}

/** The whole timeline as one bar: drag the window to scroll, an edge to zoom, double-click to show everything. */
function TimelineNavigator({
  className,
  ...props
}: TimelinePrimitive.Navigator.Props) {
  return (
    <TimelinePrimitive.Navigator
      className={cn("h-6 rounded-[min(var(--radius-sm),6px)] bg-muted shrink-0 select-none", className)}
      data-slot="timeline-navigator"
      {...props}
    >
      <TimelinePrimitive.NavigatorWindow
        aria-label="Visible range"
        className="bg-primary/25 hover:bg-primary/35 has-focus-visible:ring-2 has-focus-visible:ring-ring/50 inset-y-0 min-w-6 cursor-grab rounded-[inherit] active:cursor-grabbing"
      />
      <TimelinePrimitive.NavigatorHandle
        aria-label="Start of the visible range"
        className="rounded-[min(var(--radius-sm),6px)] border-ring border bg-card transition-[color,box-shadow] hover:ring-2 hover:ring-ring/40 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 motion-reduce:transition-none inset-y-1 ms-1 w-2 cursor-ew-resize after:absolute after:-inset-x-1.5 after:inset-y-0"
        edge="start"
      />
      <TimelinePrimitive.NavigatorHandle
        aria-label="End of the visible range"
        className="rounded-[min(var(--radius-sm),6px)] border-ring border bg-card transition-[color,box-shadow] hover:ring-2 hover:ring-ring/40 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 motion-reduce:transition-none inset-y-1 w-2 cursor-ew-resize data-[edge=end]:-translate-x-[calc(100%+0.25rem)] after:absolute after:-inset-x-1.5 after:inset-y-0"
        edge="end"
      />
    </TimelinePrimitive.Navigator>
  );
}

const selectText = (element: HTMLInputElement | null) => element?.select();

/** A track is never shorter than its full header needs: to go smaller, fold it. */
export const MIN_TRACK_HEIGHT = 79;
/** Below this a track is a strip: the full header (name, solo, mute, gain, pan) needs this much room, so a shorter track keeps the name, solo and mute and drops the rest. */
export const COMPACT_TRACK_HEIGHT = 79;
export const MAX_TRACK_HEIGHT = 320;
export const TRACK_HEIGHT_STEP = 8;

/** The colors a track can take: the theme's chart tokens, written out for the stylesheet to find. */
export const TRACK_COLORS = [
  {
    dot: "bg-chart-1",
    clip: "[--primary:var(--chart-1)]",
    edge: "border-s-chart-1",
    name: "Color 1",
  },
  {
    dot: "bg-chart-2",
    clip: "[--primary:var(--chart-2)]",
    edge: "border-s-chart-2",
    name: "Color 2",
  },
  {
    dot: "bg-chart-3",
    clip: "[--primary:var(--chart-3)]",
    edge: "border-s-chart-3",
    name: "Color 3",
  },
  {
    dot: "bg-chart-4",
    clip: "[--primary:var(--chart-4)]",
    edge: "border-s-chart-4",
    name: "Color 4",
  },
  {
    dot: "bg-chart-5",
    clip: "[--primary:var(--chart-5)]",
    edge: "border-s-chart-5",
    name: "Color 5",
  },
] as const;

const formatPan = (pan: number) => {
  const amount = Math.round(Math.abs(pan) * 100);
  if (amount === 0) {
    return "C";
  }
  return `${pan < 0 ? "L" : "R"}${amount}`;
};

/** A track's context menu: an entry for each action it is given, then the colors. */
function TimelineTrackMenu({
  folded,
  onColor,
  onDuplicate,
  onFold,
  onInsert,
  onRemove,
  onRename,
}: {
  folded?: boolean;
  onColor: (event: MouseEvent<HTMLElement>) => void;
  onDuplicate?: () => void;
  onFold?: () => void;
  onInsert?: () => void;
  onRemove?: () => void;
  onRename?: () => void;
}) {
  return (
    <ContextMenuContent className="w-auto min-w-36">
      {onRename ? (
        <ContextMenuItem onClick={onRename}>Rename</ContextMenuItem>
      ) : null}
      {onDuplicate ? (
        <ContextMenuItem onClick={onDuplicate}>Duplicate</ContextMenuItem>
      ) : null}
      {onFold ? (
        <ContextMenuItem onClick={onFold}>
          {folded ? "Unfold" : "Fold"}
        </ContextMenuItem>
      ) : null}
      {onRemove ? (
        <>
          <ContextMenuSeparator />
          <ContextMenuItem onClick={onRemove} variant="destructive">
            Delete
          </ContextMenuItem>
        </>
      ) : null}
      {onInsert ? (
        <>
          <ContextMenuSeparator />
          <ContextMenuItem onClick={onInsert}>Insert track</ContextMenuItem>
        </>
      ) : null}
      <ContextMenuSeparator />
      {TRACK_COLORS.map((swatch, index) => (
        <ContextMenuItem data-color={index} key={swatch.name} onClick={onColor}>
          <span
            aria-hidden="true"
            className={cn("size-2.5 rounded-full", swatch.dot)}
          />
          {swatch.name}
        </ContextMenuItem>
      ))}
    </ContextMenuContent>
  );
}

/**
 * What a track's header holds: its name, which selects the track (its content
 * is where the cursor goes), solo, mute and gain. The lower edge resizes it.
 */
function TimelineTrackControls({
  children,
  compact,
  folded,
  mix,
  name,
  onMix,
  onMixCommit,
  onDuplicate,
  onInsert,
  onRemove,
  onRename,
  onSelect,
  onToggleFold,
  meter,
  selected,
  trackId,
}: {
  /** More rows under the mix, such as the automation's parameter chooser. A short track leaves them out. */
  children?: ReactNode;
  /** A level meter drawn down the header's right edge, such as a vertical `Meter` sized to fill it. */
  meter?: ReactNode;
  /** Whether the track is short: one row, with no gain. */
  compact: boolean;
  /** Whether the track is folded to a strip: the caret opens it. */
  folded?: boolean;
  /** Gives the header a caret that folds the track. */
  onToggleFold?: (trackId: string) => void;
  mix: Mix;
  name: string;
  onMix: (trackId: string, patch: Partial<Mix>) => void;
  /** Called when a drag of the gain or the pan ends, to close it as one undo step. */
  onMixCommit?: (trackId: string) => void;
  /** Gives the menu a "Duplicate" entry. */
  onDuplicate?: (trackId: string) => void;
  /** Gives the menu an "Insert track" entry: a new track after this one. */
  onInsert?: (trackId: string) => void;
  /** Lets the name be edited in place: a double-click, or "Rename" in the menu. */
  onRename?: (trackId: string, name: string) => void;
  /** Gives the menu a "Delete track" entry. Leave it out for tracks that cannot be removed. */
  onRemove?: (trackId: string) => void;
  /** `additive` when Shift, Cmd or Ctrl is held: add the track to the tracks picked. */
  onSelect: (trackId: string, additive: boolean) => void;
  selected: boolean;
  trackId: string;
}) {
  const handleMute = useCallback(
    () => onMix(trackId, { muted: !mix.muted }),
    [mix.muted, onMix, trackId]
  );
  const handleSolo = useCallback(
    () => onMix(trackId, { soloed: !mix.soloed }),
    [mix.soloed, onMix, trackId]
  );
  const handleGain = useCallback(
    (value: number | readonly number[]) =>
      onMix(trackId, { gain: Array.isArray(value) ? value[0] : Number(value) }),
    [onMix, trackId]
  );
  const handlePan = useCallback(
    (value: number | readonly number[]) =>
      onMix(trackId, { pan: Array.isArray(value) ? value[0] : Number(value) }),
    [onMix, trackId]
  );
  const handleMixCommit = useCallback(
    () => onMixCommit?.(trackId),
    [onMixCommit, trackId]
  );
  const handleColor = useCallback(
    (event: MouseEvent<HTMLElement>) =>
      onMix(trackId, { color: Number(event.currentTarget.dataset.color) }),
    [onMix, trackId]
  );
  const handleRemove = useCallback(
    () => onRemove?.(trackId),
    [onRemove, trackId]
  );
  const [renaming, setRenaming] = useState(false);
  const startRenaming = useCallback(() => setRenaming(true), []);
  // The menu hands focus back as it closes, which would end the edit: open the field after it.
  const renameWhenClosed = useRef(false);
  const handleMenuClosed = useCallback((open: boolean) => {
    if (!open && renameWhenClosed.current) {
      renameWhenClosed.current = false;
      setRenaming(true);
    }
  }, []);
  const handleRenameItem = useCallback(() => {
    renameWhenClosed.current = true;
  }, []);
  const handleNameKeyDown = useCallback(
    (event: KeyboardEvent<HTMLInputElement>) => {
      event.stopPropagation();
      if (event.key === "Enter") {
        onRename?.(trackId, event.currentTarget.value);
        setRenaming(false);
      } else if (event.key === "Escape") {
        setRenaming(false);
      }
    },
    [onRename, trackId]
  );
  const handleNameBlur = useCallback(
    (event: FocusEvent<HTMLInputElement>) => {
      onRename?.(trackId, event.currentTarget.value);
      setRenaming(false);
    },
    [onRename, trackId]
  );
  const handleDuplicate = useCallback(
    () => onDuplicate?.(trackId),
    [onDuplicate, trackId]
  );
  const handleInsert = useCallback(
    () => onInsert?.(trackId),
    [onInsert, trackId]
  );
  const handleFold = useCallback(
    () => onToggleFold?.(trackId),
    [onToggleFold, trackId]
  );
  const handleClick = useCallback(
    (event: MouseEvent<HTMLElement>) =>
      onSelect(trackId, event.shiftKey || event.metaKey || event.ctrlKey),
    [onSelect, trackId]
  );
  // The whole header picks the track, but not through a control that has its own job.
  const handleHeaderClick = useCallback(
    (event: MouseEvent<HTMLElement>) => {
      const target = event.target as Element;
      if (
        !target.closest(
          "button, input, [data-slot^=fader], [data-slot=knob], [data-slot=timeline-track-resize-handle]"
        )
      ) {
        handleClick(event);
      }
    },
    [handleClick]
  );
  return (
    <ContextMenu onOpenChangeComplete={handleMenuClosed}>
      <ContextMenuTrigger
        render={
          // biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: a larger target for the name button, which carries the keyboard and the accessible name
          <div
            className={cn(
              "@container/header relative flex w-full cursor-default flex-col justify-center border-s-4",
              // A strip is as tall as its buttons plus an equal margin all round, so they sit concentric with its corners.
              compact ? "ps-4 pe-4 py-0" : "gap-1.5 ps-4 pe-5 py-2.5",
              TRACK_COLORS[mix.color]?.edge
            )}
            onClick={handleHeaderClick}
          />
        }
      >
        <div className="flex items-center gap-1">
          {onToggleFold ? (
            <Button
              aria-expanded={!folded}
              aria-label={`${folded ? "Unfold" : "Fold"} ${name}`}
              onClick={handleFold}
              size="icon-xs"
              variant="ghost"
            >
              <IconPlaceholder
                className={cn(!folded && "rotate-90")}
                hugeicons="ArrowRight01Icon"
                lucide="ChevronRightIcon"
                phosphor="CaretRightIcon"
                remixicon="RiArrowRightSLine"
                tabler="IconChevronRight"
              />
            </Button>
          ) : null}
          {renaming ? (
            <input
              aria-label="Track name"
              autoFocus
              className="min-w-0 flex-1 bg-transparent font-medium outline-none"
              defaultValue={name}
              onBlur={handleNameBlur}
              onKeyDown={handleNameKeyDown}
              ref={selectText}
            />
          ) : (
            <button
              aria-pressed={selected}
              className={cn(
                "min-w-0 flex-1 truncate text-left font-medium outline-none hover:underline focus-visible:underline",
                compact && "ps-1.5"
              )}
              onClick={handleClick}
              onDoubleClick={onRename ? startRenaming : undefined}
              type="button"
            >
              {name}
            </button>
          )}
          <Button
            aria-label={`Solo ${name}`}
            aria-pressed={mix.soloed}
            onClick={handleSolo}
            size="icon-xs"
            variant={mix.soloed ? "default" : "ghost"}
          >
            <IconPlaceholder
              hugeicons="HeadphonesIcon"
              lucide="HeadphonesIcon"
              phosphor="HeadphonesIcon"
              remixicon="RiHeadphoneLine"
              tabler="IconHeadphones"
            />
          </Button>
          <Button
            aria-label={`Mute ${name}`}
            aria-pressed={mix.muted}
            onClick={handleMute}
            size="icon-xs"
            variant={mix.muted ? "default" : "ghost"}
          >
            {mix.muted ? (
              <IconPlaceholder
                hugeicons="VolumeMute01Icon"
                lucide="VolumeXIcon"
                phosphor="SpeakerSimpleSlashIcon"
                remixicon="RiVolumeMuteLine"
                tabler="IconVolumeOff"
              />
            ) : (
              <IconPlaceholder
                hugeicons="VolumeHighIcon"
                lucide="Volume2Icon"
                phosphor="SpeakerSimpleHighIcon"
                remixicon="RiVolumeUpLine"
                tabler="IconVolume"
              />
            )}
          </Button>
        </div>
        {compact ? null : (
          <div className="flex h-7 items-center gap-6 pe-1">
            <Fader
              aria-label={`${name} gain`}
              className="ms-2.5 min-w-0 flex-1 **:data-[orientation=horizontal]:min-w-0 **:data-[slot=fader-value]:top-full **:data-[slot=fader-value]:bottom-auto **:data-[slot=fader-value]:mt-1 **:data-[slot=fader-value]:mb-0"
              editableValue
              max={1}
              min={0}
              onValueChange={handleGain}
              onValueCommitted={handleMixCommit}
              orientation="horizontal"
              size="sm"
              step={0.01}
              thumbMarks={false}
              value={mix.gain}
              valueLabel="auto"
            />
            <Knob
              anchor={0}
              aria-label={`${name} pan`}
              className="mb-0 size-7 shrink-0"
              editableValue
              formatValue={formatPan}
              max={1}
              min={-1}
              onValueChange={handlePan}
              onValueCommitted={handleMixCommit}
              size="sm"
              step={0.01}
              value={mix.pan}
            />
          </div>
        )}
        {compact ? null : <div className="contents">{children}</div>}
        {meter ? (
          <div className="pointer-events-none absolute inset-y-1.5 end-1.5 w-2">
            {meter}
          </div>
        ) : null}
        <TimelineTrackResizeHandle aria-label={`${name} height`} />
      </ContextMenuTrigger>
      <TimelineTrackMenu
        folded={folded}
        onColor={handleColor}
        onDuplicate={onDuplicate ? handleDuplicate : undefined}
        onFold={onToggleFold ? handleFold : undefined}
        onInsert={onInsert ? handleInsert : undefined}
        onRemove={onRemove ? handleRemove : undefined}
        onRename={onRename ? handleRenameItem : undefined}
      />
    </ContextMenu>
  );
}

/** The menu items of the grid, for a context menu or a dropdown menu. */
type GridMenuKit = {
  RadioGroup: typeof ContextMenuRadioGroup;
  RadioItem: typeof ContextMenuRadioItem;
  CheckboxItem: typeof ContextMenuCheckboxItem;
  Separator: typeof ContextMenuSeparator;
};

const CONTEXT_GRID_KIT: GridMenuKit = {
  RadioGroup: ContextMenuRadioGroup,
  RadioItem: ContextMenuRadioItem,
  CheckboxItem: ContextMenuCheckboxItem,
  Separator: ContextMenuSeparator,
};

const DROPDOWN_GRID_KIT: GridMenuKit = {
  RadioGroup: DropdownMenuRadioGroup,
  RadioItem: DropdownMenuRadioItem,
  CheckboxItem: DropdownMenuCheckboxItem,
  Separator: DropdownMenuSeparator,
};

/** The grid's divisions and its two flags, as the menu a right-click on a lane or a dropdown opens. */
function TimelineGridMenuItems({
  division,
  divisions,
  onDivisionChange,
  onSnapChange,
  onTripletChange,
  snap,
  triplet,
  variant = "context",
}: {
  division: string;
  divisions: { label: string; value: string }[];
  onDivisionChange: (value: string) => void;
  onSnapChange: (checked: boolean) => void;
  onTripletChange: (checked: boolean) => void;
  snap: boolean;
  triplet: boolean;
  variant?: "context" | "dropdown";
}) {
  const { RadioGroup, RadioItem, CheckboxItem, Separator } =
    variant === "dropdown" ? DROPDOWN_GRID_KIT : CONTEXT_GRID_KIT;
  return (
    <>
      <RadioGroup onValueChange={onDivisionChange} value={division}>
        {divisions.map((item) => (
          <RadioItem key={item.value} value={item.value}>
            {item.label.replace("Grid: ", "")}
          </RadioItem>
        ))}
      </RadioGroup>
      <Separator />
      <CheckboxItem checked={triplet} onCheckedChange={onTripletChange}>
        Triplet grid
      </CheckboxItem>
      <CheckboxItem checked={snap} onCheckedChange={onSnapChange}>
        Snap to grid
      </CheckboxItem>
    </>
  );
}

export {
  Timeline,
  TimelineClip,
  TimelineClipFade,
  TimelineClipFadeHandle,
  TimelineClipHandle,
  TimelineClipHeader,
  TimelineGridMenuItems,
  TimelineLane,
  TimelineMark,
  TimelineNavigator,
  TimelinePlayhead,
  TimelineRange,
  TimelineRangeHandle,
  TimelineRuler,
  TimelineTrack,
  TimelineTrackControls,
  TimelineTrackHeader,
  TimelineTrackResizeHandle,
  TimelineViewport,
};
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { Timeline } from "@audio-ui/react";

<Timeline.Root>
  <Timeline.Viewport>
    <Timeline.Ruler>
      <Timeline.Mark value={0} />
      <Timeline.Playhead />
    </Timeline.Ruler>
    <Timeline.Track>
      <Timeline.TrackHeader>
        <Timeline.TrackResizeHandle />
      </Timeline.TrackHeader>
      <Timeline.Lane />
    </Timeline.Track>
    <Timeline.Lane>
      <Timeline.Range>
        <Timeline.RangeHandle edge="start" />
        <Timeline.RangeHandle edge="end" />
      </Timeline.Range>
      <Timeline.Clip>
        <Timeline.ClipHeader />
        <Timeline.ClipFade edge="in" />
        <Timeline.ClipFade edge="out" />
        <Timeline.ClipHandle edge="start" />
        <Timeline.ClipHandle edge="end" />
        <Timeline.ClipFadeHandle edge="in" />
        <Timeline.ClipFadeHandle edge="out" />
      </Timeline.Clip>
      <Timeline.Playhead />
    </Timeline.Lane>
  </Timeline.Viewport>
  <Timeline.Navigator>
    <Timeline.NavigatorWindow />
    <Timeline.NavigatorHandle edge="start" />
    <Timeline.NavigatorHandle edge="end" />
  </Timeline.Navigator>
</Timeline.Root>
```

## Usage

```tsx
import {
  Timeline,
  TimelineLane,
  TimelineMark,
  TimelinePlayhead,
  TimelineRuler,
  TimelineViewport,
} from "@/components/audio-ui/timeline";
import {
  DEFAULT_TEMPO,
  describeBarsBeats,
  gridInterval,
} from "@audio-ui/core/time";
```

```tsx
const snapStep = gridInterval("adaptive", zoom, DEFAULT_TEMPO);

<Timeline
  duration={32}
  onPositionChange={(seconds) => transport.seek(seconds)}
  onZoomChange={setZoom}
  snapStep={snapStep}
  source={transport.position}
  zoom={zoom}
>
  <TimelineViewport>
    <TimelineRuler
      aria-label="Playhead"
      getAriaValueText={(seconds) => describeBarsBeats(seconds, DEFAULT_TEMPO)}
    >
      <TimelineMark value={0}>1</TimelineMark>
      <TimelineMark value={2}>2</TimelineMark>
      <TimelinePlayhead />
    </TimelineRuler>
    <TimelineLane gridStep={snapStep}>
      <TimelinePlayhead />
    </TimelineLane>
  </TimelineViewport>
</Timeline>
```

## Ranges

```tsx
const [loop, setLoop] = useState<readonly [number, number]>([8, 16]);

<TimelineLane>
  <TimelineRange aria-label="Loop" drawable onValueChange={setLoop} value={loop}>
    <TimelineRangeHandle aria-label="Loop start" edge="start" />
    <TimelineRangeHandle aria-label="Loop end" edge="end" />
  </TimelineRange>
</TimelineLane>
```

The range's own input moves it with Left and Right, by the timeline's `step`, and with Up and Down by its own length; Cmd/Ctrl + Left and Right shorten or lengthen it by a step, Cmd/Ctrl + Up and Down double or halve it; each handle's input moves its edge, which stops `minLength` short of the other one (the grid step by default). `onValueChange` receives `[start, end]` and `eventDetails` with the `edge` that moved (`"start"`, `"end"` or `"both"`) and the `reason` (`"drag"`, `"draw"`, `"keyboard"` or `"input-change"`). Leave the handles out for a range that only moves, or pass `movable={false}` for one that only resizes.

## Tracks

A track is a row: a header beside a lane. `Timeline.Track` holds the row, `Timeline.TrackHeader` is the strip that names it and stays at the left edge while the lane scrolls, and `Timeline.TrackResizeHandle` sets its height. You hold the height: leave it out and the row shares the room it is given; set it and the row keeps it.

```tsx
const [height, setHeight] = useState<number | undefined>();

<Timeline.Track
  height={height}
  onHeightChange={(next) => setHeight(next)}
  selected={selected}
>
  <Timeline.TrackHeader>
    Drums
    <Timeline.TrackResizeHandle aria-label="Height of Drums" />
  </Timeline.TrackHeader>
  <Timeline.Lane>{/* clips */}</Timeline.Lane>
</Timeline.Track>;
```

You put the header's controls in it, such as solo, mute and gain. The track does not know about them.

The block reorders tracks by their grip, the bar at the track's left edge: press it and drag. A copy of the track's header follows the pointer, the dragged track stays in place faded, and a line marks the place it would land in. Near the top or bottom of a view that scrolls, the view scrolls on its own while the track is held. The arrow keys move a focused grip one place up or down, and every move is one undo step with its clips. The move is `moveTrack(id, index)` in `useArrangement`; it uses a pointer drag rather than the browser's drag and drop, so it does not depend on how the browser handles a drag.

## Clips

A clip is a stretch of a source (a recording, a pattern) laid on a track. It is a range that knows about its source: trimming its start moves where in the source it begins, so its content stays where it is, and it cannot show more than the source holds.

```tsx
const [clip, setClip] = useState<Timeline.Clip.Value>({
  start: 8, // where it sits on the timeline
  length: 16, // how long it plays
  offset: 4, // where in the source it begins
  fadeIn: 0,
  fadeInShape: "linear", // or "fast", "slow", "smooth"
  fadeOut: 0,
});

<Timeline.Lane data-track="drums" seekable>
  <Timeline.Clip
    aria-label="Drums, Groove"
    onRemove={() => removeClip()}
    onValueChange={setClip}
    sourceLength={48}
    value={clip}
  >
    <Timeline.ClipHeader>Groove</Timeline.ClipHeader>
    <Timeline.ClipFade edge="in" />
    <Timeline.ClipFade edge="out" />
    <Timeline.ClipHandle aria-label="Groove start" edge="start" />
    <Timeline.ClipHandle aria-label="Groove end" edge="end" />
    <Timeline.ClipFadeHandle aria-label="Groove fade in" edge="in" />
    <Timeline.ClipFadeHandle aria-label="Groove fade out" edge="out" />
  </Timeline.Clip>
</Timeline.Lane>
```

All of it is in the timeline's units, the source's too. Dragging the clip's header, its title bar, moves it on the grid, and over other lanes: `eventDetails.lane` is the `Timeline.Lane` under the pointer, so put your own attribute on each lane (`data-track` above) and read it in `onValueCommitted`, which also fires for a drop on another lane that left the position as it was. The clip follows the pointer vertically through `--clip-drag-y`. A press on the clip's body selects it and puts the cursor there, and a drag scrubs, so the body never moves the clip by accident. Its content is yours: it sets `--clip-offset` and `--clip-source-length` so a `Waveform` can show the stretch that is played.

Holding Alt when a drag begins marks it as a copy (`eventDetails.copy`): the clip you were dragging is the copy, so leave the original where it was and add the dragged one. A fade's shape is stepped by double-clicking its handle, or Enter on its input (`reason` is `"double-click"` or `"keyboard"`), and `ClipFade` draws the real ramp. The registry's `TimelineClipFadeHandle` also takes `onShapeChange`: a right-click on the handle lists the four curves, each drawn, and the arrangement's `setFadeShape(id, edge, shape)` sets one as a single undo step.

Laying a clip where others already are is `coverClips(clips, placed, { duration }, split)`: the clips under it are covered rather than stacked (removed, trimmed or cut in two around it), and `split` makes the second piece of a cut clip from the first, so what your app keeps about a clip travels with each piece. Covering only shortens a clip, so the timeline's `duration` is the one limit it reads: the length of a source, which limits how far one clip stretches, never applies to the clips under it.

Cutting, copying and the like are plain functions on the clip, for the model behind your clips: `splitClip(clip, at)` returns the two pieces (or `null` when `at` leaves one too short), and `moveClip`, `trimClipStart`, `trimClipEnd`, `setClipFadeIn`, `setClipFadeOut`, `setClipFadeShape` and `duplicateClip` return the clip as it would end up, within the limits you give them.

### Many clips

Dragging one clip among 1 000 takes about a millisecond of script, and the playhead moves outside React, so it costs no render. The real cost is rendering every clip again whenever one moves, which happens when the clip component is not memoized. Put one clip in a `React.memo` component with stable props (the clip's own `value`, callbacks that take its id), so React skips every clip a drag did not touch.

### Selecting several clips

The primitive keeps no selection: `selected` and `onSelectedChange` are yours, so a selection is any set of ids you keep. `onSelectedChange` fires when a press selects a clip, and again on a press with Shift, Cmd or Ctrl held (read `eventDetails.event`), which is how you add to a selection or take from it. A touch screen has no modifier keys: give the clip `additive` (from a "select several" mode of your own) and every press is reported, so a press adds a clip to the selection or takes it from it. To move a group, read `eventDetails.delta` on a move, how far the dragged clip is from where the drag began, and move each other selected clip by the same amount from where it was when the drag began; `moveClip` keeps each on the timeline. For the group to follow the clip up and down too, `onDragOffsetChange` reports how far it is dragged vertically, in px, and 0 when the drag ends: shift the other selected clips by that much. A drop on another lane then moves each by as many lanes as the dragged one did. The registry's arrangement block does all of this, with Cmd/Ctrl + A to select every clip and Shift + drag across bare lane to select the clips a rectangle touches (it measures each clip's `getBoundingClientRect()`, so it needs nothing from the primitive).

### The cursor

A `Timeline.Lane` with `seekable` puts the position where a press lands on its bare area, as the ruler does, and a drag scrubs it. Presses on the clips in it go to the clips. The cursor can then go anywhere along a track, and your track header selects the whole track. A split is then `splitClip(clip, position)` on the selected clip, or on each clip of the selected track that the cursor is over.

## Navigator

```tsx
<Timeline zoom={zoom} onZoomChange={setZoom} duration={32}>
  <TimelineViewport scrollbar="hidden">…</TimelineViewport>
  <TimelineNavigator />
</Timeline>
```

Place it anywhere inside the timeline, outside the viewport. Dragging an edge zooms while holding the other edge in place, through `onZoomChange` with `reason: "drag"`; a double-click fits the whole timeline with `reason: "double-click"`.

## Musical time

`@audio-ui/core/time` turns a tempo into what a timeline needs, in seconds:

- `gridInterval(division, zoom, tempo)` is the grid's step: a fixed division (`"1-bar"`, `"1/8"`, …), or `"adaptive"` for the finest division still 24px apart at this zoom. Pass `{ triplet: true }` for three in the space of two. Give the same number to `snapStep` and to the lanes' `gridStep`, so gestures land on the lines you see.
- `barLabelInterval(zoom, tempo)` is how far apart the ruler's bar numbers go: whole bars, spread out until they no longer collide.
- `barsBeatsAt(seconds, tempo)` and `describeBarsBeats` read a position as bars, beats and sixteenths.
- `gridLabel(seconds, tempo)` names an interval the way a grid readout does (`"1 bar"`, `"1/16"`, `"1/8T"`), so the adaptive grid reads out the division it uses, and a menu and a readout always name a grid the same way.
- `metronomeRhythmSeconds(rhythm, tempo)` is the interval of a click rhythm (`"1/8"`, `"1/8t"`, …), or `null` for `"auto"`, which follows the beat.

## An arrangement

The arrangement block, the preview above, is a timeline with tracks of clips, locators, time signatures, automation and MIDI clips. Its parts, its state, its keys and its undo are on the [Arrangement editor](/docs/components/base/arrangement-editor) page.

## Placing your own parts

Every lane sets a `--timeline-unit` CSS variable, the width of one unit. Parts are placed with it, so anything you draw follows the zoom the same way:

```tsx
<div style={{ left: "calc(var(--timeline-unit) * 12)", position: "absolute" }} />
```

## API reference

### Root

Groups the parts of a timeline and owns what they share: its length, its zoom, its grid and the playback position. Units are yours: seconds, beats, pulses or steps. `zoom` is how many px one unit takes; leave it unset and the timeline fits the width it is given. The position comes from `position`, or from a live `source` that the playhead follows straight in the DOM without re-rendering React. With a `snapStep`, every gesture lands on the grid unless Cmd/Ctrl is held, and `snapPoints` (the edges of other clips, the locators) pull a dragged clip or range onto them from a few px away. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultPosition` | `number` | `0` | Uncontrolled starting position. |
| `defaultZoom` | `number` |  | Uncontrolled starting zoom, in px per unit. Unset, the timeline fits its width until zoomed. |
| `disabled` | `boolean` |  |  |
| `duration` | `number` | `1` | Length of the timeline, in your units. |
| `largeStep` | `number` |  | Step for Page Up/Page Down and Shift + arrow keys. Defaults to ten `step`s. |
| `maxZoom` | `number` | `Infinity` |  |
| `minZoom` | `number` | `0` |  |
| `onPositionChange` | `(value: number, eventDetails: PositionChangeEventDetails) => void` |  | Fires while the position is moved: press, drag, keyboard and input changes. |
| `onPositionCommitted` | `(value: number, eventDetails: PositionCommitEventDetails) => void` |  | Fires when a gesture that moved the position ends. |
| `onZoomChange` | `(zoom: number, eventDetails: ZoomChangeEventDetails) => void` |  | Fires when the zoom changes, already clamped. `eventDetails.cancel()` keeps the zoom where it was. |
| `position` | `number` |  | Playback position, from 0 to `duration`. |
| `snapOffset` | `number` | `0` | Where the grid's lines start, in your units. |
| `snapPoints` | `readonly number[]` |  | Places that pull a dragged clip or range onto them from a few px away, ahead of the grid: the edges of other clips, the locators. Cmd/Ctrl lets go of them too. |
| `snapStep` | `number` | `0` | Distance between grid lines, in your units. 0 is no grid. |
| `source` | `Source` |  | A live position followed outside React; takes precedence over `position`. |
| `step` | `number` |  | Step for the arrow keys. Defaults to `snapStep`, or a hundredth of `duration`. |
| `zoom` | `number` |  | Zoom, in px per unit. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Navigator

The whole timeline as one bar, with the stretch the viewport shows drawn inside it as `Timeline.NavigatorWindow`: drag the window to scroll, drag one of its `Timeline.NavigatorHandle`s to zoom, double-click to show everything. Place it anywhere inside `Timeline.Root`, outside the viewport. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getAriaValueText` | `DescribePosition` |  | Returns how a position is announced by the window and its handles. Defaults to the number. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### NavigatorWindow

The stretch of the timeline the viewport shows. Dragging it scrolls; its native `<input type="range">` scrolls from the keyboard and carries its accessible name. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### NavigatorHandle

One edge of the window: dragging it zooms, holding the other edge in place. Its native `<input type="range">` does the same from the keyboard and carries its accessible name. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"end" \| "start"` |  | The edge of the window this handle moves. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Viewport

The scrolling window onto the timeline. Cmd/Ctrl + wheel (and pinch on a trackpad) zooms around the pointer; a bare wheel scrolls as usual. Every zoom change keeps its anchor point in place, or the middle of the view when there is none. It reports its scroll offset and width for `Timeline.Navigator`. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Lane

A strip along the timeline that its parts are placed on: a track, the ruler's strip, a row of markers. Zoomed, it is as long as the timeline; fitted, it takes the width it is given and the timeline is spread across it. Its `--timeline-unit` CSS variable is the width of one unit. With `seekable`, a press on its bare area moves the position, as on the ruler, so the cursor can be put anywhere along a track; with `onSelectionChange` too, a drag from there selects a stretch of time instead of scrubbing. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `seekable` | `boolean` | `false` | Whether a press on the lane's bare area moves the position, and a drag scrubs it, on the grid unless Cmd/Ctrl is held. |
| `onSelectionChange` | `(range: readonly [number, number], eventDetails) => void` |  | With `seekable`, fires while a drag from the lane's bare area selects a stretch of time, as `[start, end]` in timeline units on the grid unless Cmd/Ctrl is held: from where the press put the position to the pointer. The drag then no longer scrubs. `eventDetails.cancel()` keeps the selection as it was. |
| `onSelectionCommitted` | `(range: readonly [number, number], eventDetails) => void` |  | Fires when a selection that changed ends. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Track

One track of the arrangement: a row holding a header beside a lane. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `height` | `number` |  | The height in px, or leave it out for the row to share what room there is. |
| `minHeight / maxHeight` | `number` | `32 / 480` | Bounds the height is clamped to. |
| `step / largeStep` | `number` | `8 / 32` | How far the arrows, and Page Up and Page Down, move the height. |
| `selected` | `boolean` | `false` | Whether the track is the one picked: only styling, `data-selected`. |
| `onHeightChange` | `(height \| undefined, eventDetails) => void` |  | A new height is asked for, by a drag or a key; a double-click on the handle gives the height up, which is `undefined`. `eventDetails.cancel()` refuses it. |
| `onHeightCommitted` | `(height \| undefined, eventDetails) => void` |  | A resize ended having changed the height. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-selected` | Present when the track is selected. |
| `data-resizing` | Whether a pointer is resizing the track. |

### TrackHeader

The strip beside a track's lane that names it and holds what sets it. It stays at the left edge while the lane scrolls under it, at the stacking level of the `--timeline-track-header-z` CSS variable (20): raise it while the header is hovered or focused if it shows something that reaches into the next row, such as a value readout. It carries the track's `data-selected` and `data-resizing`. Renders a `<div>` element.

### TrackResizeHandle

Sets the track's height: by dragging, or from the keyboard through the native `<input type="range">` it holds, which carries the accessible name. The arrows move by a step (Down is taller), Page Up and Page Down by a large one, Home and End to the minimum and the maximum. Place it along the track's lower edge. A double-click clears the height, so the track shares the room again. Renders a `<div>` element.

### Ruler

The lane that moves the position: a press jumps to the pointer and a drag scrubs, both on the grid unless Cmd/Ctrl is held; with Shift held the drag scrubs four times finer. With `onSelectionChange`, a drag that starts with Shift held selects a stretch instead, to loop. It holds the native `<input type="range">` that takes focus, handles the keyboard and carries the accessible name (`aria-label` / `aria-labelledby`). Place `Timeline.Mark`s in it for its labels. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getAriaValueText` | `DescribePosition` |  | Returns how the position is announced, e.g. `"bar 3, beat 2"`. Defaults to the number. |
| `onSelectionChange` | `(range: readonly [number, number], eventDetails) => void` |  | Fires while a stretch is selected by dragging with Shift held, as `[start, end]` in timeline units on the grid unless Cmd/Ctrl is held: feed it to a `Timeline.Range` as a loop. A plain press and drag still move the position. `eventDetails.cancel()` keeps the selection as it was. |
| `onSelectionCommitted` | `(range: readonly [number, number], eventDetails) => void` |  | Fires when a selection that changed ends. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Range

A stretch of the timeline, such as a loop, a selection or a clip, laid on the lane it sits in from `value[0]` to `value[1]`. Dragging its body moves it, on the grid unless Cmd/Ctrl is held; add a `Timeline.RangeHandle` per edge to resize it. With `drawable`, dragging across empty lane draws a new range. Its own native `<input type="range">` moves it from the keyboard and carries its accessible name (`aria-label` / `aria-labelledby`): Left and Right by a step, Up and Down by its own length, and with Cmd/Ctrl, Left and Right shorten or lengthen it by a step while Up and Down double or halve it. A drag lands an edge on a `snapPoints` place within a few px. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Value` |  | Uncontrolled starting range. Defaults to the whole timeline. |
| `disabled` | `boolean` |  |  |
| `drawable` | `boolean` | `false` | Whether dragging across empty lane draws a new range. |
| `getAriaValueText` | `DescribePosition` |  | Returns how a position is announced, for the range and its handles. Defaults to the number. |
| `minLength` | `number` |  | The shortest the range can be, in the timeline's units. Defaults to the grid step. |
| `movable` | `boolean` | `true` | Whether dragging the body, or its keys, moves the range. |
| `onValueChange` | `(value: Value, eventDetails: ChangeEventDetails) => void` |  | Fires while the range is moved, resized or drawn. |
| `onValueCommitted` | `(value: Value, eventDetails: CommitEventDetails) => void` |  | Fires when a gesture that changed the range ends. |
| `value` | `Value` |  | The range, as `[start, end]` in the timeline's units. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is moving, resizing or drawing the range. |

### RangeHandle

Resizes one edge of the range: by dragging, on the grid unless Cmd/Ctrl is held, or from the keyboard through the native `<input type="range">` it holds, which carries the accessible name. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"end" \| "start"` |  | The edge of the range this handle resizes. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is moving, resizing or drawing the range. |

### Clip

A stretch of a source laid on a lane. Dragging its `Timeline.ClipHeader` moves it, on the grid unless Cmd/Ctrl is held, and over other lanes too; pressing its body selects it and puts the cursor there. Add a `Timeline.ClipHandle` per edge to trim it and a `Timeline.ClipFadeHandle` per fade. It sets `--clip-offset`, `--clip-source-length`, `--clip-fade-in` and `--clip-fade-out` for its content, and `--clip-fade-in-max` and `--clip-fade-out-max`, how long each fade can grow, to draw the limit of a fade being dragged (`data-fading`). Its own native `<input type="range">` moves it from the keyboard and carries its accessible name (`aria-label` / `aria-labelledby`); Delete and Backspace on it call `onRemove`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `Value` |  | The clip: `{ start, length, offset, fadeIn, fadeOut }`, all in the timeline's units. |
| `defaultValue` | `Value` |  | Uncontrolled starting clip. Defaults to the whole timeline. |
| `sourceLength` | `number` |  | How long the source is, in the timeline's units: trimming stops where it runs out. Defaults to no limit. |
| `selected` | `boolean` |  | Whether the clip is selected. |
| `defaultSelected` | `boolean` | `false` | Whether the clip starts selected, when `selected` is not set. |
| `additive` | `boolean` | `false` | Whether every press is reported, as one with Shift, Cmd or Ctrl held is: for building a selection where there are no modifier keys, such as a touch screen. |
| `onSelectedChange` | `(selected: boolean, eventDetails) => void` |  | Fires when a press on the clip selects it, and again on a press with Shift, Cmd or Ctrl held (or any, when `additive`), which `eventDetails.event` carries. |
| `onValueChange` | `(value: Value, eventDetails: ChangeEventDetails) => void` |  | Fires while the clip is moved, trimmed or faded. |
| `onValueCommitted` | `(value: Value, eventDetails: CommitEventDetails) => void` |  | Fires when a gesture that changed the clip ends, or one that dropped it on another lane. |
| `onRemove` | `(eventDetails) => void` |  | Fires when Delete or Backspace is pressed on the clip: removing it is up to you. |
| `minLength` | `number` |  | The shortest the clip can be trimmed to, in the timeline's units. Defaults to the grid step. |
| `movable` | `boolean` | `true` | Whether dragging the header, or the keys, moves the clip. |
| `getAriaValueText` | `DescribePosition` |  | Returns how a position is announced. Defaults to the number. |
| `disabled` | `boolean` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is moving, trimming or fading the clip. |
| `data-fading` | `"in"` or `"out"`: the fade a pointer is dragging. |
| `data-selected` | Present when the clip is selected. |

### ClipHeader

The clip's title bar: dragging it moves the clip, on the grid unless Cmd/Ctrl is held, and over other lanes too. The rest of the clip is its body, where a press puts the cursor. Place it along the clip's top and put the clip's name in it. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is moving, trimming or fading the clip. |
| `data-selected` | Present when the clip is selected. |

### ClipHandle

Trims one edge of the clip: by dragging, on the grid unless Cmd/Ctrl is held, or from the keyboard through the native `<input type="range">` it holds, which carries the accessible name. Trimming the start moves where in the source the clip begins and leaves its content where it is; trimming the end stops where the source or the timeline does. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"end" \| "start"` |  | The edge of the clip this handle trims. |

### ClipFadeHandle

Sets how long one of the clip's fades lasts: by dragging, or from the keyboard through the native `<input type="range">` it holds, which carries the accessible name. Double-clicking it, or pressing Enter on its input, steps the fade through its shapes, and Delete or Backspace takes the fade away. It sits where its fade ends, at the clip's top edge. The two fades cannot overlap. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"in" \| "out"` |  | The fade of the clip this handle sets: the one at its start or at its end. Double-click it, or press Enter on its input, to step the fade through its shapes. |

### ClipFade

Draws a fade over the clip's content, as a DAW does: the ramp as a line from the clip's edge, as long as the fade, with what it attenuates lightly shaded. It is hidden while its fade is empty and ignores the pointer. Its color is the `color` it is given. Renders an `<svg>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `edge` | `"in" \| "out"` |  | The fade of the clip this draws. |

### Playhead

A marker at the playback position, on the lane it sits in. It follows the position straight in the DOM, through its own `--timeline-offset` CSS variable, so a playhead moving every frame never re-renders React. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Mark

Something placed at `value` on a lane: a bar number on the ruler, a cue, a grid line. Its children are its label. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Where the mark sits, in the timeline's units. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-fitted` | Whether no zoom is set, so the timeline fits the width it is given. |
| `data-scrubbing` | Whether a pointer is moving the position. |

### Registry component

#### Timeline

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `duration` | `number` | `1` | Length of the timeline, in your units. |
| `zoom` | `number` |  | Zoom, in px per unit. |
| `defaultZoom` | `number` |  | Uncontrolled starting zoom, in px per unit. Unset, the timeline fits its width until zoomed. |
| `minZoom / maxZoom` | `number` | `0 / Infinity` | Bounds the zoom is clamped to. |
| `onZoomChange` | `(zoom, eventDetails) => void` |  | Fires when the zoom changes, already clamped. `eventDetails.cancel()` keeps the zoom where it was. |
| `position` | `number` |  | Playback position, from 0 to `duration`. |
| `defaultPosition` | `number` | `0` | Uncontrolled starting position. |
| `source` | `{ getSnapshot, subscribe }` |  | A live position followed outside React; takes precedence over `position`. |
| `onPositionChange` | `(value, eventDetails) => void` |  | Fires while the position is moved: press, drag, keyboard and input changes. |
| `onPositionCommitted` | `(value, eventDetails) => void` |  | Fires when a gesture that moved the position ends. |
| `snapStep` | `number` | `0` | Distance between grid lines, in your units. 0 is no grid. |
| `snapOffset` | `number` | `0` | Where the grid's lines start, in your units. |
| `step / largeStep` | `number` | `snapStep / ten steps` | Keyboard steps. |
| `disabled` | `boolean` | `false` | Disables seeking and zooming. |

#### Parts

**Components**

| Name | Description |
| --- | --- |
| `TimelineViewport` | The scrolling window. Zooms around the pointer with Cmd/Ctrl + scroll. `scrollbar="hidden"` drops the native scrollbar. |
| `TimelineRuler` | The lane that seeks. Holds the input; takes `aria-label` and `getAriaValueText`. |
| `TimelineLane` | A strip the timeline's parts are placed on. `gridStep` draws a line every that many units. |
| `TimelineTrack` | A row: a header beside a lane. `height`, `selected`, `onHeightChange`. |
| `TimelineTrackHeader` | The strip that names a track, kept at the left edge. |
| `TimelineTrackResizeHandle` | Sets the track's height, by dragging or with the keys. |
| `TimelineMark` | Something placed at `value`: a bar number, a cue. |
| `TimelinePlayhead` | Follows the position on the lane it sits in. |
| `TimelineRange` | A stretch of the timeline: `value`, `defaultValue`, `minLength`, `movable`, `drawable`, `onValueChange`, `onValueCommitted`. |
| `TimelineRangeHandle` | Resizes one `edge` of the range. |
| `TimelineClip` | A stretch of a source: `value`, `sourceLength`, `selected`, `onValueChange`, `onValueCommitted`, `onRemove`. Dragged over other lanes too. |
| `TimelineClipHeader` | The clip's title bar: drag it to move the clip. |
| `TimelineClipHandle` | Trims one `edge` of the clip. |
| `TimelineClipFade` | Draws a fade, as a line with its shading. Takes `edge`. |
| `TimelineClipFadeHandle` | Sets a fade, at the clip's top corner: a square that shows when the clip is hovered or selected. Takes `edge`. |
| `TimelineNavigator` | The whole timeline as one bar, scrolling and zooming the viewport. |

`@audio-ui/react/timeline` exposes the same parts as `Timeline.Root`, `Timeline.Viewport`, `Timeline.Ruler`, `Timeline.Lane`, `Timeline.Mark`, `Timeline.Playhead`, `Timeline.Range`, `Timeline.RangeHandle`, `Timeline.Clip`, `Timeline.ClipHeader`, `Timeline.ClipHandle`, `Timeline.ClipFade`, `Timeline.ClipFadeHandle`, and `Timeline.Navigator` with its `Timeline.NavigatorWindow` and `Timeline.NavigatorHandle`. Every part accepts a `render` prop and `className` / `style` as functions of state, and exposes `data-disabled`, `data-fitted` and `data-scrubbing`.
