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.
pnpm add @tessinaui/uiWhen to use
- A cyclical value, or one with no natural end: a time of day, a compass heading, a hue. On a wrapping scale the shorter way between two values can be through the end of the range, and the ring says so.
- A value that has to sit inside a round read-out. Put the number in
childrenand the ring frames it, as a dimmer or a thermostat does. - A range on a ring. A two-number
valuegives two handles, each with its ownlabel, so a sleep window reads as one arc. - An open-arc gauge.
sweepandstartAngleshape a partial ring when the scale has two real ends. - An approximate value where the feel of turning matters more than the exact
number. Pass
formatValueso the value also reaches assistive technology as words.
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.
When not to use
- For a plain bounded quantity in a form. Use Slider: a straight track is easier to aim at and reads faster.
- When the exact number matters. Use NumberField, so people can type a precise value. For a small quantity, Stepper.
- For an exact time of day. Use TimePicker; a ring is for choosing roughly, not for entering 07:45.
- To show a value people cannot change. Use Meter or Progress; a handle promises control.
- For a handful of named choices. Use SegmentedControl. A ring spreads a short list too thin to aim at, and a very long scale leaves too little arc per step.
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>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.
Error message
errorMessage renders under the ring, implies intent="error", marks each handle invalid and is announced politely. Passing the prop at all — even as undefined — wraps the dial in a column frame once, so an error can appear mid-drag without remounting the handle under your finger; that frame then takes ref and className.
On color
Inside a Surface the slider inherits tone="on-color": the track and the
arc take the plate's ink, the handle becomes a disc of the plate outlined in
its ink, and an intent paints the plate's own intent ink. The focus ring is
the plate's ink over a gap of the plate. Pass tone="on-color" yourself only
on a plate something else paints; an explicit tone always wins.
RTL
A circular slider does not mirror: dials and clock faces run clockwise in every locale, so the ring, the sweep and the arrow keys stay the same under dir="rtl". Only the text inside the ring follows the direction. There is no dir prop; the slider inherits from its container.
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 |
tone | "default" | "on-color" | inherited from <Surface> | Colour recipe for a coloured or dark surface: the track and fill take the plate's ink, the handle becomes a disc of the plate outlined in its ink, and the focus ring is the plate's ink over a gap of the plate |
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 |
errorMessage | ReactNode | — | Error text under the ring. Implies intent="error", joins each handle's aria-describedby, sets aria-invalid; suppressed under readOnly. Passing the prop wraps the dial in a frame that takes ref and className |
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 |
Accessibility
VoiceOver + Safari: passed 2026-09-14. Focus reads "35%, Brightness, slider"; Right and Up arrows read "36%", "37%", "38%". NVDA and JAWS are untested.
- 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.errorMessagedescribes every handle and setsaria-invalid; the polite region it renders into is mounted before the error arrives, so it is announced.- Contrast (
audit:contrast, both themes): the arc paints the same intent fills over the samesecondarytrack as Slider, so three fills do not clear the 3:1 WCAG 1.4.11 asks:intent="warning"(1.7:1) andintent="success"(2.6:1) in the light theme, andintent="error"(2.8:1) in dark. That is the intent palette's own ceiling, accepted design-system-wide (§9, TES-272), not a CircularSlider tuning miss. The handle stays a neutral card disc in every intent and its position carries the value; a centre read-out inchildrenputs that value in words. Every other fill clears 3:1.