CircularSlider
A range slider bent into a ring. One or two handles, an arc fill, tick dots, wrapping clock scales, and open-arc gauges. Full keyboard operation and a per-handle accessible name.
Playground
Installation
npx shadcn@latest add https://tessinaui.com/r/circular-slider.jsonUsage
import { CircularSlider } from "@tessinaui/ui";<CircularSlider
label="Brightness"
value={value}
onValueChange={(v) => setValue(v as number)}
formatValue={(v) => `${v}%`}
>
<span>{value}%</span>
</CircularSlider>When to use it
A ring, not a bar, when the value is cyclical or has no natural end — a time of day, a compass heading, a hue — or when the control has to sit inside a round read-out, which is what every sleep, thermostat and dimmer screen in the research does. For a plain bounded quantity in a form, use Slider: a straight track is easier to aim at and reads faster.
It is a separate component rather than a slider variant because the
interaction model differs, not just the paint. A linear slider maps one axis to
a value; this maps an angle, and on a wrapping scale the shorter way between
two values can be through the end of the range.
Examples
Default
A single handle with a centre read-out.
Sleep range
Two handles on a wrapping 24-hour scale. Bedtime after wake-up is the normal way to say "overnight", so the arc runs forward through midnight rather than backwards around the dial. Each handle carries its own name — "Sleep, 22:00 to 06:00" on one control does not tell a screen-reader user which end is about to move.
Gauge
An open arc: sweep={270} with startAngle={225}, the shape thermostats use.
wrap is ignored when the arc has a gap — there is no seam to travel through,
and silently teleporting a handle across it would be worse than clamping.
Accessibility
- Every handle is a
role="slider"witharia-valuemin/aria-valuemax/aria-valuenow, focusable in source order. - Pass
formatValue. A bare22on a clock face means nothing; it becomesaria-valuetext="22:00". - A range takes two
labels, one per handle. - Keyboard: arrows step, Shift+arrow and PageUp/PageDown take the large step, Home/End jump to the ends. On a wrapping scale the arrows carry through the seam.
- The SVG ring is
aria-hiddenandfocusable="false"— the handles carry all the semantics, so assistive tech never meets a nameless graphic. - Handles keep a 44px touch target on phones via an invisible centred pad, so the visible handle can stay proportionate to a small ring (WCAG 2.5.5).
readOnlykeeps focus and announcesaria-readonly;disabledremoves the handles from the tab order.
API Reference
CircularSlider
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | [number, number] | — | Controlled value. A two-number array makes it a range |
defaultValue | number | [number, number] | min | Uncontrolled initial value |
min | number | 0 | Minimum |
max | number | 100 | Maximum |
step | number | 1 | Step increment |
largeStep | number | step × 10 | Step for PageUp/PageDown and Shift+arrow |
onValueChange | (value) => void | — | Fires on every change |
onValueCommitted | (value) => void | — | Fires once at the end of a drag, or immediately for a keyboard step |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Ring diameter preset |
diameter | number | from size | Diameter in px, overriding size |
thickness | number | 7.5% of diameter | Track thickness in px |
startAngle | number | 0 | Where min sits, in degrees clockwise from 12 o'clock |
sweep | number | 360 | How much of the circle the scale spans, in degrees |
wrap | boolean | false | Whether the scale wraps at the ends. Forced off when sweep < 360 |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Arc and focus-ring colour |
ticks | number | number[] | — | Tick dots: a count, or explicit values |
handleIcon | ReactNode | [ReactNode, ReactNode] | — | Icon inside the handle; an array names each end |
label | string | [string, string] | "Value" | Accessible name. A range takes two |
formatValue | (value: number) => string | — | Human text for aria-valuetext and your own read-out |
disabled | boolean | false | — |
readOnly | boolean | false | Focusable, not changeable |
children | ReactNode | — | Rendered in the middle of the ring |
Sizes:
| Size | Diameter | Handle |
|---|---|---|
xs | 112px | 24px |
sm | 136px | 28px |
md | 160px | 32px |
lg | 200px | 36px |
xl | 248px | 40px |
CircularSliderSkeleton
Placeholder ring. Its box comes from the component's own diameter map, so the two cannot drift.
| Prop | Type | Default | Description |
|---|---|---|---|
size | CircularSliderSize | "md" | Diameter preset |
diameter | number | from size | Diameter in px |