Step Grid
step-gridControlsSteps across, lanes down, a page at a time. Tap a cell to toggle it, drag across a row to paint a whole stroke on or off, drag up or down on a lit cell to ride its velocity — velocity is drawn as fill height and brightness both, because a six-pixel fill needs the second cue, and gate is the fill's width: the note's length inside its own step, drawn as exactly that. The playhead is a prop, an absolute step index, the same way lfo-visualizer takes phase: the host owns the clock, so the cursor is wherever the audio actually is rather than wherever a display timer drifted to. Past 16 steps the grid pages like the hardware it is quoting, with tabs and an opt-in follow mode that keeps a running sequence on screen. Leave the callbacks off and it becomes a read-only mini piano-roll — which is how the arpeggiator draws its computed line through the same cells a sequencer edits, with ghost cells for a preview that is not pattern truth. Layout and hit-testing are the same arithmetic in a pure core, so a paint stroke cannot disagree with the rectangles it paints.
Tap a cell to toggle it, drag across a row to paint a stroke, drag up or down on a lit cell to ride its velocity (Shift is fine). Velocity is fill height and brightness both; gate is the fill's width. The playhead is a prop — this demo owns the clock, the grid just draws it — and the page follows it.
Install
Copies the source into your project. Anything it depends on comes with it.
$ npx shadcn add @syntho-ui/step-gridPulls in
First time? Add the registry to your components.json once:
{
"registries": {
"@syntho-ui": "https://engine.syntho.app/r/{name}.json"
}
}Usage
Controlled or uncontrolled, whichever suits the surrounding state.
import { StepGrid } from "@/components/ui/step-grid"
// An editable 16-step, 4-lane pattern. The host owns the cells and the clock.
<StepGrid
steps={16}
lanes={4}
cells={cells}
laneLabels={["hat", "snare", "clap", "kick"]}
playhead={currentStep}
onToggle={(step, lane, active) => setCell(step, lane, active)}
onVelocity={(step, lane, velocity) => setVelocity(step, lane, velocity)}
/>
// Read-only: an arpeggiator's computed line as a mini piano-roll.
import { sequenceGrid, stepNoteName } from "@/lib/step-pattern"
const model = sequenceGrid(heldNotes, { order: "updown", octaves: 2 })
<StepGrid
steps={model.steps}
lanes={model.laneNotes.length}
cells={model.cells.map((c) => ({ step: c.step, lane: c.lane, gate: 0.6 }))}
laneLabels={model.laneNotes.map(stepNoteName)}
playhead={activeStep}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps / lanes | number | — | The pattern's full size — steps counts across every page, not the visible one. |
| cells | StepGridCellState[] | — | The lit cells, each addressed by absolute step and lane, carrying velocity (0..1), gate (0..1), an optional ghost flag for computed previews, and an optional per-cell color. Everything absent is an empty cell. |
| playhead | number | null | — | Absolute step index the host's clock is on. Drawn as a lit column when it falls on the visible page; null draws no cursor. |
| onToggle | (step, lane, active) => void | — | Report a toggle. Present makes the grid editable; absent makes it a read-only drawing with a summary label. |
| onVelocity | (step, lane, velocity) => void | — | Report a velocity ride — vertical drag on a lit cell, Shift+arrows on the keyboard, Shift mid-drag for a 4x fine pass. Omit it and vertical drags paint instead. |
| pageSize | number | 16 | Steps shown at a time. Tabs appear when the pattern outruns one page. |
| page / onPageChange | number / (page) => void | — | Controlled paging. Omit page to let the grid keep its own. |
| followPlayhead | boolean | false | Flip pages to keep the playhead on screen. Off by default so paging around a running sequence does not fight the cursor. |
| groupSize | number | 4 | Columns per beat-group: alternating groups carry a faint tint so beats stay countable. 0 disables. |
| laneLabels | string[] | — | Row captions, top to bottom — note names, drum names. |
| onInteraction | (event) => void | — | Fired once, on the first gesture of any kind — the place to resume a suspended AudioContext. |
| color | string | — | Override the accent colour for this instance. |
| height | number | 160 | Grid surface height in pixels. |
Interaction
- Drag with pointer capture, so the gesture keeps tracking past the edge of the control.
- Shift while dragging for a fine pass; release it mid-gesture and coarse tracking resumes.
- Double-click resets to
defaultValue. - Arrow keys step normally, Shift+arrow steps finely, PageUp/PageDown jump by ten, Home/End go to the extremes.
- Proper
sliderandswitchroles with livearia-valuetext, so a screen reader announces the formatted value rather than the raw number. - The scroll wheel adjusts the value while the pointer is over the control; stepped ranges move at least one declared step per notch by default.