npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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-knob
import "@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 step

Events

| 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 demo

Then 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