Theme
Design your own
On this page (13)
Component Skill — components/knob/component-skill.md

Native basis

<input type="range" class="knob-input"> in a <div class="knob">: the value in the knob's units (min, max, step, value, name), submitted and reset by its form, named by its <label for>, read as a slider. The runtime draws the dial over it; without scripting it is the plain range.

Web Platform APIs

<input type="range">Pointer Eventsconic-gradient()Intl.NumberFormat<output>@media (scripting)

Classes

.knob.knob-input.knob-label.knob-value.knob-rings

Data attributes

• data-scale - linear, exponential, logarithmic, power, decibel or a registered one; data-exponent - the curve of power and decibel

• data-format - number, si, percent, time, signed or a registered one; data-unit, data-decimals

• data-origin - where the value arc starts (0 for pan); data-default - the value a reset goes back to

• data-ticks - a mark per step of a stepped knob; data-ticks="5" - that many over the turn

• data-radius - the precision radius in px; data-travel - the drag in px for the whole turn at full speed

• data-size - sm, lg; data-variant="flat"; data-color - chart-1 ... chart-5, destructive; --knob-reserve - ring slots kept free, so a row lines up

In script.knob-rings: an array of rings - id, label, amount (-1 to 1 of the turn), bipolar, color; df$.shadcn.knob.modulate(knob, { id: output }) runs them

Pointer and keys

• Drag left / right - turn it; the farther from the knob, the finer; Shift - ten times finer; Escape - back to where the drag started

• Double click, Ctrl / Cmd + click, Delete - the default

• Double click the value, Enter - type it in (a Number Input, xs): Enter sets it, Escape keeps the old one

• Right / Up, Left / Down - a step (the input's, else 1 % of the turn; Shift: 0.1 %); Page Up / Page Down - 10 %; Home / End - the ends

§Knob

A filter cutoff from 20 Hz to 20 kHz on an exponential scale: every decade turns as far, so 200 Hz, 2 kHz and 20 kHz sit at a third, two thirds and the end of the turn. Drag left or right to turn it - near the knob it follows the pointer fast; move away from it (the dashed circle is the radius it turns at full speed) and the same move turns it finer. Double click the knob to reset it to 1 kHz, double click the value to type one in (Enter sets it, Escape keeps the old one). Its two rings are the sources that modulate it: LFO 1 swings it both ways by 15 % of the turn, Envelope 2 opens it by 30 %.

§Precision

A fine tune in cents from its centre (data-origin='0'), with a small precision radius (data-radius='48') and a long travel (data-travel='400'): within 48 px of the knob a 400 px move turns it from end to end; at 96 px half as far, at 192 px a quarter - set it to a tenth of a cent by moving away first. Shift slows any drag ten times; Escape puts it back where the drag started.

§Scales

Six scales over the same turn: a mix from 0 to 1 written as a percentage (linear), a frequency (exponential, written with a metric prefix), a feedback that turns fast first and fine at the end (logarithmic), an attack time on a power curve (data-exponent='3': fine for short times, written in ms and s), a gain in decibels on an audio taper, and semitones on whole steps (step='1') that fill from 0. The value always snaps to the input's step after the scale.

§Steps

Stepped knobs move step by step - a drag snaps as it goes, an arrow key moves one step, a typed value settles on the nearest. data-ticks marks every step between the value ring and the face: octaves and semitones from their centre, voices, a waveform switch (its four steps written as names by a format the page adds). data-ticks='5' marks the quarters of a mix in 5 % steps; on an exponential delay from 1 ms to 1 s in milliseconds, data-ticks='4' marks the decades.

§Automation rings

Three sources around one cutoff: LFO 1 swings it both ways (bipolar), Envelope 1 opens it, Macro 1 closes it (a negative amount). Each ring shows its range around the value; run the sources and each ring shows how far its source moves the value now, while the value ring marks the effective value - the knob's value moved by all of them (df$.shadcn.knob.modulate returns it). Turn the knob while they run: the rings move with it.

§Sizes, colours and states

data-size='sm' and 'lg' around the default size, data-color for the value arc (a chart colour, destructive), data-variant='flat' without the face, and an input authored disabled - the knob starts in its disabled state: no drag, no keys, and a form leaves it out.

§Custom scales and formats

A page adds scales and formats by name. The crossfade turns on a smoothstep curve (data-scale='smooth'): fine at both ends, fast through the middle. The pan writes L20, C and R20 (data-format='pan'). Knobs already on a scale or a format draw again when it is added.

§A synth panel

An oscillator in a Card: octave, semitones and voices on whole steps, a fine tune and a pan that fill from their centre, a level in decibels. LFO 2 modulates the detune and the pan - one source, a ring on each knob it reaches - and Envelope 1 the level: run them and the rings move together. The row keeps a ring slot free on every knob (--knob-reserve: 1), so the knobs with a ring and without one line up.

§In a form

The input is the form control: it submits its value under its name, in the knob's units, and a reset puts the authored value back. The gain snaps to half decibels (step='0.5'), the mix to hundredths.

§States

Named states via the shared State API, bound on each .knob:

  • default - at rest: the pointer and the arc from the origin show the value, the readout writes it
  • adjusting - being turned: a drag enters it and returns to default when it ends; the precision radius and the circle through the pointer show around the dial
  • disabled - the input is disabled: no drag, no keys, and a form leaves it out

Every state takes { value }, merged into the current config - the value in the knob's units, settled on its range and step; getState() reports the live value, also one a drag or a key set. A drag sends the input's input events and knob-change as it goes and change once when it ends.

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/knob-{state}.png.

Machine contract - verified against knob.schema.json by bun run verify:

StateTypeValuesDefaultDescription
valuenumber——The value in the knob's units, settled on its range and step; observed from getState().config.value.
adjustingbooleantrue, falsefalseBeing turned (setState('adjusting')): the precision radius shows around the dial.
disabledbooleantrue, falsefalseThe input is disabled (setState('disabled')): no drag, no keys.

§API

Generated from knob.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type KnobState = 'default' | 'adjusting' | 'disabled' - setState(name, config) takes the config of the state it names.

StateDescription
default
At rest: the pointer and the arc from the origin show the value, the readout writes it.
Config fieldTypeDescription
value?numberthe value in the knob's units, between the input's min and max and on its step; getState() reports the live one
adjusting
Being turned: the precision radius and the pointer's reach show around the dial - a drag enters it and returns to default when it ends.
Config fieldTypeDescription
value?numberthe value in the knob's units, between the input's min and max and on its step; getState() reports the live one
disabled
Disabled: the input is disabled - no drag, no keys, and a form leaves it out.
Config fieldTypeDescription
value?numberthe value in the knob's units, between the input's min and max and on its step; getState() reports the live one

Every element

MemberDescription
el.api.setState<S extends KnobState>(name: S, config?: KnobStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?KnobStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: KnobState; config: KnobStateConfigs[KnobState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.knobApi.setState<S extends KnobState>(el: HTMLElement, name: S, config?: KnobStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?KnobStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.knobApi.getState(el: HTMLElement): { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.knobApi.render(state: { name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: KnobState; config: KnobStateConfigs[KnobState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.knobApi.store(el: HTMLElement): Store<{ name: KnobState; config: KnobStateConfigs[KnobState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: KnobState; config: KnobStateConfigs[KnobState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.knobApi.commit<S extends KnobState>(el: HTMLElement, name: S, config?: KnobStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?KnobStateConfigs[S]its config
df$.shadcn.knobStates: KnobState[]The declared states, 'default' first: default, adjusting, disabled.

df$.shadcn.knob

MemberDescription
value(target: string | HTMLElement, value?: number): number
The knob's value, or set it (setState with { value }: settled on the range and step, the change told).
ArgumentTypeDescription
targetstring | HTMLElementthe .knob element, its input, or a selector of either
value?numberthe value to set; leave it out to read

Returns number - the value now; NaN for an unknown element

position(target: string | HTMLElement): number
Where the value stands on the knob's turn.
ArgumentTypeDescription
targetstring | HTMLElementthe .knob element, its input, or a selector of either

Returns number - the position, 0 (the start of the turn) to 1 (its end); NaN for an unknown element

reset(target: string | HTMLElement): number
Back to the default value (data-default, else the input's authored value) - what a double click does.
ArgumentTypeDescription
targetstring | HTMLElementthe .knob element, its input, or a selector of either

Returns number - the value now; NaN for an unknown element

rings(target: string | HTMLElement, list?: KnobRing[]): KnobRing[]
The rings around the knob, or replace them - the live values of the sources that stay are kept.
ArgumentTypeDescription
targetstring | HTMLElementthe .knob element, its input, or a selector of either
list?KnobRing[]the new rings; leave it out to read

Returns KnobRing[] - a copy of the rings now

modulate(target: string | HTMLElement, values: Record<string, number | null> | null): number
The live outputs of the sources - each ring shows how far its source moves the knob now, the value ring the effective value. A unipolar source runs from 0 to 1, a bipolar one from -1 to 1.
ArgumentTypeDescription
targetstring | HTMLElementthe .knob element, its input, or a selector of either
valuesRecord<string, number | null> | nullthe outputs by ring id (null for a ring: no live value); null: none at all

Returns number - the effective value - the knob's value moved by every live source, inside its range

scale(name: string, scale: KnobScale): void
Add a scale knobs name in data-scale - the knobs on it are drawn again.
ArgumentTypeDescription
namestringthe scale's name
scaleKnobScaleits value() and position(), each the inverse of the other
format(name: string, format: (value: number, context: KnobFormatContext) => string): void
Add a format knobs name in data-format - the knobs in it write their values again.
ArgumentTypeDescription
namestringthe format's name
format(value: number, context: KnobFormatContext) => stringthe value's text, from the value and the knob's unit, decimals, locale and range

Events

EventDescription
knob-change
Fires when the value changes - while a drag goes on (committed: false) and when a change is done.

detail: KnobChangeDetail

FieldTypeDescription
valuenumberthe value
positionnumberits position on the turn, 0 to 1
textstringthe value as the readout writes it ("1.20 kHz")
committedbooleantrue when the change is done (a drag ended, a key, a reset, setState), false while a drag goes on

Types

TypeDescription
KnobChangeDetail
What knob-change carries.
FieldTypeDescription
valuenumberthe value
positionnumberits position on the turn, 0 to 1
textstringthe value as the readout writes it ("1.20 kHz")
committedbooleantrue when the change is done (a drag ended, a key, a reset, setState), false while a drag goes on
KnobConfig
The config every state takes.
FieldTypeDescription
value?numberthe value in the knob's units, between the input's min and max and on its step; getState() reports the live one
KnobFormatContext
What a format gets besides the value.
FieldTypeDescription
unitstringthe unit (data-unit), "" without one
decimalsnumber | nullthe fraction digits asked for (data-decimals), null for the format's own
localestringthe locale numbers are written in
minnumberthe value at the start of the turn
maxnumberthe value at the end of the turn
stepnumber | nullthe input's step, null for any
KnobRange
What a scale maps between.
FieldTypeDescription
minnumberthe value at the start of the turn - the input's min
maxnumberthe value at the end of the turn - the input's max
exponentnumberthe curve of the power and decibel scales (data-exponent)
KnobRing
A ring around the knob: an automation or modulation source that moves its value.
FieldTypeDescription
idstringunique on the knob - modulate() names the source by it
label?stringthe source's name ("LFO 1") - the ring's title and its description for assistive technology
amountnumberhow far the source moves the knob, as a share of its turn: -1 to 1 (0.25 = a quarter of the turn up)
color?stringa theme colour (chart-1 ... chart-5, primary, destructive) or a CSS colour; default the chart colour of its number
bipolar?booleanthe source swings both ways around the value (an LFO), not away from it (an envelope)
KnobScale
A scale: how a position on the knob's turn (0 to 1) maps to a value, and back.
FieldTypeDescription
value(position: number, range: KnobRange) => numberthe value at a position of the turn
position(value: number, range: KnobRange) => numberthe position of a value - the inverse of value()

§CSS view file

The dial: every arc a conic-gradient masked down to a ring, painted from the positions the runtime writes (the value, the origin, each ring's range and live value, the effective value); the face, the pointer, the precision circles, sizes and colours.

/* -- Knob component ---------------------------------------------- */
/* A rotary control over a native range input: the dial (drawn by the */
/* runtime) turns 270 degrees from 7:30 to 4:30. Every arc is a       */
/* conic-gradient masked down to a ring - the value ring innermost,   */
/* a ring per modulation source around it - and the positions come in */
/* as custom properties (0 to 1) the runtime writes on the dial.      */
@layer components {
  .knob {
    --knob-size: 3rem;
    --knob-thickness: 0.25rem;
    --knob-ring-thickness: 0.1875rem;
    --knob-ring-gap: 0.125rem;
    --knob-color: var(--primary);
    --knob-track-color: var(--muted);
    /* the room between the value ring and the face - the marks of the steps sit in it */
    --knob-face-gap: calc(var(--knob-size) * 0.07);
    position: relative;
    display: inline-grid;
    justify-items: center;
    align-content: start;
    gap: 0.25rem;
    inline-size: max-content;
    color: var(--foreground);
    font-size: 0.75rem;
    line-height: 1.2;
    text-align: center;
    -webkit-user-select: none;
    user-select: none;
    &[data-size="sm"] {
      --knob-size: 2.25rem;
      --knob-thickness: 0.1875rem;
      --knob-ring-thickness: 0.125rem;
      --knob-ring-gap: 0.09375rem;
      font-size: 0.6875rem;
    }
    &[data-size="lg"] {
      --knob-size: 4.5rem;
      --knob-thickness: 0.3125rem;
      --knob-ring-thickness: 0.25rem;
      --knob-ring-gap: 0.125rem;
      font-size: 0.8125rem;
    }
    &[data-color="chart-1"] {
      --knob-color: var(--chart-1);
    }
    &[data-color="chart-2"] {
      --knob-color: var(--chart-2);
    }
    &[data-color="chart-3"] {
      --knob-color: var(--chart-3);
    }
    &[data-color="chart-4"] {
      --knob-color: var(--chart-4);
    }
    &[data-color="chart-5"] {
      --knob-color: var(--chart-5);
    }
    &[data-color="destructive"] {
      --knob-color: var(--destructive);
    }
    /* the one being turned: above its neighbours, so its precision circles are not cut */
    &[data-state-name="adjusting"] {
      z-index: 2;
    }
    &:has(.knob-input:disabled) {
      opacity: 0.5;
      .knob-dial {
        cursor: not-allowed;
      }
    }
    &[data-ticks] {
      --knob-face-gap: calc(var(--knob-size) * 0.15);
    }
  }
  /* the input is the form control, the keyboard target and the slider assistive technology reads - drawn over by
     the dial once the runtime runs; without scripting it is the plain range */
  @media (scripting: enabled) {
    .knob-input {
      position: absolute;
      inline-size: 1px;
      block-size: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      border: 0;
      white-space: nowrap;
      clip-path: inset(50%);
    }
  }
  .knob-label {
    color: var(--muted-foreground);
    font-weight: 500;
    text-wrap: balance;
  }
  /* the value never sizes the knob: contained, a longer text ("-60.0 dB" after "0.0 dB") spills out on both sides
     and the dial stays where it is; a double click types the value in */
  .knob-value {
    display: flex;
    justify-content: center;
    justify-self: stretch;
    contain: inline-size;
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    cursor: text;
  }
  .knob:not(:has(.knob-input:disabled)) .knob-value:hover {
    text-decoration: underline dotted;
    text-underline-offset: 3px;
  }
  /* the editor: a Number Input (xs) laid over the readout - the knob keeps its size while it is open */
  .knob-editor {
    position: absolute;
    left: 50%;
    z-index: 3;
    inline-size: 6.5rem;
    translate: -50% -50%;
  }
  /* the dial: the knob's face, the value ring and the rings of the sources around it; geometry is physical - a knob
     turns clockwise in any writing direction */
  .knob-dial {
    --knob-step: calc(var(--knob-ring-thickness) + var(--knob-ring-gap));
    /* the ring slots: its rings, or as many as --knob-reserve keeps free - knobs in a row line up */
    --knob-slots: max(var(--knob-rings, 0), var(--knob-reserve, 0));
    --knob-dial-size: calc(var(--knob-size) + var(--knob-slots) * 2 * var(--knob-step));
    --knob-radius: var(--knob-dial-size);
    position: relative;
    display: block;
    inline-size: var(--knob-dial-size);
    aspect-ratio: 1;
    border-radius: 50%;
    direction: ltr;
    cursor: ew-resize;
    touch-action: none;
    /* the precision of a drag: the radius (dashed) within which it turns at full speed, and the circle through the
       pointer - the farther out, the finer */
    &::before,
    &::after {
      position: absolute;
      top: 50%;
      left: 50%;
      aspect-ratio: 1;
      border-radius: 50%;
      content: "";
      opacity: 0;
      pointer-events: none;
      translate: -50% -50%;
      transition: opacity 120ms ease;
    }
    &::before {
      inline-size: calc(var(--knob-reach, 0px) * 2);
      border: 1px solid color-mix(in oklab, var(--knob-color) 45%, transparent);
    }
    &::after {
      inline-size: calc(var(--knob-radius) * 2);
      border: 1px dashed color-mix(in oklab, var(--knob-color) 70%, transparent);
    }
  }
  .knob[data-state-name="adjusting"] .knob-dial {
    &::before,
    &::after {
      opacity: 1;
    }
  }
  .knob:has(.knob-input:focus-visible) .knob-dial {
    outline: 2px solid var(--ring);
    outline-offset: 2px;
  }
  /* the value ring: the track, the arc from the origin to the value, and - while sources move it - a mark at the
     effective value */
  .knob-track,
  .knob-ring {
    position: absolute;
    border-radius: 50%;
    -webkit-mask: radial-gradient(
      farthest-side,
      transparent calc(100% - var(--knob-w) - 0.5px),
      #000 calc(100% - var(--knob-w))
    );
    mask: radial-gradient(
      farthest-side,
      transparent calc(100% - var(--knob-w) - 0.5px),
      #000 calc(100% - var(--knob-w))
    );
  }
  .knob-track {
    --knob-w: var(--knob-thickness);
    inset: calc(var(--knob-slots) * var(--knob-step));
    background:
      conic-gradient(
        from -135deg,
        transparent 0 calc(min(var(--knob-p, 0), var(--knob-o, 0)) * 270deg),
        var(--knob-color) 0 calc(max(var(--knob-p, 0), var(--knob-o, 0)) * 270deg),
        transparent 0
      ),
      conic-gradient(from -135deg, var(--knob-track-color) 0 270deg, transparent 0);
    &::after {
      position: absolute;
      inset: 0;
      display: none;
      border-radius: 50%;
      background: conic-gradient(
        from -135deg,
        transparent 0 calc(var(--knob-e, 0) * 270deg - 3deg),
        var(--foreground) 0 calc(var(--knob-e, 0) * 270deg + 3deg),
        transparent 0
      );
      content: "";
    }
  }
  .knob-dial[data-modulated] .knob-track::after {
    display: block;
  }
  /* a source's ring: its range around the value, and - while it runs - the arc to where it moves the value now */
  .knob-ring {
    /* the runtime writes each ring's own colour */
    --knob-ring-color: var(--chart-1);
    --knob-w: var(--knob-ring-thickness);
    inset: calc((var(--knob-slots) - var(--knob-ring, 1)) * var(--knob-step));
    background:
      conic-gradient(
        from -135deg,
        transparent 0 calc(var(--knob-from, 0) * 270deg),
        color-mix(in oklab, var(--knob-ring-color) 80%, transparent) 0 calc(var(--knob-to, 0) * 270deg),
        transparent 0
      ),
      conic-gradient(from -135deg, color-mix(in oklab, var(--muted) 70%, transparent) 0 270deg, transparent 0);
    &[data-live] {
      background:
        conic-gradient(
          from -135deg,
          transparent 0 calc(var(--knob-at-lo, 0) * 270deg),
          var(--knob-ring-color) 0 calc(var(--knob-at-hi, 0) * 270deg),
          transparent 0
        ),
        conic-gradient(
          from -135deg,
          transparent 0 calc(var(--knob-from, 0) * 270deg),
          color-mix(in oklab, var(--knob-ring-color) 35%, transparent) 0 calc(var(--knob-to, 0) * 270deg),
          transparent 0
        ),
        conic-gradient(from -135deg, color-mix(in oklab, var(--muted) 70%, transparent) 0 270deg, transparent 0);
    }
  }
  /* the knob's face and its pointer, turned to the value */
  .knob-face {
    position: absolute;
    inset: calc(var(--knob-slots) * var(--knob-step) + var(--knob-thickness) + var(--knob-face-gap));
    border: 1px solid var(--border);
    border-radius: 50%;
    background: linear-gradient(to bottom, color-mix(in oklab, var(--secondary) 80%, var(--card)), var(--card));
    box-shadow: var(--shadow-sm);
  }
  .knob-pointer {
    position: absolute;
    inset: 0;
    rotate: calc(var(--knob-p, 0) * 270deg - 135deg);
    &::before {
      position: absolute;
      top: 10%;
      left: calc(50% - 1px);
      inline-size: 2px;
      block-size: 32%;
      border-radius: 1px;
      background-color: var(--foreground);
      content: "";
    }
  }
  /* the marks of the steps (data-ticks), between the value ring and the face */
  .knob-tick {
    position: absolute;
    inset: calc(var(--knob-slots) * var(--knob-step));
    pointer-events: none;
    rotate: calc(var(--knob-t, 0) * 270deg - 135deg);
    &::before {
      position: absolute;
      top: calc(var(--knob-thickness) + 1px);
      left: calc(50% - 0.5px);
      inline-size: 1px;
      block-size: calc(var(--knob-face-gap) - 2px);
      background-color: var(--muted-foreground);
      content: "";
    }
  }
  /* flat: no face - the rings and the pointer only */
  .knob[data-variant="flat"] .knob-face {
    border-color: transparent;
    background: none;
    box-shadow: none;
  }
  .knob-rings-text {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  @media (prefers-reduced-motion: reduce) {
    .knob-dial::before,
    .knob-dial::after {
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .knob {
      --knob-track-color: var(--muted-foreground);
    }
    .knob-face {
      border-color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    .knob-dial {
      --knob-color: Highlight;
      --knob-track-color: GrayText;
      forced-color-adjust: none;
    }
    .knob-face {
      border-color: CanvasText;
      background: Canvas;
    }
    .knob-pointer::before {
      background: CanvasText;
    }
    .knob-tick::before {
      background: GrayText;
    }
  }
}

§JavaScript view file

The scales and formats (and the registries a page adds to), the value settled on the range and step, the rings, the drag whose speed is the radius over the distance, the keys, the events and the State API.

// -- Knob ------------------------------------------------------
// A rotary control over a native <input type="range"> - the knobs of an audio plugin: the value on any scale (linear,
// exponential, logarithmic, power, decibel, or one the page registers), drawn as an arc from its origin and a pointer,
// rings around it for the automation and modulation sources that move it (their range and their live value), and a
// drag whose precision follows the distance from the knob: within its radius a horizontal move turns it fast, farther
// away slower and finer. The input stays the form control, the keyboard target and the accessible slider; the runtime
// draws the dial over it.
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent, textLocale } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const knobStates = ['default', 'adjusting', 'disabled'];
/** The config every state takes. */
export interface KnobConfig {
  /** the value in the knob's units, between the input's min and max and on its step; getState() reports the live one */
  value?: number;
}
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state. */
export interface KnobStateConfigs {
  /** At rest: the pointer and the arc from the origin show the value, the readout writes it. */
  default: KnobConfig;
  /** Being turned: the precision radius and the pointer's reach show around the dial - a drag enters it and returns to default when it ends. */
  adjusting: KnobConfig;
  /** Disabled: the input is disabled - no drag, no keys, and a form leaves it out. */
  disabled: KnobConfig;
}
/** A ring around the knob: an automation or modulation source that moves its value. */
export interface KnobRing {
  /** unique on the knob - modulate() names the source by it */
  id: string;
  /** the source's name ("LFO 1") - the ring's title and its description for assistive technology */
  label?: string;
  /** how far the source moves the knob, as a share of its turn: -1 to 1 (0.25 = a quarter of the turn up) */
  amount: number;
  /** a theme colour (chart-1 ... chart-5, primary, destructive) or a CSS colour; default the chart colour of its number */
  color?: string;
  /** the source swings both ways around the value (an LFO), not away from it (an envelope) */
  bipolar?: boolean;
}
/** What a scale maps between. */
export interface KnobRange {
  /** the value at the start of the turn - the input's min */
  min: number;
  /** the value at the end of the turn - the input's max */
  max: number;
  /** the curve of the power and decibel scales (data-exponent) */
  exponent: number;
}
/** A scale: how a position on the knob's turn (0 to 1) maps to a value, and back. */
export interface KnobScale {
  /** the value at a position of the turn */
  value: (position: number, range: KnobRange) => number;
  /** the position of a value - the inverse of value() */
  position: (value: number, range: KnobRange) => number;
}
/** What a format gets besides the value. */
export interface KnobFormatContext {
  /** the unit (data-unit), "" without one */
  unit: string;
  /** the fraction digits asked for (data-decimals), null for the format's own */
  decimals: number | null;
  /** the locale numbers are written in */
  locale: string;
  /** the value at the start of the turn */
  min: number;
  /** the value at the end of the turn */
  max: number;
  /** the input's step, null for any */
  step: number | null;
}
/** What knob-change carries. */
export interface KnobChangeDetail {
  /** the value */
  value: number;
  /** its position on the turn, 0 to 1 */
  position: number;
  /** the value as the readout writes it ("1.20 kHz") */
  text: string;
  /** true when the change is done (a drag ended, a key, a reset, setState), false while a drag goes on */
  committed: boolean;
}
/** a drag: the pointer, where it was, the position it moves (unrounded), the value it started from */
type Drag = { id: number; x: number; p: number; start: number; cx: number; cy: number; radius: number };
/** per-element runtime (state lives on the element) */
type Runtime = {
  input: HTMLInputElement;
  dial: HTMLElement;
  value: number;
  p: number;
  /** the value of the last change event - change fires when a committed value differs from it */
  committed: number;
  rings: KnobRing[];
  live: Map<string, number>;
  drag: Drag | null;
  /** set while the runtime dispatches the input's events - its own input event is not a change from outside */
  writing: boolean;
  /** the Number Input over the readout while the value is typed in */
  editor: HTMLElement | null;
};
/** the slowest a far drag gets, as a share of the full speed */
const MIN_SPEED = 0.02;
/** the keys' steps on the turn: an arrow, with Shift, a page */
const KEY_STEP = 0.01;
const FINE_STEP = 0.001;
const PAGE_STEP = 0.1;
/** the most marks a dial draws - a finer step draws none */
const MAX_TICKS = 49;
/** the theme colours a ring names */
const THEME_COLORS = /^(chart-[1-5]|primary|destructive|foreground|muted-foreground)$/;
let rings = 0;
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
const num = (v: unknown, fallback: number): number => {
  const n = typeof v === 'number' ? v : v === null || v === undefined || v === '' ? NaN : Number(v);
  return Number.isFinite(n) ? n : fallback;
};
const esc = (s: string): string =>
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c);
/** no float noise in a value: 12 significant digits */
const tidy = (v: number): number => Number(v.toPrecision(12));
const round4 = (v: number): string => String(Math.round(v * 10000) / 10000);
const sameSign = (r: KnobRange): boolean => r.min !== 0 && r.max !== 0 && Math.sign(r.min) === Math.sign(r.max);
const linear: KnobScale = {
  value: (p, r) => r.min + p * (r.max - r.min),
  position: (v, r) => (r.max === r.min ? 0 : (v - r.min) / (r.max - r.min)),
};
/** the scales by name - a page adds its own with df$.shadcn.knob.scale() */
const SCALES: Record<string, KnobScale> = {
  linear,
  // equal ratios per share of the turn - frequency, time: from 20 Hz to 20 kHz every decade turns as far
  exponential: {
    value: (p, r) => (sameSign(r) ? r.min * Math.pow(r.max / r.min, p) : linear.value(p, r)),
    position: (v, r) => (sameSign(r) ? Math.log(v / r.min) / Math.log(r.max / r.min) : linear.position(v, r)),
  },
  // the mirror of exponential: fast at the start of the turn, fine at its end
  logarithmic: {
    value: (p, r) => (sameSign(r) ? r.min + r.max - r.min * Math.pow(r.max / r.min, 1 - p) : linear.value(p, r)),
    position: (v, r) =>
      sameSign(r) ? 1 - Math.log((r.min + r.max - v) / r.min) / Math.log(r.max / r.min) : linear.position(v, r),
  },
  // a curve: exponent 2 (default) is fine at the start, 0.5 fine at the end
  power: {
    value: (p, r) => r.min + (r.max - r.min) * Math.pow(p, r.exponent),
    position: (v, r) => Math.pow(clamp(linear.position(v, r), 0, 1), 1 / r.exponent),
  },
  // a gain in dB on an audio taper - the amplitude grows with the turn to the power of the exponent (3, default): the
  // turn's start is the min (-60 dB), its end the max
  decibel: {
    value: (p, r) => {
      const floor = Math.pow(10, (r.min - r.max) / (20 * r.exponent));
      return r.max + 20 * r.exponent * Math.log10(floor + (1 - floor) * p);
    },
    position: (v, r) => {
      const floor = Math.pow(10, (r.min - r.max) / (20 * r.exponent));
      return (Math.pow(10, (v - r.max) / (20 * r.exponent)) - floor) / (1 - floor);
    },
  },
};
const fixed = (locale: string, digits: number) =>
  new Intl.NumberFormat(locale, { minimumFractionDigits: digits, maximumFractionDigits: digits });
const three = (locale: string) =>
  new Intl.NumberFormat(locale, { minimumSignificantDigits: 3, maximumSignificantDigits: 3 });
/** the fraction digits a value needs when the knob does not say: its step's, else fewer the wider its range */
const autoDecimals = (c: KnobFormatContext): number => {
  if (c.step !== null) return Math.min(6, (String(c.step).split('.')[1] ?? '').length);
  const span = Math.abs(c.max - c.min);
  return span >= 100 ? 0 : span >= 10 ? 1 : 2;
};
const withUnit = (text: string, unit: string): string => (unit ? `${text} ${unit}` : text);
const SI: [number, string][] = [
  [1e9, 'G'],
  [1e6, 'M'],
  [1e3, 'k'],
  [1, ''],
  [1e-3, 'm'],
];
/** the formats by name - a page adds its own with df$.shadcn.knob.format() */
const FORMATS: Record<string, (value: number, context: KnobFormatContext) => string> = {
  number: (v, c) => withUnit(fixed(c.locale, c.decimals ?? autoDecimals(c)).format(v), c.unit),
  // with a metric prefix and three significant digits: 440 Hz, 1.20 kHz, 500 mHz
  si: (v, c) => {
    const a = Math.abs(v);
    const [f, prefix] = a === 0 ? [1, ''] : (SI.find(([x]) => a >= x) ?? SI[SI.length - 1]);
    const n = c.decimals === null ? three(c.locale) : fixed(c.locale, c.decimals);
    return withUnit(n.format(v / f), `${prefix}${c.unit}`);
  },
  // a fraction as a percentage: 0.5 is 50 %
  percent: (v, c) =>
    new Intl.NumberFormat(c.locale, {
      style: 'percent',
      minimumFractionDigits: c.decimals ?? 0,
      maximumFractionDigits: c.decimals ?? 0,
    }).format(v),
  // seconds: below one in milliseconds - 250 ms, 1.20 s
  time: (v, c) => {
    const n = c.decimals === null ? three(c.locale) : fixed(c.locale, c.decimals);
    return Math.abs(v) < 1 ? `${n.format(v * 1000)} ms` : `${n.format(v)} s`;
  },
  // always with its sign: +12 st, -3.0 dB
  signed: (v, c) => {
    const d = c.decimals ?? autoDecimals(c);
    const text = new Intl.NumberFormat(c.locale, {
      minimumFractionDigits: d,
      maximumFractionDigits: d,
      signDisplay: 'exceptZero',
    }).format(v);
    return withUnit(text, c.unit);
  },
};
const runtimeOf = (el: HTMLElement): Runtime => el._knob;
const inputOf = (el: Element): HTMLInputElement | undefined =>
  dfDollar(el).find<HTMLInputElement>('input.knob-input').get(0);
/** the knob's range: the input's min and max, the scale's exponent */
function rangeOf(el: HTMLElement, input: HTMLInputElement): KnobRange {
  const scale = el.getAttribute('data-scale') ?? 'linear';
  const exponent = num(el.getAttribute('data-exponent'), scale === 'decibel' ? 3 : 2);
  return {
    min: num(input.getAttribute('min'), 0),
    max: num(input.getAttribute('max'), 100),
    exponent: exponent > 0 ? exponent : 1,
  };
}
const scaleOf = (el: HTMLElement): KnobScale => SCALES[el.getAttribute('data-scale') ?? 'linear'] ?? linear;
/** the value at a position of the turn, on the knob's scale - a scale that fails is linear */
function valueAt(el: HTMLElement, p: number): number {
  const r = rangeOf(el, runtimeOf(el).input);
  let v = NaN;
  try {
    v = scaleOf(el).value(clamp(p, 0, 1), r);
  } catch {
    v = NaN;
  }
  return Number.isFinite(v) ? v : linear.value(clamp(p, 0, 1), r);
}
/** the position of a value on the turn, 0 to 1 */
function positionAt(el: HTMLElement, v: number): number {
  const r = rangeOf(el, runtimeOf(el).input);
  let p = NaN;
  try {
    p = scaleOf(el).position(v, r);
  } catch {
    p = NaN;
  }
  return clamp(Number.isFinite(p) ? p : linear.position(v, r), 0, 1);
}
/** a value the knob can take: inside its range, on the input's step (step="any": any) */
function settle(el: HTMLElement, v: number): number {
  const input = runtimeOf(el).input;
  const r = rangeOf(el, input);
  const lo = Math.min(r.min, r.max);
  const hi = Math.max(r.min, r.max);
  let out = clamp(Number.isFinite(v) ? v : lo, lo, hi);
  const step = Number(input.getAttribute('step'));
  if (step > 0) out = clamp(r.min + Math.round((out - r.min) / step) * step, lo, hi);
  return tidy(out);
}
/** the value as the readout writes it - the knob's format, its unit and decimals, in its locale */
function textOf(el: HTMLElement, v: number): string {
  const input = runtimeOf(el).input;
  const r = rangeOf(el, input);
  const d = Number.parseInt(el.getAttribute('data-decimals') ?? '', 10);
  const context: KnobFormatContext = {
    unit: el.getAttribute('data-unit') ?? '',
    decimals: Number.isFinite(d) && d >= 0 ? Math.min(d, 20) : null,
    locale: textLocale(el),
    min: r.min,
    max: r.max,
    step: Number(input.getAttribute('step')) > 0 ? Number(input.getAttribute('step')) : null,
  };
  const format = FORMATS[el.getAttribute('data-format') ?? 'number'] ?? FORMATS.number;
  try {
    return String(format(v, context));
  } catch {
    return FORMATS.number(v, context);
  }
}
/** the rings as the data writes them: an id, an amount from -1 to 1, a label, a colour, bipolar */
function ringsOf(raw: unknown): KnobRing[] {
  if (!Array.isArray(raw)) return [];
  const seen = new Set<string>();
  return raw
    .filter((r) => r && typeof r === 'object' && r.id != null && !seen.has(String(r.id)) && seen.add(String(r.id)))
    .map((r) => ({
      id: String(r.id),
      label: r.label == null ? String(r.id) : String(r.label),
      amount: clamp(num(r.amount, 0), -1, 1),
      ...(r.color == null ? {} : { color: String(r.color) }),
      bipolar: r.bipolar === true,
    }));
}
/** a ring's colour: a theme colour by name, a CSS colour the browser takes, else the chart colour of its number */
function colorOf(ring: KnobRing, i: number): string {
  const c = ring.color ?? '';
  if (THEME_COLORS.test(c)) return `var(--${c})`;
  if (c && typeof CSS !== 'undefined' && CSS.supports('color', c)) return c;
  return `var(--chart-${(i % 5) + 1})`;
}
/** what the rings say for assistive technology: "modulated by LFO 1 (± 25 %), Envelope (+ 40 %)" */
function ringsText(list: KnobRing[]): string {
  if (!list.length) return '';
  const share = (r: KnobRing) =>
    `${r.bipolar ? '±' : r.amount < 0 ? '-' : '+'} ${Math.round(Math.abs(r.amount) * 100)} %`;
  return `Modulated by ${list.map((r) => `${r.label} (${share(r)})`).join(', ')}`;
}
/** the marks around the value ring (data-ticks): a mark per step of a stepped knob, or that many over the turn
 *  (data-ticks="5": the quarters) - none for more than MAX_TICKS */
function ticksOf(el: HTMLElement): number[] {
  const attr = el.getAttribute('data-ticks');
  if (attr === null) return [];
  const count = Number.parseInt(attr, 10);
  if (count >= 2)
    return Array.from({ length: Math.min(count, MAX_TICKS) }, (_, i) => i / (Math.min(count, MAX_TICKS) - 1));
  const input = runtimeOf(el).input;
  const step = Number(input.getAttribute('step'));
  const r = rangeOf(el, input);
  const steps = step > 0 ? Math.floor(Math.abs(r.max - r.min) / step + 1e-9) + 1 : Infinity;
  if (steps > MAX_TICKS) return [];
  const dir = Math.sign(r.max - r.min) || 1;
  return Array.from({ length: steps }, (_, i) => positionAt(el, r.min + i * step * dir));
}
/** Draw the dial: the rings of the sources (the first next to the value ring), the marks of the steps, the value
 *  ring, the face and its pointer. */
function renderDial(el: HTMLElement): void {
  const rt = runtimeOf(el);
  const n = rt.rings.length;
  const markup = rt.rings
    .map(
      (r, i) =>
        `<span class="knob-ring" data-ring="${esc(r.id)}"${r.bipolar ? ' data-bipolar' : ''} title="${esc(r.label ?? r.id)}" style="--knob-ring: ${i + 1}; --knob-ring-color: ${esc(colorOf(r, i))}"></span>`,
    )
    .join('');
  const ticks = ticksOf(el)
    .map((t) => `<span class="knob-tick" style="--knob-t: ${round4(t)}"></span>`)
    .join('');
  const id = rt.dial.getAttribute('data-describes') ?? '';
  dfDollar(rt.dial).html(
    `${markup}${ticks}<span class="knob-track"></span><span class="knob-face"><span class="knob-pointer"></span></span><span class="knob-rings-text" id="${esc(id)}">${esc(ringsText(rt.rings))}</span>`,
  );
  rt.dial.style.setProperty('--knob-rings', String(n));
  if (el.hasAttribute('data-radius'))
    rt.dial.style.setProperty('--knob-radius', `${Math.max(8, num(el.getAttribute('data-radius'), 8))}px`);
}
/** the same value drawn again - after another scale, range, step, marks or format */
function redraw(el: HTMLElement): void {
  renderDial(el);
  show(el, runtimeOf(el).value, true, false);
}
/** Paint the value: the pointer, the arc from the origin, each ring's range and live value, the effective value, the
 *  readout and the input's text. */
function paint(el: HTMLElement): void {
  const rt = runtimeOf(el);
  const dial = rt.dial;
  const origin = el.hasAttribute('data-origin') ? positionAt(el, num(el.getAttribute('data-origin'), 0)) : 0;
  dial.style.setProperty('--knob-p', round4(rt.p));
  dial.style.setProperty('--knob-o', round4(origin));
  let effective = rt.p;
  let modulated = false;
  dfDollar(dial)
    .children<HTMLElement>('.knob-ring')
    .each((i, ring) => {
      const r = rt.rings[i];
      if (!r) return;
      const reach = Math.abs(r.amount);
      const from = r.bipolar ? rt.p - reach : Math.min(rt.p, rt.p + r.amount);
      const to = r.bipolar ? rt.p + reach : Math.max(rt.p, rt.p + r.amount);
      ring.style.setProperty('--knob-from', round4(clamp(from, 0, 1)));
      ring.style.setProperty('--knob-to', round4(clamp(to, 0, 1)));
      const out = rt.live.get(r.id);
      if (out === undefined) {
        if (ring.hasAttribute('data-live')) dfDollar(ring).attr('data-live', null);
        return;
      }
      const at = clamp(rt.p + r.amount * out, 0, 1);
      effective += r.amount * out;
      modulated = true;
      ring.style.setProperty('--knob-at-lo', round4(Math.min(rt.p, at)));
      ring.style.setProperty('--knob-at-hi', round4(Math.max(rt.p, at)));
      if (!ring.hasAttribute('data-live')) dfDollar(ring).attr('data-live', '');
    });
  dial.style.setProperty('--knob-e', round4(clamp(effective, 0, 1)));
  if (dial.hasAttribute('data-modulated') !== modulated) dfDollar(dial).attr('data-modulated', modulated ? '' : null);
  const text = textOf(el, rt.value);
  if (rt.input.getAttribute('aria-valuetext') !== text) dfDollar(rt.input).attr('aria-valuetext', text);
  dfDollar(el)
    .find<HTMLElement>('.knob-value')
    .each((_i, out) => {
      if (dfDollar(out).text() !== text) dfDollar(out).text(text);
    });
}
/** Show a value: settled on the knob's range and step, written into the input, painted - and told: the input's input
 *  event and knob-change while it changes, its change event when a change is committed. */
function show(el: HTMLElement, v: number, committed: boolean, emit = true): void {
  const rt = runtimeOf(el);
  const value = settle(el, v);
  const changed = value !== rt.value;
  rt.value = value;
  rt.p = positionAt(el, value);
  if (rt.input.value !== String(value)) rt.input.value = String(value);
  paint(el);
  if (!emit) {
    if (committed) rt.committed = value;
    return;
  }
  const done = committed && value !== rt.committed;
  if (!changed && !done) return;
  rt.writing = true;
  try {
    if (changed) rt.input.dispatchEvent(new Event('input', { bubbles: true }));
    if (done) {
      rt.committed = value;
      rt.input.dispatchEvent(new Event('change', { bubbles: true }));
    }
  } finally {
    rt.writing = false;
  }
  // Fires when the value changes - while a drag goes on (committed: false) and when a change is done.
  el.dispatchEvent(
    new CustomEvent<KnobChangeDetail>('knob-change', {
      bubbles: true,
      detail: { value, position: Math.round(rt.p * 10000) / 10000, text: textOf(el, value), committed },
    }),
  );
}
/** the value a reset goes back to: data-default, else the input's authored value */
const defaultOf = (el: HTMLElement): number =>
  num(el.getAttribute('data-default'), num(runtimeOf(el).input.defaultValue, rangeOf(el, runtimeOf(el).input).min));
/** the markup of a state: disabled disables the input; the other states write none */
function applyMarkup(el: Element, stateName: string): void {
  const input = inputOf(el);
  if (!input) return;
  if (stateName === 'disabled') {
    if (!input.hasAttribute('disabled')) dfDollar(input).attr('disabled', '');
  } else if (input.hasAttribute('disabled')) dfDollar(input).attr('disabled', null);
}
/** UI side of setState: the state's markup, then the value when the config names one. */
function triggerStateChange(el: HTMLElement, stateName: string, incoming: Record<string, unknown>) {
  applyMarkup(el, stateName);
  const rt = runtimeOf(el);
  if (!rt) return undefined;
  if (stateName !== 'adjusting' && rt.drag) endDrag(el, false, false);
  if (stateName !== 'adjusting') rt.dial.style.removeProperty('--knob-reach');
  if ('value' in incoming) show(el, num(incoming.value, rt.value), true);
  return undefined;
}
/** Registry-level API; pass the .knob element explicitly. Unknown names throw. */
export const knobApi = componentState({
  component: 'knob',
  states: knobStates,
  // the config is the value: a state change keeps it unless it names another
  mergeConfig: true,
  apply: (el, state, _previous, incoming) => triggerStateChange(el, state.name, incoming),
  // the user turns the knob without setState; a disabled input is the disabled state
  read: (el, state) => {
    const rt = runtimeOf(el);
    if (!rt) return state;
    const name = rt.input.disabled ? 'disabled' : state.name === 'disabled' ? 'default' : state.name;
    if (name === state.name && (state.config as KnobConfig).value === rt.value) return state;
    return { name, config: { ...state.config, value: rt.value } };
  },
  markup: (el, state) => applyMarkup(el, state.name),
  events: ['input', 'change'],
});
df$.knobApi = knobApi;
df$.knobStates = knobStates;
/** the speed of a drag at a pointer: full within the radius, the radius over the distance beyond it */
function speedAt(d: Drag, e: PointerEvent): number {
  const dist = Math.hypot(e.clientX - d.cx, e.clientY - d.cy);
  return dist <= d.radius ? 1 : Math.max(MIN_SPEED, d.radius / dist);
}
/** the drag's precision drawn: the circle through the pointer around the knob */
function reach(el: HTMLElement, e: PointerEvent): void {
  const rt = runtimeOf(el);
  if (!rt.drag) return;
  const dist = Math.hypot(e.clientX - rt.drag.cx, e.clientY - rt.drag.cy);
  rt.dial.style.setProperty('--knob-reach', `${Math.round(dist)}px`);
}
/** the precision radius: data-radius (px), else the dial's width - beyond it a drag slows down */
const radiusOf = (el: HTMLElement, box: DOMRect): number => Math.max(8, num(el.getAttribute('data-radius'), box.width));
function onPointerDown(el: HTMLElement, e: PointerEvent): void {
  const rt = runtimeOf(el);
  if (!rt || rt.input.disabled || e.button !== 0) return;
  e.preventDefault();
  // the keys go to the knob now - without the ring a key press would show
  rt.input.focus({ preventScroll: true, focusVisible: false } as FocusOptions);
  // Ctrl / Cmd + click: back to the default, as in a plugin
  if (e.ctrlKey || e.metaKey) {
    show(el, defaultOf(el), true);
    return;
  }
  const box = rt.dial.getBoundingClientRect();
  rt.drag = {
    id: e.pointerId,
    x: e.clientX,
    p: rt.p,
    start: rt.value,
    cx: box.left + box.width / 2,
    cy: box.top + box.height / 2,
    radius: radiusOf(el, box),
  };
  rt.dial.style.setProperty('--knob-radius', `${Math.round(rt.drag.radius)}px`);
  try {
    rt.dial.setPointerCapture(e.pointerId);
  } catch {
    /* a synthetic pointer: no capture */
  }
  el.api?.setState('adjusting');
  reach(el, e);
}
function onPointerMove(el: HTMLElement, e: PointerEvent): void {
  const rt = runtimeOf(el);
  const d = rt?.drag;
  if (!d || e.pointerId !== d.id) return;
  const dx = e.clientX - d.x;
  d.x = e.clientX;
  const travel = Math.max(20, num(el.getAttribute('data-travel'), 200));
  d.p = clamp(d.p + (dx / travel) * speedAt(d, e) * (e.shiftKey ? 0.1 : 1), 0, 1);
  reach(el, e);
  show(el, valueAt(el, d.p), false);
}
/** End a drag: committed where it is, or (cancel: Escape) back where it started. */
function endDrag(el: HTMLElement, cancel: boolean, rest = true): void {
  const rt = runtimeOf(el);
  const d = rt.drag;
  if (!d) return;
  rt.drag = null;
  try {
    if (rt.dial.hasPointerCapture(d.id)) rt.dial.releasePointerCapture(d.id);
  } catch {
    /* already released */
  }
  show(el, cancel ? d.start : rt.value, true);
  if (rest && el.dataset.stateName === 'adjusting') el.api?.setState('default');
}
/** The keys (the APG slider): an arrow a step - the input's step, else 1 % of the turn (Shift: 0.1 %) -, Page Up /
 *  Down 10 %, Home / End the ends, Delete / Backspace the default, Escape ends a drag where it started. */
function onKey(el: HTMLElement, e: KeyboardEvent): void {
  const rt = runtimeOf(el);
  if (!rt || rt.input.disabled || e.altKey || e.ctrlKey || e.metaKey) return;
  if (e.key === 'Escape') {
    if (!rt.drag) return;
    e.preventDefault();
    endDrag(el, true);
    return;
  }
  const dir = ({ ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 } as Record<string, number>)[e.key];
  const step = Number(rt.input.getAttribute('step'));
  let value: number | null = null;
  if (dir && step > 0)
    value = rt.value + dir * step * Math.sign(rangeOf(el, rt.input).max - rangeOf(el, rt.input).min || 1);
  else if (dir) value = valueAt(el, rt.p + dir * (e.shiftKey ? FINE_STEP : KEY_STEP));
  else if (e.key === 'PageUp' || e.key === 'PageDown')
    value = valueAt(el, rt.p + (e.key === 'PageUp' ? PAGE_STEP : -PAGE_STEP));
  else if (e.key === 'Home' || e.key === 'End') value = valueAt(el, e.key === 'Home' ? 0 : 1);
  else if (e.key === 'Delete' || e.key === 'Backspace') value = defaultOf(el);
  else if (e.key === 'Enter' && !rt.drag) {
    e.preventDefault();
    openEditor(el);
    return;
  }
  if (value === null) return;
  e.preventDefault();
  if (rt.drag) rt.drag.p = positionAt(el, settle(el, value));
  show(el, value, true);
}
/** how the editor writes a value: a percent knob in percent, a time knob in seconds, any other in its unit */
function editScaleOf(el: HTMLElement): { factor: number; unit: string } {
  const format = el.getAttribute('data-format') ?? 'number';
  if (format === 'percent') return { factor: 100, unit: '%' };
  if (format === 'time') return { factor: 1, unit: 's' };
  return { factor: 1, unit: el.getAttribute('data-unit') ?? '' };
}
/** Type the value in: a Number Input (xs) over the readout - the knob's range and step, its unit -, Enter or leaving
 *  it sets the value (settled on the range and step), Escape keeps the old one. It lies over the knob, so nothing
 *  moves while it is open. */
function openEditor(el: HTMLElement): void {
  const rt = runtimeOf(el);
  if (!rt || rt.input.disabled || rt.editor) return;
  const { factor, unit } = editScaleOf(el);
  const r = rangeOf(el, rt.input);
  const step = Number(rt.input.getAttribute('step'));
  const id = `${rt.dial.getAttribute('data-describes') ?? 'knob'}-edit`;
  const name = dfDollar(el).find('.knob-label').text().trim() || rt.input.getAttribute('aria-label') || 'Value';
  const typed = (v: number) => String(tidy(v * factor));
  const editor = dfDollar(
    `<div class="number-input knob-editor" data-size="xs"><input type="number" id="${esc(id)}" min="${typed(Math.min(r.min, r.max))}" max="${typed(Math.max(r.min, r.max))}" step="${step > 0 ? typed(step) : 'any'}" value="${typed(rt.value)}" aria-label="${esc(name)}" enterkeyhint="done" />${unit ? `<label class="number-input-unit" for="${esc(id)}">${esc(unit)}</label>` : ''}</div>`,
  ).get(0) as HTMLElement;
  const output = dfDollar(el).find<HTMLElement>('.knob-value').get(0);
  // centred on the readout (without one, just under the dial)
  const middle = output ? output.offsetTop + output.offsetHeight / 2 : rt.dial.offsetTop + rt.dial.offsetHeight + 16;
  editor.style.setProperty('top', `${middle}px`);
  if (output) output.style.setProperty('visibility', 'hidden');
  dfDollar(el).append(editor);
  rt.editor = editor;
  const field = dfDollar(editor).find<HTMLInputElement>('input').get(0)!;
  const close = (commit: boolean, back: boolean) => {
    if (rt.editor !== editor) return;
    rt.editor = null;
    const n = field.valueAsNumber;
    editor.remove();
    if (output) output.style.removeProperty('visibility');
    if (commit && Number.isFinite(n)) show(el, n / factor, true);
    if (back) rt.input.focus({ preventScroll: true });
  };
  dfDollar(field).on('keydown', (e: KeyboardEvent) => {
    if (e.key !== 'Enter' && e.key !== 'Escape') return;
    e.preventDefault();
    e.stopPropagation();
    close(e.key === 'Enter', true);
  });
  dfDollar(field).on('blur', () => close(true, false));
  field.focus({ preventScroll: true });
  field.select();
}
function init() {
  dfDollar('.knob:not([data-init])')
    .toArray()
    .forEach((el: HTMLElement) => {
      el.dataset.init = '';
      const input = inputOf(el);
      if (!input) return;
      // a range without a step snaps to whole numbers - the knob's values are as fine as its scale unless it says
      // (the browser has snapped the authored value already: it is read again under step="any")
      if (!input.hasAttribute('step')) {
        dfDollar(input).attr('step', 'any');
        if (input.hasAttribute('value')) input.value = input.defaultValue;
      }
      let data: unknown = null;
      try {
        data = JSON.parse(dfDollar(el).children('script.knob-rings').text() || 'null');
      } catch {
        data = null;
      }
      const id = `knob-rings-${++rings}`;
      const dial = dfDollar(`<span class="knob-dial" aria-hidden="true" data-describes="${id}"></span>`).get(
        0,
      ) as HTMLElement;
      dfDollar(input).before(dial);
      const described = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
      dfDollar(input).attr('aria-describedby', [...described, id].join(' '));
      const rt: Runtime = {
        input,
        dial,
        value: NaN,
        p: 0,
        committed: NaN,
        rings: ringsOf(data),
        live: new Map(),
        drag: null,
        writing: false,
        editor: null,
      };
      el._knob = rt;
      renderDial(el);
      show(el, num(input.value, defaultOf(el)), true, false);
      // el.store + el.api (AGENTS.md "State through stores"): $('#cutoff').api.setState('default', { value: 440 })
      bindComponent(el, knobApi, { name: input.disabled ? 'disabled' : 'default', config: { value: rt.value } });
      dfDollar(dial).on('pointerdown', (e: PointerEvent) => onPointerDown(el, e));
      dfDollar(dial).on('pointermove', (e: PointerEvent) => onPointerMove(el, e));
      dfDollar(dial).on('pointerup', () => endDrag(el, false));
      dfDollar(dial).on('pointercancel', () => endDrag(el, false));
      dfDollar(dial).on('lostpointercapture', () => endDrag(el, false));
      dfDollar(dial).on('dblclick', () => {
        if (!input.disabled) show(el, defaultOf(el), true);
      });
      dfDollar(input).on('keydown', (e: KeyboardEvent) => onKey(el, e));
      // a double click on the readout types the value in
      dfDollar(el).on('dblclick', (e: MouseEvent) => {
        if ((e.target as Element).closest?.('.knob-value')) openEditor(el);
      });
      // a value written from outside (input.value = ... and an input event) is shown, not sent back
      dfDollar(input).on('input', () => {
        if (!rt.writing && Number(input.value) !== rt.value) show(el, num(input.value, rt.value), true, false);
      });
      // a form reset puts the authored value back
      if (input.form)
        dfDollar(input.form).on('reset', () => {
          setTimeout(() => show(el, num(input.value, defaultOf(el)), true, false));
        });
      // another scale, range, step, marks or format: the same value drawn again
      const again = () => redraw(el);
      new MutationObserver(again).observe(el, {
        attributes: true,
        attributeFilter: [
          'data-scale',
          'data-exponent',
          'data-origin',
          'data-unit',
          'data-format',
          'data-decimals',
          'data-ticks',
        ],
      });
      new MutationObserver(again).observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step'] });
    });
}
/** the knob of a target: the .knob element, its input, or a selector of either */
const resolve = (target: string | HTMLElement): HTMLElement | undefined => {
  const el = typeof target === 'string' ? dfDollar(target).get(0) : target;
  const knob = el?.closest?.<HTMLElement>('.knob');
  return knob?._knob ? knob : undefined;
};
df$.knob = {
  /**
   * The knob's value, or set it (setState with { value }: settled on the range and step, the change told).
   * @param target - the .knob element, its input, or a selector of either
   * @param value - the value to set; leave it out to read
   * @returns the value now; NaN for an unknown element
   */
  value: (target: string | HTMLElement, value?: number): number => {
    const el = resolve(target);
    if (!el) return NaN;
    if (value !== undefined) el.api?.setState(el.dataset.stateName === 'disabled' ? 'disabled' : 'default', { value });
    return runtimeOf(el).value;
  },
  /**
   * Where the value stands on the knob's turn.
   * @param target - the .knob element, its input, or a selector of either
   * @returns the position, 0 (the start of the turn) to 1 (its end); NaN for an unknown element
   */
  position: (target: string | HTMLElement): number => {
    const el = resolve(target);
    return el ? runtimeOf(el).p : NaN;
  },
  /**
   * Back to the default value (data-default, else the input's authored value) - what a double click does.
   * @param target - the .knob element, its input, or a selector of either
   * @returns the value now; NaN for an unknown element
   */
  reset: (target: string | HTMLElement): number => {
    const el = resolve(target);
    if (!el) return NaN;
    show(el, defaultOf(el), true);
    return runtimeOf(el).value;
  },
  /**
   * The rings around the knob, or replace them - the live values of the sources that stay are kept.
   * @param target - the .knob element, its input, or a selector of either
   * @param list - the new rings; leave it out to read
   * @returns a copy of the rings now
   */
  rings: (target: string | HTMLElement, list?: KnobRing[]): KnobRing[] => {
    const el = resolve(target);
    if (!el) return [];
    const rt = runtimeOf(el);
    if (list !== undefined) {
      rt.rings = ringsOf(list);
      for (const id of rt.live.keys()) if (!rt.rings.some((r) => r.id === id)) rt.live.delete(id);
      renderDial(el);
      paint(el);
    }
    return rt.rings.map((r) => ({ ...r }));
  },
  /**
   * The live outputs of the sources - each ring shows how far its source moves the knob now, the value ring the
   * effective value. A unipolar source runs from 0 to 1, a bipolar one from -1 to 1.
   * @param target - the .knob element, its input, or a selector of either
   * @param values - the outputs by ring id (null for a ring: no live value); null: none at all
   * @returns the effective value - the knob's value moved by every live source, inside its range
   */
  modulate: (target: string | HTMLElement, values: Record<string, number | null> | null): number => {
    const el = resolve(target);
    if (!el) return NaN;
    const rt = runtimeOf(el);
    if (values === null) rt.live.clear();
    else
      for (const [id, out] of Object.entries(values ?? {})) {
        const ring = rt.rings.find((r) => r.id === id);
        if (!ring || out === null || !Number.isFinite(Number(out))) rt.live.delete(id);
        else rt.live.set(id, clamp(Number(out), ring.bipolar ? -1 : 0, 1));
      }
    paint(el);
    let p = rt.p;
    for (const r of rt.rings) p += r.amount * (rt.live.get(r.id) ?? 0);
    return settle(el, valueAt(el, clamp(p, 0, 1)));
  },
  /**
   * Add a scale knobs name in data-scale - the knobs on it are drawn again.
   * @param name - the scale's name
   * @param scale - its value() and position(), each the inverse of the other
   */
  scale: (name: string, scale: KnobScale): void => {
    if (!name || typeof scale?.value !== 'function' || typeof scale?.position !== 'function') return;
    SCALES[name] = scale;
    for (const el of dfDollar(`.knob[data-init][data-scale="${CSS.escape(name)}"]`).toArray() as HTMLElement[])
      if (el._knob) redraw(el);
  },
  /**
   * Add a format knobs name in data-format - the knobs in it write their values again.
   * @param name - the format's name
   * @param format - the value's text, from the value and the knob's unit, decimals, locale and range
   */
  format: (name: string, format: (value: number, context: KnobFormatContext) => string): void => {
    if (!name || typeof format !== 'function') return;
    FORMATS[name] = format;
    for (const el of dfDollar(`.knob[data-init][data-format="${CSS.escape(name)}"]`).toArray() as HTMLElement[])
      if (el._knob) paint(el);
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub