Transport Controls
transport-controlsControlsThe bar across the top of every sequencer-shaped thing, and deliberately a composition rather than new button code: the four keys are glow-button at a small size, in the modes it already has — toggle for play, loop and record, momentary for stop — so the halo, the LED bezel, the press-in seat and the keyboard behaviour are the ones that already shipped. The tempo is a monospace readout you drag vertically, wheel over, arrow-key or double-click to reset, with Shift as a four-times-slower tenth-bpm pass anchored where Shift went down. The TAP key keeps nothing but timestamps: the bpm comes from a pure median-averaged reducer in lib/musical-time and leaves immediately through onTempoChange, so a fumbled tap moves the answer by nothing. The division row is the part that earns its space — pick 1/8T and the readout beside it says 208 ms and 4.80 Hz at the current tempo, which is the number you were actually trying to hit. The beat indicator pulses from a phase or beat prop and from nothing else, because a display timer and an audio clock disagree within seconds; the host owns the clock, exactly as it does for lfo-visualizer's phase and step-grid's playhead. Every control is independently read-only when its callback is absent, so with none of them it is a status strip.
The demo owns the clock — an interval integrating beats, which is why dragging the tempo speeds the transport up from where it is instead of rewriting where it has been. The component only ever receives that position as a prop and pulses its light from it, the same rule lfo-visualizer and step-grid follow. Drag the number to set the tempo (Shift for tenths, double-click to reset), or tap the key four times: tap tempo here is pure maths over a list of timestamps, median-averaged so one fumbled tap does not move it. Swing lives in the same lib, built and tested with no face passing it yet — the sequencer that wants it is still coming-soon, the same call step-pattern made about rotateSteps.
Install
Copies the source into your project. Anything it depends on comes with it.
$ npx shadcn add @syntho-ui/transport-controlsPulls 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 { TransportControls } from "@/components/ui/transport-controls"
// Fully controlled. The host owns the clock and passes the position in.
<TransportControls
playing={playing}
onPlayingChange={setPlaying}
onStop={() => { setPlaying(false); rewind() }}
loop={loop}
onLoopChange={setLoop}
tempo={tempo}
onTempoChange={setTempo}
division={division}
onDivisionChange={setDivision}
beat={beatsElapsed}
/>
// Record only appears when it can do something.
<TransportControls recording={armed} onRecordChange={setArmed} /* ... */ />
// No callbacks: a status strip beside a rendered result.
<TransportControls playing loop tempo={174} division="1/16" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| playing / onPlayingChange | boolean / (playing) => void | — | Rolling or not. Controlled — the key reports and never latches locally. Without the callback it becomes a lamp showing the state. |
| onStop | (event) => void | — | Momentary, and purely a report: whether stopping also clears playing, rewinds or disarms the record is the host's decision and the host's state. Absent hides the key. |
| loop / onLoopChange | boolean / (loop) => void | — | Loop state, same contract as play. |
| recording / onRecordChange | boolean / (recording) => void | — | Record arm. The callback's ABSENCE hides the key entirely — a transport that cannot record should not show one that pretends it can. |
| tempo / onTempoChange | number / (bpm) => void | — | Tempo in bpm, clamped 20..300 (the band the session transport already uses). Drag, wheel, arrows, double-click-to-default and tap all report through the callback; without it the readout is a readout. |
| tempoStep / tempoFineStep | number | 1 / 0.1 | What a drag, a wheel notch or an arrow key lands on, plain and with Shift held. Snapping goes through knob-core's snapToStep, so a tempo lands on its step exactly as a knob's value does. |
| defaultTempo | number | 120 | Where a double-click on the readout sends the tempo. |
| division / divisions / onDivisionChange | NoteDivisionId / NoteDivisionId[] / (id) => void | "1/8" / 1/4, 1/8, 1/8T, 1/16 | The picker. Ids come from lib/musical-time's table — any of eighteen, from a whole note to a 1/32 in plain, dotted and triplet flavours. Labels are 1/8, 1/8• and 1/8T. |
| showDivisionReadout | boolean | true | Print the current division's length and rate beside the row. This is what makes a division mean something: the same interval in milliseconds and in steps per second, the two units a host actually wires to. |
| phase / beat | number | null | — | The host's clock, and the only thing that lights the beat indicator. phase is 0..1 inside the current beat; beat is an absolute fractional beat index and additionally tells the indicator which hits are downbeats. Nothing in the component animates a counter. |
| beatsPerBar | number | 4 | Beats per bar, for the downbeat accent. Beats are quarters — there is no time-signature model behind it. |
| showTap / showTempo / showBeat | boolean | — | Drop the parts a given transport does not want. The tap key defaults to visible whenever onTempoChange is present, and to hidden when it is not. |
| size | number | 52 | Key diameter in pixels. The tempo digits and the tap key scale off it. |
| recordColor | string | var(--uikit-record, #ef4444) | The record key's colour — red since 1955, and the one colour the theme has no token for. Written as an opt-in variable with a red fallback so a skin can claim --uikit-record without control-theme having to grow a token. |
| onInteraction | (event) => void | — | Fires once, synchronously inside the first gesture of any kind — the place to resume a suspended AudioContext, and the reason a transport is usually the first thing on the page. |
| color | string | — | Override the accent colour for this instance. |
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.