DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. CircularSlider

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/ui

When 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 children and the ring frames it, as a dimmer or a thermostat does.
  • A range on a ring. A two-number value gives two handles, each with its own label, so a sleep window reads as one arc.
  • An open-arc gauge. sweep and startAngle shape 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 formatValue so 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

Terminal
npx shadcn@latest add https://tessinaui.com/r/circular-slider.json

Usage

example.tsx
import { CircularSlider } from "@tessinaui/ui";
example.tsx
<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

PropTypeDefaultDescription
valuenumber | [number, number]—Controlled value. A two-number array makes it a range
defaultValuenumber | [number, number]minUncontrolled initial value
minnumber0Minimum
maxnumber100Maximum
stepnumber1Step increment
largeStepnumberstep × 10Step 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
diameternumberfrom sizeDiameter in px, overriding size
thicknessnumber7.5% of diameterTrack thickness in px
startAnglenumber0Where min sits, in degrees clockwise from 12 o'clock
sweepnumber360How much of the circle the scale spans, in degrees
wrapbooleanfalseWhether the scale wraps at the ends. Forced off when sweep < 360
intent"none" | "error" | "warning" | "success" | "info""none"Arc and focus-ring colour
ticksnumber | number[]—Tick dots: a count, or explicit values
handleIconReactNode | [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
labelstring | [string, string]"Value"Accessible name. A range takes two
formatValue(value: number) => string—Human text for aria-valuetext and your own read-out
errorMessageReactNode—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
disabledbooleanfalse—
readOnlybooleanfalseFocusable, not changeable
childrenReactNode—Rendered in the middle of the ring

Sizes:

SizeDiameterHandle
xs112px24px
sm136px28px
md160px32px
lg200px36px
xl248px40px

CircularSliderSkeleton

Placeholder ring. Its box comes from the component's own diameter map, so the two cannot drift.

PropTypeDefaultDescription
sizeCircularSliderSize"md"Diameter preset
diameternumberfrom sizeDiameter 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" with aria-valuemin / aria-valuemax / aria-valuenow, focusable in source order.
  • Pass formatValue. A bare 22 on a clock face means nothing; it becomes aria-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-hidden and focusable="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).
  • readOnly keeps focus and announces aria-readonly; disabled removes the handles from the tab order.
  • errorMessage describes every handle and sets aria-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 same secondary track as Slider, so three fills do not clear the 3:1 WCAG 1.4.11 asks: intent="warning" (1.7:1) and intent="success" (2.6:1) in the light theme, and intent="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 in children puts that value in words. Every other fill clears 3:1.
SliderRating

On this page

When to useWhen not to useInstallationUsageExamplesDefaultSleep rangeGaugeError messageOn colorRTLAPI ReferenceCircularSliderSizes:CircularSliderSkeletonAccessibility