# Knob

Chooses a number by turning a dial, with the value in its middle.

- **Import:** `import { Knob } from "@booleanpress/ui/knob"`
- **APG Slider:** <https://www.w3.org/WAI/ARIA/apg/patterns/slider/>
- **Page:** <https://ui.booleanpress.com/components/knob> · @booleanpress/ui 0.2.0

## Usage

A knob is a slider drawn as a dial, for dashboards and settings where a round control fits better than a track.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export function SendingRate() {
  return <Knob defaultValue={50} aria-label="Sending rate" />
}
```

It is uncontrolled with `defaultValue` (`min` by default), or controlled with `value` and `onValueChange`; `onValueCommit` fires once when a drag or a key press ends. `min`, `max` (0 and 100 by default) and `step` bound it; when the range spans zero, the value's arc starts at zero. Drag round the dial with a pointer, or use the arrow keys, Page Up/Down, Home and End. `formatValue` turns the number into the text in the middle and the value screen readers announce (`(value) => `${value}%``); without it the number is formatted in the provider's locale. `size` is `sm` (80 px), `default` (100 px) or `lg` (150 px) and follows the provider's `controlSize`; a `size-*` class sets any other size. `strokeWidth` sets the arc's thickness, and `valueColor`, `rangeColor` and `textColor` its colours. `readOnly` keeps the dial focusable and announced but fixed; `disabled` dims it and takes it out of the tab order. With `name`, the value is submitted in a form (not while disabled), and a form reset brings back the first value.

## Examples

### Basic

A dial from 0 to 100, at 50.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobBasic() {
  return <Knob defaultValue={50} aria-label="Sending rate" />
}
```

### Min and max

`min={-50}` and `max={50}`: the value's arc starts at zero.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobMinMax() {
  return <Knob min={-50} max={50} defaultValue={10} aria-label="Time zone offset in minutes" />
}
```

### Step

`step={10}` moves the value in steps of 10, by key and by drag.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobStep() {
  return <Knob step={10} defaultValue={50} aria-label="Retry delay in seconds" />
}
```

### Value template

`formatValue` shows and announces "42%".

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobValueTemplate() {
  return <Knob defaultValue={42} formatValue={(value) => `${value}%`} aria-label="Daily quota used" />
}
```

### Stroke width

`strokeWidth={5}` draws a thin arc.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobStroke() {
  return <Knob strokeWidth={5} defaultValue={40} aria-label="Open rate goal" />
}
```

### Sizes

`sm` is 80 px, `default` 100 px and `lg` 150 px; the text scales with the dial.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobSizes() {
  return (
    <div className="flex items-center gap-6">
      <Knob size="sm" defaultValue={30} aria-label="Small" />
      <Knob defaultValue={50} aria-label="Default" />
      <Knob size="lg" defaultValue={70} aria-label="Large" />
    </div>
  )
}
```

### Colours

`valueColor` and `rangeColor` take theme tokens for a delivered, bounced and opened dial.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobColours() {
  return (
    <div className="flex items-center gap-6">
      <Knob defaultValue={75} valueColor="var(--success)" rangeColor="var(--success-tag)" aria-label="Delivered" />
      <Knob defaultValue={12} valueColor="var(--destructive)" rangeColor="var(--destructive-tag)" aria-label="Bounced" />
      <Knob defaultValue={40} valueColor="var(--chart-2)" aria-label="Opened" />
    </div>
  )
}
```

### Controlled

`value` and `onValueChange`, also changed by two buttons.

```tsx
import { useState } from "react"
import { MinusIcon, PlusIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Knob } from "@booleanpress/ui/knob"

export default function KnobControlled() {
  const [workers, setWorkers] = useState(0)

  return (
    <div className="flex flex-col items-center gap-2">
      <Knob value={workers} onValueChange={setWorkers} size="lg" aria-label="Queue workers" />
      <div className="flex gap-2">
        <Button size="icon-sm" aria-label="Add a worker" disabled={workers >= 100} onClick={() => setWorkers((n) => Math.min(100, n + 1))}>
          <PlusIcon />
        </Button>
        <Button size="icon-sm" variant="secondary" aria-label="Remove a worker" disabled={workers <= 0} onClick={() => setWorkers((n) => Math.max(0, n - 1))}>
          <MinusIcon />
        </Button>
      </div>
    </div>
  )
}
```

### Read only

`readOnly` keeps the dial in the tab order and announced, but fixed.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobReadOnly() {
  return <Knob readOnly value={50} aria-label="Disk usage" />
}
```

### Disabled

`disabled` dims the dial and takes it out of the tab order.

```tsx
import { Knob } from "@booleanpress/ui/knob"

export default function KnobDisabled() {
  return <Knob disabled defaultValue={50} aria-label="Sending rate" />
}
```

## Accessibility

**Semantics.** The dial is an SVG with `role="slider"`, `aria-valuenow`, `aria-valuemin` and `aria-valuemax`; `formatValue` adds `aria-valuetext`. Read only, it carries `aria-readonly="true"`; disabled, `aria-disabled="true"`. The arcs and the text in the middle are decoration. With `name`, a hidden input carries the value for the form.

**Labels.** Name it with `aria-label` or `aria-labelledby`. Put a hint or an error in `aria-describedby`.

**Focus.** The dial is one tab stop (none when disabled). Keyboard focus is a 1px `--ring` outline 2px round it. A drag focuses it, so the keys continue from there.

**Known limits.**

- The dial turns clockwise to raise the value in every reading direction, so → and ↑ raise it in right-to-left pages too.
- A drag is hard to stop on an exact value; the arrow keys, or a number field beside it, are exact.
- Colours given to `valueColor`, `rangeColor` or `textColor` are yours to check for contrast against the page.

### Keyboard

| Key | Behaviour |
| --- | --- |
| → or ↑ | Raises the value by one step. |
| ← or ↓ | Lowers the value by one step. |
| Shift + → | With any arrow key, moves ten steps that way. |
| Page Up or Page Down | Raises or lowers the value by ten steps. |
| Home or End | Sets the minimum or the maximum. |

## API

### Knob

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `number` |  | The value at the start, when it controls itself. `min` by default. |
| `disabled` | `boolean` | `false` | Dims the dial and takes it out of the tab order. |
| `form` | `string` |  | The id of the form the value belongs to, for a control placed outside it. |
| `formatValue` | `((value: number) => string)` |  | The text in the middle, and the value screen readers announce: `(value) => \`${value}%\``. The number by default. |
| `max` | `number` | `100` | The highest value. 100 by default. |
| `min` | `number` | `0` | The lowest value. 0 by default. |
| `name` | `string` |  | The form field name: the value is submitted with the form, and a form reset brings back the first value. |
| `onValueChange` | `((value: number) => void)` |  | Called with the new value while it changes. |
| `onValueCommit` | `((value: number) => void)` |  | Called with the value once a drag or a key press ends. |
| `rangeColor` | `string` |  | The colour of the rest of the arc. `--border` by default. |
| `readOnly` | `boolean` | `false` | Keeps the dial focusable and announced but ignores the pointer and the keys. |
| `showValue` | `boolean` | `true` | Shows the value in the middle. True by default. |
| `size` | `"default" \| "sm" \| "lg"` |  | The dial's width and height: 80, 100 or 150 px. Defaults to the provider's `controlSize`; a `size-*` class sets any other. |
| `step` | `number` | `1` | The amount each move changes the value by. 1 by default. |
| `strokeWidth` | `number` | `14` | The arc's thickness, in hundredths of the dial's width. 14 by default. |
| `textColor` | `string` |  | The colour of the value text. `--muted-foreground` by default. |
| `value` | `number` |  | The value, when you control it. |
| `valueColor` | `string` |  | The colour of the value's arc, any CSS colour. `--primary` by default. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="knob"` (Knob), and `data-size`, `data-disabled`, `data-readonly`.

## Theming

The arc is `--border`, the value's arc `--primary` and the text `--muted-foreground`; `valueColor`, `rangeColor` and `textColor` set them as the variables `--knob-value`, `--knob-range` and `--knob-text`. Focus is `--ring`. A disabled knob is drawn at 60% opacity.

| Token | Used for |
| --- | --- |
| `--ring` | outline |
