Skip to content
ShineUI
Install

Knob

A dial for what-if numbers.

<sui-knob>Advanced inputsStable

About

Circular dial for what-if numbers on a dashboard: drag up to raise the value, down to lower it, and read the figure in the face. Reach for it where a number is tuned by feel and the bounds are known; use sui-slider for a wider track or a range.

Usage

<sui-knob label="Target margin" name="margin" min="0" max="100" value="48"></sui-knob> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-knob"); el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiKnob } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return <SuiKnob label="Target margin" name="margin" min="0" max="100" value="48" onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiKnob } from "@shineui/vue"; import "@shineui/core/shineui.css"; const onChange = (e) => console.log(e.detail); </script> <template> <SuiKnob label="Target margin" name="margin" min="0" max="100" value="48" @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiKnob } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiKnob], template: `<sui-knob label="Target margin" name="margin" min="0" max="100" value="48" (suiChange)="onChange($event)"></sui-knob>`, }) export class Example { onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The dial is role="slider" with aria-orientation="vertical", and the formatted readout is also its aria-valuetext. Drag up to raise the value.

<sui-knob label="Target margin" name="margin" min="0" max="100" value="48" help="Used for the what-if column only."></sui-knob>
// setup(root): properties and events setup(root) { root.querySelector('sui-knob').format = pct; }

Sizes and formats

diameter is the dial in px. The inner disc is 22px smaller and the readout scales with it, so a large dial needs no separate type setting.

<div style="display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap"> <sui-knob id="kn-sm" label="Utilization" min="0" max="100" value="72" diameter="60"></sui-knob> <sui-knob id="kn-md" label="Target margin" min="0" max="100" value="48"></sui-knob> <sui-knob id="kn-lg" label="Monthly retainer" min="0" max="10000" step="250" value="4000" diameter="120"></sui-knob> </div>
// setup(root): properties and events setup(root) { root.querySelector('#kn-sm').format = pct; root.querySelector('#kn-md').format = pct; root.querySelector('#kn-lg').format = money; }

States

readonly keeps the dial focusable and readable and refuses every move. error carries the author verdict and sets customError. The dial always sits somewhere, so required fails only while value is null: the face shows no figure until the person sets it.

<div style="display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap"> <sui-knob id="kn-ro" label="Approved margin" min="0" max="100" value="30" readonly diameter="68" help="Locked at approval."></sui-knob> <sui-knob id="kn-off" label="Locked" min="0" max="100" value="55" disabled diameter="68"></sui-knob> <sui-knob id="kn-err" label="Discount" min="0" max="100" value="45" diameter="68" error="Discount above 40% needs a partner sign-off."></sui-knob> <sui-knob id="kn-req" label="Target" name="target" min="0" max="100" required diameter="68" help="Set a target."></sui-knob> </div>
// setup(root): properties and events setup(root) { ['#kn-ro', '#kn-off', '#kn-err', '#kn-req'].forEach((sel) => { root.querySelector(sel).format = pct; }); root.querySelector('#kn-req').value = null; }

In a form

The dial posts its number under name. A disabled dial is barred from submission and posts nothing at all.

Submit to see what posts.
<form id="kn-form" style="display:grid;gap:16px;justify-items:start"> <div style="display:flex;gap:28px;flex-wrap:wrap"> <sui-knob id="kn-form-margin" label="Target margin" name="margin" min="0" max="100" value="48"></sui-knob> <sui-knob id="kn-form-util" label="Utilization" name="utilization" min="0" max="100" value="72"></sui-knob> </div> <sui-button type="submit" variant="brand" label="Submit"></sui-button> <output id="kn-form-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit to see what posts.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#kn-form'); const out = root.querySelector('#kn-form-out'); root.querySelector('#kn-form-margin').format = pct; root.querySelector('#kn-form-util').format = pct; form.addEventListener('submit', (e) => { e.preventDefault(); out.textContent = [...new FormData(form)].map(([k, v]) => `${k}=${v}`).join(' '); }); }

Events

suiInput fires on every frame of a drag, suiChange once the pointer is released or a keyboard step lands. A drag crosses the whole range in 150px whatever step is set to.

Idle.
<div style="display:flex;gap:20px;align-items:center;flex-wrap:wrap"> <sui-knob id="kn-events" label="Write-off rate" name="rate" min="0" max="100" step="5" value="20"></sui-knob> <span id="kn-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#kn-events'); const log = root.querySelector('#kn-log'); let frames = 0; el.format = pct; el.addEventListener('suiInput', () => { frames += 1; log.textContent = `suiInput fired ${frames} time(s).`; }); el.addEventListener('suiChange', (e) => { log.textContent = `suiChange ${e.detail} after ${frames} suiInput event(s).`; frames = 0; }); }

Keyboard

KeyAction
ArrowUp ArrowRightRaises the value one step. Under dir="rtl" the horizontal pair swaps, as it does on a native range input, so ArrowLeft raises it.
ArrowDown ArrowLeftLowers it one step.
PageUp PageDownMoves it ten steps.
Home EndJumps to the lowest or the highest value.

API

<sui-knob>

Properties

PropertyAttributeTypeDefaultDescription
diameterdiameternumber84Diameter of the dial in px. The inner disc is 22px smaller; the readout scales with it.
disableddisabledbooleanfalseDisables the dial. A disabled dial posts nothing and never holds the form.
errorerrorstringstring | undefinedError message under the caption; sets customError on the form and aria-invalid on the dial.
formatproperty only((v: number) => string) | undefinedFormats the readout and aria-valuetext, e.g. (v) => v + "%". Defaults to the value grouped and marked in the page's locale (1,250.5, 1.250,5). Property, not attribute.
helphelpstringstring | undefinedHelp text under the caption. Replaced by error when set.
labellabelstringstring | undefinedCaption under the dial.
maxmaxnumber100Highest value.
minminnumber0Lowest value.
namenamestringstring | undefinedForm control name. Posts the number; a disabled dial posts nothing. Reflected: entry construction reads the content attribute, and the three framework bindings set props, so an unreflected name would post nothing.
readOnlyreadonlybooleanfalseDisplay only: no drag, no keyboard changes; the dial stays focusable and readable.
requiredrequiredbooleanfalseA value must be set for the surrounding form to be valid. The dial always sits somewhere, so this fails only while value is empty (null), which is how a host starts a dial the person must set, and what a framework form writes when it resets; the first drag or key sets it. A disabled or read-only dial never holds the form.
stepstepnumber1Increment between values; drag positions and keyboard steps snap to it. Decimals are honoured. A drag crosses the whole range in 150px whatever the step, so a coarse step lands on the grid without slowing the drag.
valuevaluenull | number50Current value. Mutable; updated before suiInput / suiChange fire. null is empty: the dial sits at min with no figure in the face, a screen reader hears "No value", it posts an empty string, and with required it fails the form until the person sets it.

Events

EventDetailReactVueDescription
suiChangenumberonSuiChange@sui-changeFires when a drag ends or a keyboard step lands; detail is the value.
suiInputnumberonSuiInput@sui-inputFires continuously while dragging; detail is the value.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the dial. sui-form calls this to reach the first invalid control.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also