@tomhermans/range-knob
v0.2.0
Published
A circular range/knob web component: form-associated, pointer and keyboard accessible, themeable via CSS custom properties and ::part(), with an optional built-in settings dialog and pluggable MIDI / CSS-binding add-ons.
Maintainers
Readme
@tomhermans/range-knob
A circular range/knob web component. Form-associated, pointer and keyboard
accessible, themeable via CSS custom properties and ::part(), with a
built-in settings dialog and pluggable MIDI / CSS-binding add-ons.
The component has no dependency on any framework, store, or global object.
Use it with plain <input>-style events (input/change), or drop it into
a <form> and read it via FormData like any native control.
Install
npm install @tomhermans/range-knobimport "@tomhermans/range-knob";Or, with no build step at all, straight from a CDN:
<script type="module" src="https://cdn.jsdelivr.net/npm/@tomhermans/range-knob/range-knob.js"></script>Quick start
<label>
Volume
<range-knob min="0" max="100" value="50" suffix="%"></range-knob>
</label>
<script type="module">
import "@tomhermans/range-knob";
const knob = document.querySelector("range-knob");
knob.addEventListener("input", (e) => {
console.log(knob.value); // updates live while dragging
});
</script>Attributes
None of these are required — <range-knob></range-knob> with zero attributes
renders a complete, usable 0–100 knob (the classic 280° "volume knob" arc
with a gap at the bottom, tick marks, and min/mid/max labels). Set only what
you need to change.
| Attribute | Description | Default |
| -------------- | -------------------------------------------------------------------- | ------- |
| value | Current value | min |
| min | Minimum value | 0 |
| max | Maximum value | 100 |
| step | Increment for pointer/keyboard interaction | 1 |
| shift-step | Increment when a keyboard arrow is pressed with Shift held | step |
| start | Start angle of the track, in degrees | 220 |
| end | End angle of the track, in degrees | 500 |
| suffix | String appended to the displayed value (%, °, ...) | "" |
| decimals | Decimal places to display. Omit to derive it from step's precision | — |
| indices | Number of tick marks drawn around the ring. indices="0" explicitly turns them off | 11 |
| labels | "value:text,value:text,...". Leave empty/omit for auto-generated labels (see below) | — |
| label-count | How many labels to auto-generate when labels is empty | 3 |
| active-label | Value of the label to visually highlight (::part(active-label)) | — |
| enable-min | Presence-only. Adds a .at-min class (and :state(at-min)-style hook) when value equals min | — |
Auto-generated labels
If labels is empty or omitted, the component generates label-count
evenly-spaced labels across the current min/max itself — so they always
match the range, even after you change min/max later (via the settings
dialog or programmatically). Write an explicit labels string only when you
need custom text or non-uniform spacing (e.g. "100:0" for a wraparound
scale); a label whose value falls outside the current [min, max] is simply
not rendered, rather than being drawn at a wrong/wrapped position.
Properties
Attributes are for declarative initial config; use properties to read/write
at runtime — knob.min = 0 reflects straight to the attribute.
knob.value; // number
knob.value = 75;
knob.min = 0;
knob.max = 200;
knob.step = 0.5;
knob.shiftStep = 5;
knob.start = 220;
knob.end = 500;
knob.suffix = "%";
knob.decimals = 2; // or null to derive from stepEvents
| Event | Fires when | detail |
| ----------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| input | Value changes (drag, keyboard, or a dialog edit that forces a re-clamp) | — (read knob.value) |
| change | Same as input, dispatched right after it | — (read knob.value) |
| binding-changed | The built-in settings dialog is saved | { title, min, max, step, labels } |
| settings-dialog-open | Extension point: dialog is about to open | { form } — the internal <form> element |
| settings-dialog-save | Extension point: Save was clicked, dialog still open | { form } |
| settings-dialog-close | Extension point: dialog closed (Save, Cancel, or Escape) | — |
All events bubble and are composed: true, so they cross the shadow
boundary — listen on the element itself or on document via delegation.
input/change are the only events you need for the common case: sync the
knob into your own state (a plain object, Redux, Pinia, whatever) by
listening for them and reading knob.value; push external state back in by
setting knob.value or the value attribute.
The settings-dialog-* events exist so optional add-ons (see below) can
inject their own fields into the same gear-icon dialog without the component
knowing anything about what those fields mean.
Forms
The component is formAssociated. Give it a name inside a <form> and it
participates in FormData/form.requestSubmit() like a native control —
no extra wiring needed:
<form>
<range-knob name="volume" min="0" max="100" value="50"></range-knob>
</form>Styling
CSS custom properties
Set these on the element or a parent — see range-knob.js for the full
list (search for --range-knob-). The main ones:
range-knob {
--range-knob-maw: 96px; /* overall size */
--range-knob-track: #d0d0e6;
--range-knob-fill-start: #f00;
--range-knob-fill-end: #f00;
--range-knob-thumb-bg: #9595ac;
--range-knob-indice-c: #999;
--range-knob-labels-c: #333;
--range-knob-output-fs: 1.2rem; /* value display font size */
}CSS Shadow Parts
For structural overrides beyond custom properties:
range-knob::part(track) { }
range-knob::part(fill) { }
range-knob::part(thumb) { }
range-knob::part(indices) { }
range-knob::part(labels) { }
range-knob::part(settings-btn) { }
range-knob::part(dialog) { }
range-knob::part(active-label) { }Settings dialog
Every knob has a gear icon that opens a small built-in dialog for editing
Title/Min/Max/Step/Labels at runtime, saved via the binding-changed event.
This is entirely optional to use — ignore the gear icon if you don't want
end users editing config, or hide it with range-knob::part(settings-btn) { display: none; }.
Add-ons
The core component ships with zero knowledge of MIDI or CSS-variable
binding. Both are optional add-ons that hook into the settings-dialog-*
events to inject their own fields into the same dialog.
Target binding
Wire a knob's value to a CSS custom property or attribute on any element:
import { bindTarget, attachTargetSettings } from "@tomhermans/range-knob/addons/target-binding";
const knob = document.querySelector("range-knob");
// Low-level: bind once, no settings UI.
bindTarget(knob, { type: "css-var", prop: "--hue", elem: ":root" });
// Or give the knob an editable "Target" section in its own settings dialog,
// backed by your own state (get/set) rather than the add-on's own storage.
attachTargetSettings(knob, {
get: () => myConfig.target,
set: (value) => { myConfig.target = value; },
});MIDI learn
Map a knob to a MIDI CC number, with a "Learn" button injected into the settings dialog:
import { initWebMIDI, attachMidiSettings } from "@tomhermans/range-knob/addons/midi-learn";
const midiBus = initWebMIDI();
const knob = document.querySelector("range-knob");
attachMidiSettings(knob, midiBus, {
get: () => ({ cc: myConfig.midiCC, controlType: myConfig.controlType }),
set: ({ cc, controlType }) => {
myConfig.midiCC = cc;
myConfig.controlType = controlType;
},
});
midiBus.subscribe(({ cc, value }) => {
if (cc === myConfig.midiCC) knob.value = /* map 0-127 to your range */;
});See demo/app.js for a complete, working example wiring
several knobs to a shared config object, localStorage persistence, and
both add-ons together.
Demo
git clone https://github.com/tomhermans/range-knob.git
cd range-knob
npx serve demoThen open the printed localhost URL — module imports need http(s)://, not file://.
Browser support
Needs customElements, ElementInternals (form association), and
CSSStyleSheet.replaceSync/adoptedStyleSheets — current Chrome, Edge,
Firefox, and Safari. No IE11, no polyfill included.
License
MIT
