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 — the centre detent on a pan knob, 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
Cutoff
8000
Resonance
35
Usage
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.
-
size'sm' | 'md' | 'lg''md' - Control size. `md` is the default; `sm` and `lg` scale the dial.
-
valuenumber0 - Current knob value. Reflected as an attribute and updated on user interaction.
-
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.
-
namestring'' -
disabledbooleanfalse - Disables interaction, reducing opacity and blocking pointer events.
-
labelstring'' - Label text displayed above the knob in the label typography role.
-
detentsnumber[] | string[] - 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.
-
formatFunctionundefined - `(value) => string` shaping the readout and the accessible value text, for example adding a unit suffix. Defaults to the plain number.
-
readonlybooleanfalse - Prevents dragging, wheel, and key changes while the dial stays focusable and the value still submits.
-
formAssociatedbooleantrue -
propertiesobject{ required: { type: Boolean, reflect: true }, readonly: { type: Boolean, reflect: true }, } - Lit merges static properties up the prototype chain, so every consumer gets these without declaring them. `required` participates in constraint validation below; `readonly` reflects 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.
-
form -
validity -
validationMessage -
requiredbooleanfalse
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
- Slider Range input slider with a label, live numeric value display, accent-primary fill track, and customisable min/max/step.
- Range Slider Dual-thumb range slider for selecting a numeric interval within a defined range, with accent-primary fill between the thumbs and live value display.
- Number Input A numeric stepper input with decrement and increment buttons flanking a central text field, supporting min/max clamping, step increments, and keyboard shortcuts.