daw-ui

Curve

A curve through points in time, for automation, envelopes, fades and tempo, on a timeline or on its own, drawn on canvas once and moved by CSS, and editable with a hook.

  • Volume
  • Pan
  • Steps

Anatomy

import { scales, type CurvePoint } from '@addstack/daw-ui';
import { Curve } from '@addstack/daw-ui/react';

const points: CurvePoint[] = [
  { at: 0, value: -Infinity, shape: -0.6 },
  { at: 1, value: 0 },
];

<Curve.Root points={points} min={-Infinity} max={6} scale={scales.decibel} aria-label="Volume" className="h-12 w-64">
  <Curve.Fill className="text-orange-500/20" />
  <Curve.Line thickness={1.5} className="text-orange-500" />
</Curve.Root>

A curve goes through points, each at a time in seconds with a value: the automation of a parameter, an envelope, a fade, a tempo that changes. It is named for what it draws, like a waveform or notes: what the curve means is your application's. Before the first point and after the last, it holds their value.

A point's shape says how the curve goes on to the next point:

shape
"linear" (default)A straight line.
"hold"The point's value until the next point, then a jump: steps, as for a stepped modulation. Two points at the same time jump too.
A number from −1 to 1A tension that bends it: above 0 it stays near the point's value and moves late, below 0 it moves early, as the curved segments of DAWs.

Range

Values go bottom to top from min to max, placed with scale as a knob or a fader of that range would place them: scales.decibel for volume, scales.log for frequency. A curve in decibels on the range of your volume fader goes where the fader goes, and a straight segment is a steady move of the fader, not of the decibels. The same rule gives the value at a time:

import { createRange, curveValue, scales } from '@addstack/daw-ui';

const volume = createRange({ min: -Infinity, max: 6, scale: scales.decibel });
curveValue(points, 0.5, volume); // the value halfway through the fade, as the audio engine should play it

curveValue(points, time, range) takes points sorted by time; without a range, segments go straight in value.

Where it lies

  • On its own, as in the demo, a curve is its own time axis: offset … offset + duration across its width, up to its last point by default.
  • On a timeline, outside a region, it lies on the timeline itself, from its second 0, for ever: an automation lane. The Arrangement block has one under its bass track.
  • In a region, it shows the part the region shows, from its offset for its duration, as clip automation or a fade does.

The same holds for waveforms and notes.

Line and fill

Curve.Line strokes the curve, thickness CSS pixels wide, in its CSS color. Curve.Fill fills the area between the curve and origin, the root's min by default, or the middle for pan and pitch bend (origin={0} on a range from −1 to 1). Render the fill before the line, to lie under it.

Editing

Give the root an editing engine from useCurveEditing, and its points can be moved, added, removed and bent. The engine is yours, as the principles (section 6) have it: its options are your rules, its callbacks report what the user does, and the points stay in your state.

const [points, setPoints] = useState(initialPoints);
const editing = useCurveEditing({ snap: { time: grid }, onGestureEnd: setPoints });

<Curve.Root points={points} editing={editing} aria-label={t('volume')}>
  <Curve.Fill />
  <Curve.Line />
  <Curve.Dots />
  <Curve.Bend className="size-2 rotate-45 border" />
  <Curve.Handle aria-label={t('point')} className="size-3 rounded-full bg-white data-selected:bg-orange-400" />
</Curve.Root>
  • Dots and handles. Curve.Dots draws every point in the tiles, which costs nothing to scroll. A Curve.Handle is an element only where one is needed: at the point the pointer is near, at the selected points, and at the one that takes the focus from Tab. A lane of 2000 points has a handful of handles, however many points it shows. Curve.Bend shows in the middle of the segment the pointer is near.
  • A gesture shows without rendering. The points, their handles and the stretch of the curve a move changes are drawn again as the pointer moves, and nothing renders. onPointsChange reports each step; when the gesture ends, the curve returns to its points, so keep the changes, in onGestureEnd or as they come. Dragging a point of a lane of 2000 next to 32 waveforms takes 4 ms per frame.
  • Moves hold together. A drag moves the selection. No point passes a neighbour that does not move, no value leaves the range, and the most constrained point stops them all. Moves snap to the finest lines of snap.time at least 12 px apart, and to multiples of snap.value; Shift does not snap.
  • Your rules. lock fixes axes of points: a point locked in both has no handle. constrain is a function from the points a move makes to the points it may make, applied at every step, and canAdd and canRemove turn the double-clicks off. The Envelope block is an ADSR made with them.
InputEffect
Drag a pointMoves it, and the selection with it.
Drag the middle of a segmentBends the segment through the pointer.
Double-click the curve / a point / the middle of a segmentAdds a point / removes it / makes the segment straight.
Shift while draggingDoes not snap.
Press, Cmd/Ctrl+press, press away from the pointsSelects alone, adds or takes out, clears.
TabInto the curve: one tab stop for all its points.
Left and RightThe previous or next point.
Up and Down (Shift: finer)Moves the value, by snap.value or a hundredth of the range.
Cmd/Ctrl+Left and RightMoves the point in time, by a snap step (Shift: a pixel).
Alt+Up and DownBends the segment after the point.
Delete, SpaceRemoves; selects (with Cmd/Ctrl: adds or takes out).

Editable, the root is a group, and each handle a slider of its point's value, whose aria-valuetext says its time and value in format.time and format.value. Bend handles are hidden from assistive technology: Alt with Up and Down does what they do.

Options of useCurveEditing

Option
snap{ time?: TimeGrid, value?: number }: the grid moves snap to in time, the step in value.
lockAxes points do not move on, by index ({ 0: "both", 1: "value" }), or as a function of the index and the point.
constrain(points) => points: your rule, applied at every step of a gesture. Keep the number of points and their order.
canAdd, canRemoveWhether double-clicks and Delete add and remove points. Both true by default.
format{ time?, value? }: the formats handles announce with.
onPointsChange(points, { reason, event })Each step of a gesture: reason is "drag", "keyboard", "add", "remove" or "bend".
onGestureStart(), onGestureEnd(points)Around each gesture: one undo step.
onSelectedChange(indexes)The selection a press or a key makes.

The engine is the same object on every render. Read editing.selected, and call editing.select(indexes) or editing.selectAll() on it, from a toolbar for instance.

How it stays fast

A curve is drawn like a waveform, in canvas tiles placed in time with the timeline's CSS variables: playback and scrolling draw nothing drawn already, a zoom stretches the tiles and draws them sharp when it rests. A tile finds its first point by a binary search and traces only the points it shows: straight and held segments as lines between their points, bent ones through a point every two device pixels. An automation lane of 2000 bent points adds about half a millisecond per frame to zooming 32 waveforms.

A new points array draws the curve again: keep the same one between renders while the points stay the same.

API reference

Root

Renders a <div> with role="img", position: relative and overflow: hidden. On its own, it is its own time axis, with --timeline-start and --timeline-scale.

Prop

Type

Line

The curve as a line, drawn on canvas in the CSS color of this element. Renders a <div> that fills the root.

Prop

Type

Fill

The area between the curve and origin, drawn on canvas in the CSS color of this element. Renders a <div> that fills the root.

Prop

Type

Dots

A dot at every point, drawn on canvas in the CSS color of this element. Renders a <div> that fills the root.

Prop

Type

Handle

The handle of a point, with editing: rendered for each point that needs one, centred on it. Renders a <div> with role="slider": give it a size and a look, and an aria-label.

Prop

Type

Data attribute
data-selectedWhile its point is selected.
data-draggingWhile it is dragged.

Bend

The handle in the middle of a segment, with editing, rendered for the segment the pointer is near. Renders a <div> hidden from assistive technology, with data-dragging while it is dragged.

Prop

Type

On this page