Knob
Rotary knob input for continuous parameters, with a glowing 270-degree arc fill, vertical-drag interaction, magnetic detents, and a monospace value readout.
<arc-knob>Overview
Knob is the input a slider cannot be: a rotary control that packs a full parameter range into a compact circular footprint, so a rack of channel strips or a synth panel can put a dozen of them side by side. A 270-degree arc track fills from min to the current value, a glowing indicator line marks the position, and the readout below renders in the monospace role with tabular digits so it never shifts width while turning.
Interaction follows audio-software convention. Dragging vertically turns the knob (the full range covers about 150 pixels of travel), and holding Shift slows the drag to a tenth for fine adjustment. The mouse wheel and arrow keys step by step, Page Up and Page Down jump by ten steps, and Home and End go straight to the rails. The optional detents prop names snap values (an array from script, or a comma-separated attribute): each renders as a tick mark around the dial, and a drag snaps magnetically when it lands close, as with the centre detent on a pan knob or unity on a gain knob. Keyboard and wheel stepping ignore detents, so precise entry is never fought.
Knob follows the v3 edit/commit contract: arc-input fires continuously while the knob turns and arc-change fires once when the turn commits, so a live preview and an expensive save can listen separately. The component participates in forms through ElementInternals, submitting its value under name, and the dial is a keyboard-operable role="slider" with the full ARIA value set. format shapes both the visible readout and the accessible value text, so a screen reader hears "440 Hz" rather than a bare number.
Guidelines
When to use
- Use Knob where horizontal space is scarce and controls sit in columns: mixer strips, effect panels, tool palettes
- Provide a `label`; a bare dial gives no clue what parameter it turns
- Set `format` to include the unit, so both the readout and screen readers announce "440 Hz" rather than "440"
- Put a detent at the neutral position of a bipolar parameter: 0 on a pan knob, unity on a gain knob
- Listen to `arc-input` for live audible or visible preview and to `arc-change` for persisting the committed value
When not to use
- Do not use Knob for a wide-layout single value where a slider fits. A slider shows its whole range at a size a knob cannot
- Do not use Knob for exact numeric entry. Pair it with or replace it by Number Input when users need to type a value
- Do not scatter detents densely across the range; a magnet every few units makes smooth dragging impossible
- Do not rely on the dial alone to convey the value. The readout below it is part of the control, so leave it visible
Features
- A 270-degree SVG arc track with an accent fill from `min` to the current value and a glowing indicator line
- Synth-style vertical drag with pointer capture; Shift slows the drag to a tenth for fine adjustment
- Mouse wheel and arrow keys step by `step`, Page Up/Down by ten steps, Home/End to the rails
- Optional `detents` render tick marks and snap the drag magnetically to named values
- `format` callback shapes the readout and the ARIA value text, e.g. adding a unit suffix
- The v3 event contract: `arc-input` continuously while turning, `arc-change` once on commit
- Form participation via ElementInternals. The value submits under `name` and restores on `form.reset()`
- Keyboard-operable `role="slider"` dial with `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext`
Preview
Usage
Knob is in the media domain group, so it is absent from the default @arclux/arc-ui barrel. Import it from@arclux/arc-ui/media, or from its own subpath@arclux/arc-ui/knob. Everything else (the element, the CSS, the framework wrappers, the support it gets) is unchanged.
This component requires JavaScript. No pure HTML/CSS version is available. Use the Web Component directly or a framework wrapper.
<script type="module" src="@arclux/arc-ui"></script>
<!-- Basic labeled knob -->
<arc-knob label="Volume" value="75" min="0" max="100"></arc-knob>
<!-- Bipolar pan knob with a centre detent -->
<arc-knob label="Pan" value="0" min="-50" max="50" detents="0"></arc-knob>
<!-- Filter cutoff with a formatted readout -->
<arc-knob id="cutoff" label="Cutoff" value="8000" min="200" max="16000" step="100"></arc-knob>
<!-- Disabled state -->
<arc-knob label="Locked" value="30" disabled></arc-knob>
<script>
const cutoff = document.getElementById('cutoff');
cutoff.format = (v) => v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz';
// Real-time preview while turning
cutoff.addEventListener('arc-input', (e) => {
filterNode.frequency.value = e.detail.value;
});
// Commit final value on release
cutoff.addEventListener('arc-change', (e) => {
savePatch({ cutoff: e.detail.value });
});
</script>import { Knob } from '@arclux/arc-ui-react';
import { useState } from 'react';
function ChannelStrip() {
const [gain, setGain] = useState(0);
const [pan, setPan] = useState(0);
return (
<div style={{ display: 'flex', gap: 32 }}>
<Knob
label="Gain"
value={gain}
min={-24}
max={12}
step={0.5}
detents={[0]}
format={(v) => `${v > 0 ? '+' : ''}${v} dB`}
onArcInput={(e) => setGain(e.detail.value)}
onArcChange={(e) => saveGain(e.detail.value)}
/>
<Knob
label="Pan"
value={pan}
min={-50}
max={50}
detents={[0]}
format={(v) => (v === 0 ? 'C' : v < 0 ? `L${-v}` : `R${v}`)}
onArcInput={(e) => setPan(e.detail.value)}
/>
</div>
);
}<script setup>
import { Knob } from '@arclux/arc-ui-vue';
import { ref } from 'vue';
const cutoff = ref(8000);
const resonance = ref(35);
const hz = (v) => (v >= 1000 ? (v / 1000).toFixed(1) + ' kHz' : v + ' Hz');
</script>
<template>
<div style="display:flex; gap:32px;">
<Knob label="Cutoff" :value="cutoff" :min="200" :max="16000" :step="100"
:format="hz"
@arc-input="cutoff = $event.detail.value" />
<Knob label="Resonance" :value="resonance" :min="0" :max="100"
:detents="[0, 50, 100]"
@arc-input="resonance = $event.detail.value" />
</div>
</template><script>
import { Knob } from '@arclux/arc-ui-svelte';
let attack = 12;
let decay = 240;
let sustain = 70;
let release = 480;
const ms = (v) => v + ' ms';
</script>
<div style="display:flex; gap:24px;">
<Knob label="Attack" value={attack} min={0} max={2000} step={4} format={ms}
on:arc-input={(e) => attack = e.detail.value} />
<Knob label="Decay" value={decay} min={0} max={2000} step={4} format={ms}
on:arc-input={(e) => decay = e.detail.value} />
<Knob label="Sustain" value={sustain} min={0} max={100}
on:arc-input={(e) => sustain = e.detail.value} />
<Knob label="Release" value={release} min={0} max={4000} step={8} format={ms}
on:arc-input={(e) => release = e.detail.value} />
</div>import { Component } from '@angular/core';
import { Knob } from '@arclux/arc-ui-angular';
@Component({
imports: [Knob],
template: `
<div style="display:flex; gap:32px;">
<arc-knob label="Drive" [value]="drive" [min]="0" [max]="100"
(arc-input)="drive = $event.detail.value"></arc-knob>
<arc-knob label="Mix" [value]="mix" [min]="0" [max]="100"
[detents]="[50]"
(arc-input)="onMixPreview($event.detail.value)"
(arc-change)="onMixCommit($event.detail.value)"></arc-knob>
</div>
`,
})
export class EffectPanelComponent {
drive = 40;
mix = 50;
onMixPreview(val: number) { /* live wet/dry preview */ }
onMixCommit(val: number) { /* persist to the patch */ }
}import { Knob } from '@arclux/arc-ui-solid';
import { createSignal } from 'solid-js';
function MasterSection() {
const [volume, setVolume] = createSignal(75);
return (
<div>
<Knob
label="Master"
value={volume()}
min={0}
max={100}
format={(v) => v + '%'}
onArcInput={(e) => setVolume(e.detail.value)}
onArcChange={(e) => persistVolume(e.detail.value)}
/>
</div>
);
}import { Knob } from '@arclux/arc-ui-preact';
import { useState } from 'preact/hooks';
function SendControls() {
const [reverb, setReverb] = useState(20);
const [delay, setDelay] = useState(0);
return (
<div style={{ display: 'flex', gap: 24 }}>
<Knob label="Reverb" value={reverb} min={0} max={100}
onArcInput={(e) => setReverb(e.detail.value)} />
<Knob label="Delay" value={delay} min={0} max={100}
onArcInput={(e) => setDelay(e.detail.value)} />
</div>
);
}<!-- arc-knob is interactive — requires JS -->
<arc-knob></arc-knob><!-- arc-knob is interactive — requires JS -->
<arc-knob></arc-knob>API
SWEEPnumber270- Degrees of arc the knob sweeps; the remaining 90 stay open at the bottom.
DRAG_THROWnumber150- Pixels of vertical drag that cover the full range.
DETENT_WINDOWnumber0.025- Fraction of the range within which a drag snaps to a detent.
valuenumber0- Current knob value. Reflected as an attribute and updated on user interaction.
namestring''disabledbooleanfalse- Disables interaction, reducing opacity and blocking pointer events.
labelstring''- Label text displayed above the knob in the label typography role.
formatFunctionundefined(value) => stringshaping the readout and the accessible value text, for example adding a unit suffix. Defaults to the plain number.minnumber0- Minimum allowed value at the start of the arc sweep.
maxnumber100- Maximum allowed value at the end of the arc sweep.
stepnumber1- Increment granularity. The value snaps to multiples of this number.
detentsnumber[][]- Snap values, as an array from script or a comma-separated attribute (for example "0,50,100"). While dragging, the value snaps magnetically to a detent within 2.5% of the range, and each detent renders as a tick mark around the dial. Keyboard and wheel stepping ignore detents.
readonlybooleanfalse- Prevents dragging, wheel, and key changes while the dial stays focusable and the value still submits.
size'sm' | 'md' | 'lg''md'- Control size.
mdis the default;smandlgscale the dial. formAssociatedbooleantruepropertiesobject{ // flag(), unlike `disabled`. The exclusion in props.js is specifically // about form-associated *platform* semantics: a `disabled` content // attribute that is merely present makes the element actually disabled // per the HTML spec, and formDisabledCallback assigns the property back, // so no converter can win. Neither of these is platform-mapped: // `required` is enforced by _computeValidity() below and `readonly` by // each component's own interaction handlers, so the stock converter buys // nothing here and costs the usual bug: `required="false"` read as true, // blocking submission of a form the author meant to leave optional. // Finding #48's shape, across all 26 form controls at once. required: flag(false), readonly: flag(false), }- Lit merges static properties up the prototype chain, so every consumer
gets these without declaring them.
requiredparticipates in constraint validation below;readonlyreflects for styling and is enforced by each component's interaction handlers (the mixin can't know which gestures mutate state). autoValidatesbooleantrue- Components that run their own constraint-validation logic (pattern checks, range checks) opt out of the automatic required sync by overriding this to false, and own the whole validity flag set instead.
formvalidityvalidationMessagerequiredbooleanfalse
Methods
checkValidity()boolean- Whether the control currently satisfies its constraints, per the native
constraint-validation API. Fires
invalidon the element when it does not, and reports nothing to the user. reportValidity()boolean- As checkValidity(), but also shows the browser's validation message against the control when it fails.
Events
arc-inputdetail:{ value: number }- Fired continuously while the knob is turning: every drag movement, wheel notch, or key step. Use for real-time preview such as a filter cutoff or gain applied live.
arc-changedetail:{ value: number }- Fired once when a turn commits: on drag release, and after each discrete wheel or key step. Use for persisting the value or triggering an expensive operation.
See Also
- SliderRange input slider with a label, live numeric value display, accent-primary fill track, and customisable min/max/step.
- Range SliderDual-thumb range slider for selecting a numeric interval within a defined range, with accent-primary fill between the thumbs and live value display.
- Number InputA numeric stepper input with decrement and increment buttons flanking a central text field, supporting min/max clamping, step increments, and keyboard shortcuts.