Skip to main content

Composing controls

Every control here is two separable things: a mechanism the library owns and a skin you supply.

The control owns kinematics, state and ARIA. The part owns paint.

AmbientKnob is AmbientRotary plus a set of knob parts. If you want a control that behaves like a knob but looks like yours, take the mechanism and give it different parts.

Frames

Parts go into one of four frames, painted in this order:

FrameKinematicsOverflows the box?
panelstatic, behind the controlyes
basestatic, the control's own footprintno
actuatormoves with the valueno
fixturestatic, above the actuatorno

All four are optional. "Actuator" is the hardware term for the part you touch and that moves — a rotor, a thumb, a cap and a pill are all actuators — so one vocabulary covers every family:

FrameRotaryTravelPressLatchBank (per key)
panelprinted scaleprinted tickslegend
baseseated bodytrackwelltrackbezel
actuatorrotor + pointerthumbcappillcap
fixturehub, domelamp lens
<AmbientRotary
value={gain} onChange={setGain}
className="amb-knob"
parts={{
panel: <ScaleRing count={13} />,
base: <KnobBody flush />,
actuator: <MyPointer />,
}}
/>

A frame is a box where the control has a size of its own — a rotary, a travel track, a latch — and a display: contents marker where the part's content sizes the control, as a button's cap does. A part that fills a boxed frame does it at position: absolute; inset: 0.

The state channel

A control publishes its state on its own root, three ways from one source.

Custom properties — the canonical outlet

PropertyMeaning
--ambx-percentnormalised position, 0–1
--ambx-anglerotary only, degrees from 12 o'clock
--ambx-valuethe raw value
--ambx-sizethe control's nominal size
--ambx-travel-start / --ambx-travel-sweepthe rotary arc
--ambx-detentsrest positions; 0 is continuous

Because these are CSS, a part can be pure CSS with no React state at all:

.my-value-arc {
position: absolute;
inset: 0;
opacity: var(--ambx-percent);
transform: rotate(var(--ambx-angle));
}

Express your part's geometry as fractions of --ambx-size and it scales with the control for free — that is how the built-in parts are written.

Data attributes

data-dragging, data-at-min, data-at-max, data-disabled, data-pressed, data-orientation — the discrete conditions a part cannot detect for itself.

useControlState()

The same values, typed, for parts that genuinely need JS — a numeric readout, a tick ring that has to emit N children, an SVG whose path data depends on the value.

function Readout() {
const { value } = useControlState();
return <span className="my-readout">{Math.round(value)}</span>;
}

Inside a bank key, useBankKey() also gives you the option itself — its legend, its accessible name, its own lamp colour.

Animation

The actuator moves by transform, so one rule animates it. animate defaults to auto: follow the pointer 1:1 while dragging, ease otherwise. follow, ease and snap pin it.

Rules for parts

  • A part must not contain a focusable element or an interactive role. The control root already owns the role, the tab stop and the keyboard handler; a second one gives you two tab stops and a conflicting role. A development-only check warns if a part breaks this.
  • Mechanisms have no material prop; presets do. Which elements a material belongs on is a fact about a particular control's construction — a knurled knob paints with two, its cap and its rim; a smooth one with a single body — and a mechanism cannot know that once the parts are yours.

Kits

A set of parts that belong together is a kit — a named visual identity an app turns on once, and a third party can publish as a module. See Kits.

Just the mechanism

If you want to render the markup yourself from the ground up, the hooks are the mechanisms without any DOM: useRotary, useTravel, usePress, useLatch, useBank. Each returns the state plus the props to spread on your own root.