VI Controls

Star on GitHubFeedback
VI ControlsNative HTML and a hookot-dial

Dial

A number you turn to. The arc sets a few on a half circle and rolls to your choice; the tuner is a long scale on the rim of a wheel you turn by hand, by scroll or by key, and your number reads above it in italic. Dynamics sets the number at the size of its loudness, from niente to fff; the tumbler sets a whole number figure by figure, like the wheels of a lock.

Report an issue with Dial

Example

How many weeks do we have?
Set the tempo
Volume
The year it was printedUp and down turn the figure in parentheses; left and right choose another.
Rest
Tempo
Focus
Tempo
Disabled
Tempo
Dynamics, silent
Volume
Dynamics, loudest
Volume
Dynamics, focus
Volume
Tumbler
PagesUp and down turn the figure in parentheses; left and right choose another.
Tumbler, focus
PagesUp and down turn the figure in parentheses; left and right choose another.
Tumbler, disabled
PagesUp and down turn the figure in parentheses; left and right choose another.

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.

npx shadcn@latest add https://thedirectors.agency/ui/r/dial.json

Usage

dial-example.tsx
import { Dial } from "@/components/ui/dial"export default function Example() {return (<div className="flex flex-wrap items-end justify-center gap-x-[var(--ot-space-8)] gap-y-[var(--ot-space-7)]"><Dial name="weeks" legend="How many weeks do we have?" options={[2, 4, 6, 8, 12, 16, 24]} defaultValue={8} unit="weeks" /><Dial variant="tuner" name="tempo" legend="Set the tempo" min={40} max={208} defaultValue={96} unit="bpm" /><Dial variant="dynamics" name="volume" legend="Volume" min={0} max={100} defaultValue={60} /><Dial variant="tumbler" name="year" legend="The year it was printed" min={1450} max={2026} defaultValue={1623} /></div>)}

Contract

  • Underneath: the arc is native radios with no script; the tuner and dynamics are a native range plus a hook; the tumbler is a native number input (role="spinbutton") plus a hook.
  • Arc anatomy: <fieldset class="ot-dial" data-variant="arc" style="--n: 7">, a legend, then .ot-dial-face holding one <label style="--i: 0…n-1" data-text="N"><input type="radio"><span>N</span></label> per option, then .ot-dial-arc, .ot-dial-dot, and an optional .ot-dial-unit.
  • Arc: options sit on an arc from 170° to 10°. --sel is a registered number, set by :has() rules (up to 9 options) and transitioned over --ot-andante with --ot-spiccato, so the dial swings a touch past and settles. Every option derives its distance from it, so the whole dial rolls: the chosen number swells to 2.4× and cross-fades to italic, the others fade from ink toward pencil by distance, and the accent dot travels the inner arc. It works without script. Keyboard: native radio group.
  • Tuner anatomy: <fieldset class="ot-dial" data-variant="tuner">, a legend, then .ot-dial-face (aria-hidden) holding .ot-dial-read (a .ot-dial-drum and an optional .ot-dial-unit) and .ot-dial-scale (a .ot-dial-tick per step, style="--v: i" and data-kind="minor | half | major", a major one holding its numeral, then .ot-dial-index), then <input type="range" class="ot-sr">. The range is the control: it takes focus and keys, carries the value to screen readers (aria-valuetext with the unit) and submits the form.
  • Tuner: for more numbers than fit in view, a long scale on the rim of a wheel seen edge on. The script keeps one position, --pos in steps, and writes it to the face each frame; CSS sets every tick at the angle (v − pos) / --k on a radius of --k × --ot-dial-pitch (15 × 13px, 10px under 520px), so ticks spread under the index and crowd, narrow and fade as the rim turns away. Only the ticks within 34 steps of the index exist; the rest are made as the wheel brings them round. The index is an ink stroke standing through the rim with the accent dot on top, the slider's hand. A numeral inks as it passes the index.
  • Yours: the tuner's reading above the scale, a large italic numeral on a drum that turns with the wheel like a counter (data-a and data-b are the two numbers in view, --f how far it has turned between them), with the unit after it in roman.
  • Turning the tuner: drag it sideways and the value follows the wheel step by step; a flick coasts and lands on a step with one small swing past; past either end the rim gives a little under the hand and springs back. A tap turns it to the mark under the finger; a tap on a coasting wheel stops it. A sideways swipe always turns it; the plain scroll wheel only once it has focus, and at either end it hands the scroll back to the page (it marks the face data-lenis-prevent-wheel while it claims a scroll, so a smooth scroller leaves it alone). While it's held (data-held on the root) the reading lifts 5px and settles back with spiccato. Under reduced motion there is no coast, spring or roll: the wheel and the reading step straight to the value.
  • States: rest, hover (the arc's number inks), focus (the arc's number, or the tuner's face, in the accent outline), chosen, held (tuner), disabled (45% opacity; disabled goes on the fieldset). Tuner keyboard: arrows step, Page Up / Page Down jump a major mark, Home / End go to the ends.
  • Dynamics anatomy: <fieldset class="ot-dial" data-variant="dynamics" style="--u; --chars">, a legend, then .ot-dial-face (aria-hidden) holding .ot-dial-loud (the numeral and an optional unit) and .ot-dial-marking with data-mark, then <input type="range" class="ot-sr"> with aria-valuetext such as "60, mezzo forte".
  • Dynamics: the score's dynamics as a control. The type scale is already named for them; here the number is set at the size of its loudness. --u is how far along the range it is, and --e = (9^u − 1) / 8 bends it the way loudness is heard, so the numeral grows from --ot-p to --ot-fff slowly at first and then fast. Under it, in the expression italic and the accent, its marking: niente at nothing (in pencil), then ppp to fff. The face keeps the room of the loudest numeral, so the page never reflows as it swells. Drag up or right to get louder (260px spans the range), scroll once focused, or use the range's keys; the numeral follows the hand while held (data-held) and settles with spiccato after a key or a scroll.
  • Tumbler anatomy: <fieldset class="ot-dial" data-variant="tumbler">, a legend, then .ot-dial-face (dir="ltr", aria-hidden) holding .ot-dial-figures (style="--at", one .ot-dial-figure per place with data-k, and data-lead on a leading zero, then .ot-dial-cursor) and an optional unit, then <input type="number" role="spinbutton" inputmode="none" class="ot-sr">, described by a hint.
  • Tumbler: a whole number set one figure at a time, like the wheels of a combination lock, with the figure you turn held in accent parentheses, as the poster writes 20(25). Whole numbers run from min (at least 0) to max; max decides how many figures there are, and leading zeros stand in pencil. The parentheses slide between figures (--at, moderato, spiccato) and open a little while held. A figure that changes rolls in from below or above, the way it turned, units first and each place one arpeggio later. Keyboard: up and down turn the held figure by its place (so up on the tens adds ten, and it carries), left and right move the parentheses, a typed digit sets the figure and steps right, Home and End go to min and max. Pointer: tap a figure to hold it, drag up or down to turn it (a step every 24px); scroll once focused. Figures always read left to right; right to left, only the legend moves.
  • Forced colours: the parentheses and the marking use Highlight. Reduced motion: the swell, the roll and the parentheses change without travel.

Props

Props of Dial
PropTypeDefaultWhat it does
variant"arc" | "tuner" | "dynamics" | "tumbler""arc"The arc: up to nine choices on a half circle, native radios, no script. The tuner: any number of steps on a wheel that turns. Dynamics: the numeral grows from the quiet size to the loudest as you drag, scroll or press the arrows, and its dynamic marking (niente, ppp to fff) reads under it in the accent. The tumbler: whole numbers set one figure at a time; up and down turn the figure held in parentheses, left and right move to another, and a typed digit sets it and steps on.
namestringThe form field's name, so a form submits the choice.
legendReactNodeThe question, as a small label over the dial. It names the range too.
options(string | number | { value, label? })[]The choices in order. The arc takes up to nine; the tuner any number.
min / max / stepnumber0 / 100 / 1Tuner without options, and dynamics: the scale runs from min to max by step. Tumbler: whole numbers from min (at least 0) to max; max sets how many figures there are, and the leading zeros stand in pencil.
majornumber10Tuner: a numbered tick every this many steps, and a half-length one halfway between. Page Up / Page Down jump by it.
value / defaultValuestring | numberThe choice. Leave both out and the arc starts with none chosen, the tuner and dynamics at their first step, the tumbler at min.
onValueChange(value: string | number) => voidCalled with each new choice; on the tuner, with every step the wheel passes under your hand.
unitstringSmall words for the number, such as weeks: under the arc, after the reading on the others.
disabledbooleanfalseThe dial fades and won't turn. It disables the fieldset, so every radio or the range goes with it.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
DialWOVE; Paul Rand dimension linesNumbers on an arc; the dial rolls. Or a scale on the rim of a wheel you turn, your number read above it
Dial, dynamicsThe score's dynamics, which name the type scaleThe number is set at the size of its loudness, its marking under it
Dial, tumblerThe poster's 20(25); a combination lock's wheelsFigure by figure, the one you turn held in parentheses

How it moves

How it moves
ComponentArticulationWhat moves
DialSpiccatoThe dial swings a touch past its stop
Tuner dialCoastThe wheel follows the hand, coasts after a flick and lands on a step with one swing past; the reading turns like a counter
Dial, dynamicsCrescendoThe numeral grows with the hand, slowly then fast, and its marking changes under it
Dial, tumblerTumblerA changed figure rolls in the way it turned, units first; the parentheses slide to the next figure