Skip to the content

ComponentsForm

Knob

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

Import

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

Usage

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

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.

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.

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.

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%".

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.

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.

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.

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.

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.

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.

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

Keyboard
KeyBehaviour
โ†’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 UporPage DownRaises or lowers the value by ten steps.
HomeorEndSets the minimum or the maximum.

API

Knob

Knob props
PropTypeDefaultDescription
defaultValuenumberThe value at the start, when it controls itself. min by default.
disabledbooleanfalseDims the dial and takes it out of the tab order.
formstringThe 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.
maxnumber100The highest value. 100 by default.
minnumber0The lowest value. 0 by default.
namestringThe 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.
rangeColorstringThe colour of the rest of the arc. --border by default.
readOnlybooleanfalseKeeps the dial focusable and announced but ignores the pointer and the keys.
showValuebooleantrueShows 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.
stepnumber1The amount each move changes the value by. 1 by default.
strokeWidthnumber14The arc's thickness, in hundredths of the dial's width. 14 by default.
textColorstringThe colour of the value text. --muted-foreground by default.
valuenumberThe value, when you control it.
valueColorstringThe 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.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--ringoutline