Shadcn Circular Slider for React and Tailwind
A progress-ring dial for selecting an angle.
Installation
bunx --bun shadcn@latest add https://kit.dev/r/circular-slider.jsonpnpm dlx shadcn@latest add https://kit.dev/r/circular-slider.jsonnpx shadcn@latest add https://kit.dev/r/circular-slider.jsonyarn shadcn@latest add https://kit.dev/r/circular-slider.jsonInstall the following dependencies:
bun add @ark-ui/reactpnpm add @ark-ui/reactnpm install @ark-ui/reactyarn add @ark-ui/reactCopy and paste the following code into your project.
"use client";
import {
AngleSlider as ArkAngleSlider,
useAngleSlider as useArkAngleSlider,
useAngleSliderContext as useArkAngleSliderContext,
} from "@ark-ui/react/angle-slider";
import {
type ComponentProps,
type CSSProperties,
createContext,
type ReactNode,
useContext,
useMemo,
} from "react";
import { cn } from "@/lib/utils";
export const useCircularSlider = useArkAngleSlider;
export const useCircularSliderContext = useArkAngleSliderContext;
export const CircularSliderContext = ArkAngleSlider.Context;
const DEFAULT_SIZE = 100;
const DEFAULT_THICKNESS = 6;
const CLOCK_MARKER_ANGLES = [0, 60, 120, 180, 240, 300];
interface CircularSliderLayout {
ringCircumference: number;
ringRadius: number;
size: number;
thickness: number;
thumbSize: number;
}
const CircularSliderLayoutContext = createContext<CircularSliderLayout | null>(
null
);
const useCircularSliderLayout = () => {
const context = useContext(CircularSliderLayoutContext);
if (!context) {
throw new Error(
"CircularSlider layout parts must be used within CircularSlider or CircularSliderRootProvider"
);
}
return context;
};
interface CircularSliderLayoutProps {
/**
* Tick marks. `true` uses clock ticks (every 60°). A number array is custom angles.
*/
markers?: boolean | number[];
/**
* When `markers` is `true`, place a tick at every `step` instead of clock ticks.
*
* @default false
*/
markersAtSteps?: boolean;
/**
* Diameter of the ring, in pixels.
*
* @default 100
*/
size?: number;
/**
* Stroke width of the ring, in pixels.
*
* @default 6
*/
thickness?: number;
}
const getMarkerValues = (
markers: boolean | number[] | undefined,
markersAtSteps: boolean,
step: number
) => {
if (Array.isArray(markers) && markers.length > 0) {
return markers;
}
if (markers === true) {
return markersAtSteps
? Array.from(
{ length: Math.floor(360 / step) },
(_, index) => index * step
)
: CLOCK_MARKER_ANGLES;
}
return null;
};
const getLayout = (size: number, thickness: number): CircularSliderLayout => ({
ringCircumference: 2 * Math.PI * (size / 2 - thickness / 2),
ringRadius: size / 2 - thickness / 2,
size,
thickness,
thumbSize: Math.max(thickness + 8, 16),
});
const circularSliderRootClassName = cn(
"relative",
"inline-flex flex-col items-center justify-center",
"data-disabled:pointer-events-none data-disabled:opacity-64"
);
const CircularSliderChrome = (props: {
children?: ReactNode;
markerValues: number[] | null;
}) => {
const { children, markerValues } = props;
return (
<>
<ArkAngleSlider.Control
className="group/circular-slider-control absolute inset-0"
data-slot="circular-slider-control"
>
<CircularSliderProgressRing />
{markerValues ? (
<CircularSliderMarkerGroup>
{markerValues.map((value) => (
<CircularSliderMarker key={value} value={value} />
))}
</CircularSliderMarkerGroup>
) : null}
<CircularSliderThumb />
</ArkAngleSlider.Control>
{children}
<ArkAngleSlider.HiddenInput />
</>
);
};
export const CircularSlider = (
props: ComponentProps<typeof ArkAngleSlider.Root> & CircularSliderLayoutProps
) => {
const {
className,
children,
size = DEFAULT_SIZE,
thickness = DEFAULT_THICKNESS,
markers,
markersAtSteps = false,
step = 1,
style,
...rest
} = props;
const layout = useMemo(() => getLayout(size, thickness), [size, thickness]);
const markerValues = getMarkerValues(markers, markersAtSteps, step);
return (
<CircularSliderLayoutContext.Provider value={layout}>
<ArkAngleSlider.Root
className={cn(circularSliderRootClassName, className)}
data-slot="circular-slider"
step={step}
style={
{
"--size": `${size}px`,
"--thickness": `${thickness}px`,
height: size,
width: size,
...style,
} as CSSProperties
}
{...rest}
>
<CircularSliderChrome markerValues={markerValues}>
{children}
</CircularSliderChrome>
</ArkAngleSlider.Root>
</CircularSliderLayoutContext.Provider>
);
};
export const CircularSliderRootProvider = (
props: ComponentProps<typeof ArkAngleSlider.RootProvider> &
CircularSliderLayoutProps & {
/**
* Used with `markersAtSteps` to place ticks. Should match the machine `step`.
*
* @default 1
*/
step?: number;
}
) => {
const {
className,
children,
size = DEFAULT_SIZE,
thickness = DEFAULT_THICKNESS,
markers,
markersAtSteps = false,
step = 1,
style,
...rest
} = props;
const layout = useMemo(() => getLayout(size, thickness), [size, thickness]);
const markerValues = getMarkerValues(markers, markersAtSteps, step);
return (
<CircularSliderLayoutContext.Provider value={layout}>
<ArkAngleSlider.RootProvider
className={cn(circularSliderRootClassName, className)}
data-slot="circular-slider"
style={
{
"--size": `${size}px`,
"--thickness": `${thickness}px`,
height: size,
width: size,
...style,
} as CSSProperties
}
{...rest}
>
<CircularSliderChrome markerValues={markerValues}>
{children}
</CircularSliderChrome>
</ArkAngleSlider.RootProvider>
</CircularSliderLayoutContext.Provider>
);
};
const CircularSliderProgressRing = () => {
const api = useCircularSliderContext();
const { size, thickness, ringRadius, ringCircumference } =
useCircularSliderLayout();
const percent = api.value / 360;
const dashLength = percent * ringCircumference;
const gapLength = ringCircumference - dashLength;
const center = size / 2;
return (
<svg
aria-hidden="true"
className="pointer-events-none -rotate-90"
height={size}
viewBox={`0 0 ${size} ${size}`}
width={size}
>
<circle
className="stroke-muted"
cx={center}
cy={center}
fill="transparent"
r={ringRadius}
strokeWidth={thickness}
/>
<circle
className="stroke-primary [stroke-linecap:round]"
cx={center}
cy={center}
fill="transparent"
r={ringRadius}
strokeDasharray={`${dashLength} ${gapLength}`}
strokeWidth={thickness}
/>
</svg>
);
};
export const CircularSliderThumb = (
props: ComponentProps<typeof ArkAngleSlider.Thumb>
) => {
const { className, ...rest } = props;
const { thumbSize, ringRadius } = useCircularSliderLayout();
const halfThumb = thumbSize / 2;
return (
<ArkAngleSlider.Thumb
className={cn(
"absolute inset-0 z-10 flex items-center justify-center",
"outline-none",
"focus-visible:[&_span]:outline-hidden focus-visible:[&_span]:ring-2 focus-visible:[&_span]:ring-ring/32",
"active:[&_span]:scale-110 active:[&_span]:ring-[3px] active:[&_span]:ring-ring/32",
className
)}
data-slot="circular-slider-thumb"
style={
{
"--size": `${thumbSize}px`,
} as CSSProperties
}
{...rest}
>
<span
className={cn(
"absolute",
"shrink-0",
"bg-white",
"size-(--size)",
"rounded-full shadow-xs/5 ring-2 ring-border",
"transition-all",
"hover:cursor-grab hover:ring-[3px]",
"motion-reduce:transition-none!"
)}
style={
{
insetBlockStart: `calc(50% - ${ringRadius}px - ${halfThumb}px)`,
insetInlineStart: `calc(50% - ${halfThumb}px)`,
} as CSSProperties
}
/>
</ArkAngleSlider.Thumb>
);
};
export const CircularSliderLabel = (
props: ComponentProps<typeof ArkAngleSlider.Label>
) => {
const { className, ...rest } = props;
return (
<ArkAngleSlider.Label
className={cn("z-10 select-none font-medium text-sm", className)}
data-slot="circular-slider-label"
{...rest}
/>
);
};
interface CircularSliderValueProps
extends Omit<ComponentProps<typeof ArkAngleSlider.ValueText>, "prefix"> {
prefix?: ReactNode;
suffix?: ReactNode;
}
export const CircularSliderValue = (props: CircularSliderValueProps) => {
const { prefix, suffix, className, children, ...rest } = props;
const { value } = useCircularSliderContext();
return (
<ArkAngleSlider.ValueText
className={cn(
"z-10 flex items-center gap-1",
"font-medium tabular-nums",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
data-slot="circular-slider-value"
{...rest}
>
{children ?? (
<>
{prefix}
{value}
{suffix}
</>
)}
</ArkAngleSlider.ValueText>
);
};
export const CircularSliderMarkerGroup = (
props: ComponentProps<typeof ArkAngleSlider.MarkerGroup>
) => {
const { className, ...rest } = props;
return (
<ArkAngleSlider.MarkerGroup
className={cn(
"absolute inset-0 z-0",
"rounded-full",
"pointer-events-none",
className
)}
data-slot="circular-slider-marker-group"
{...rest}
/>
);
};
export const CircularSliderMarker = (
props: ComponentProps<typeof ArkAngleSlider.Marker>
) => {
const { className, style, ...rest } = props;
const { size, thickness } = useCircularSliderLayout();
const ringRadius = size / 2 - thickness / 2;
const markerHeight = Math.max(8, Math.min(thickness * 1.1, 16));
const markerWidth = Math.max(4, Math.min(thickness * 0.4, 6));
const markerOffset =
size / 2 - ringRadius - markerHeight / 2 + (thickness + 4);
return (
<ArkAngleSlider.Marker
className={cn(
"absolute inset-s-[calc(50%-1px)] top-0 bottom-0 w-0.5",
"before:absolute before:inset-s-1/2 before:top-(--marker-offset) before:-translate-x-1/2",
"before:h-(--marker-height) before:w-(--marker-width) before:rounded-md before:bg-border",
"data-[state=at-value]:before:bg-primary",
"data-[state=under-value]:before:bg-primary",
className
)}
data-slot="circular-slider-marker"
style={
{
...style,
"--marker-height": `${markerHeight}px`,
"--marker-offset": `${markerOffset}px`,
"--marker-width": `${markerWidth}px`,
} as CSSProperties
}
{...rest}
/>
);
};Update the import paths to match your project setup.
Circular Slider is the progress-ring preset of Ark Angle Slider. Same machine (0–360°, step, keyboard). The ring, thumb, and hidden input are baked in. Use Angle Slider when you want a needle knob and assembled parts.
Anatomy
CircularSlider
├── Control (baked in)
│ ├── Progress ring
│ ├── MarkerGroup / Marker (when `markers` is set)
│ └── Thumb (baked in)
├── CircularSliderLabel
├── CircularSliderValue
└── HiddenInput (baked in)useCircularSlider is the machine hook for CircularSliderRootProvider. useCircularSliderContext / CircularSliderContext is in-tree.
Usage
import {
CircularSlider,
CircularSliderValue,
} from "@/components/ui/circular-slider";<CircularSlider aria-label="Angle" defaultValue={45}>
<CircularSliderValue suffix="°" />
</CircularSlider>Pass aria-label (or CircularSliderLabel) — the thumb is a role="slider" with no visible name by default.
shadcn.io size defaults to 100 and thickness to 6.
Controlled
Use value and onValueChange. { value } is the angle in degrees.
Root Provider
Use useCircularSlider with CircularSliderRootProvider when you need the API outside the tree. Pass machine options (defaultValue, step, disabled, …) to useCircularSlider(), not to the provider. Pass size / thickness / markers to the provider (layout).
Context
CircularSliderContext / useCircularSliderContext read the angle inside the tree.
States
Disabled
Invalid
Read-only
Focusable but not editable.
Examples
Label
CircularSliderLabel sits in the center of the ring and names the thumb.
Size
size is the diameter in pixels.
Thickness
thickness is the ring stroke width in pixels.
Markers
markers shows clock ticks (every 60°).
Step
step snaps the thumb. Combine markers and markersAtSteps to put a tick on each step.
Value
CircularSliderValue shows the current angle. prefix and suffix wrap the number. Pass children to replace the default readout.
Custom markers
Pass a number[] of angles to markers.
API Reference
shadcn.io wraps Ark UI Angle Slider as a filled-ring control. Control, thumb, ring, and hidden input are baked into CircularSlider / CircularSliderRootProvider.
asChild merges props onto a single child element. The thumb is a role="slider" with values from 0 to 360.
CircularSlider
Root. Renders a div. Sets --value and --angle (machine) plus --size and --thickness (shadcn.io).
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | - | Accessible name for the thumb when there is no visible label. |
aria-labelledby | string | - | Id of the element that labels the thumb. Defaults to the label id. |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
defaultValue | number | 0 | Uncontrolled initial angle in degrees. |
dir | "ltr" | "rtl" | - | Text direction. Usually inherited from LocaleProvider. |
disabled | boolean | - | Disable pointer and keyboard input. |
id | string | - | Unique id for the machine. |
ids | Partial<{ root: string; thumb: string; hiddenInput: string; control: string; valueText: string; label: string }> | - | Element ids for composition. |
invalid | boolean | - | Marks the slider as invalid. |
markers | boolean | number[] | - | true draws clock ticks. An array is custom angles. |
markersAtSteps | boolean | false | When markers is true, tick every step instead of 60°. |
name | string | - | Name for form submission (hidden input). |
onValueChange | (details: ValueChangeDetails) => void | - | Called when the value changes. { value, valueAsDegree }. |
onValueChangeEnd | (details: ValueChangeDetails) => void | - | Called when dragging or stepping ends. |
readOnly | boolean | - | Focusable but not editable. |
size | number | 100 | Diameter in pixels (--size). |
step | number | 1 | Snap increment in degrees. |
thickness | number | 6 | Ring stroke width in pixels (--thickness). |
value | number | - | Controlled angle in degrees (0–360). |
| Attribute | Description |
|---|---|
data-slot | circular-slider |
data-scope | angle-slider |
data-part | root |
data-disabled | Present when disabled |
data-invalid | Present when invalid |
data-readonly | Present when read-only |
| CSS variable | Description |
|---|---|
--value | Current numeric value |
--angle | Display angle used to rotate the thumb (accounts for dir) |
--size | Diameter (shadcn.io) |
--thickness | Ring stroke width (shadcn.io) |
CircularSliderRootProvider
Takes the API from useCircularSlider. Same baked ring as CircularSlider.
| Prop | Type | Default | Description |
|---|---|---|---|
value | UseAngleSliderReturn | required | Return value of useCircularSlider(). |
asChild | boolean | false | Render the child element instead of a div. |
className | string | - | Class names on the root. |
markers | boolean | number[] | - | Same as CircularSlider. |
markersAtSteps | boolean | false | Same as CircularSlider. |
size | number | 100 | Diameter in pixels. |
step | number | 1 | Used only to place markersAtSteps ticks. Match the machine step. |
thickness | number | 6 | Ring stroke width in pixels. |
Pass defaultValue, step, disabled, name, and other machine options to useCircularSlider(), not to the provider.
CircularSliderLabel
Visible name in the center of the ring. Clicking it focuses the thumb. Renders a label.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the label. |
| Attribute | Description |
|---|---|
data-slot | circular-slider-label |
data-scope | angle-slider |
data-part | label |
data-disabled | Present when disabled |
data-invalid | Present when invalid |
data-readonly | Present when read-only |
CircularSliderValue
Current angle. Renders a div. Default content is prefix + value + suffix. Pass children to replace that.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the value. |
prefix | ReactNode | - | Content before the number. |
suffix | ReactNode | - | Content after the number. |
children | ReactNode | - | Custom content. Overrides prefix / value / suffix. |
| Attribute | Description |
|---|---|
data-slot | circular-slider-value |
data-scope | angle-slider |
data-part | value-text |
CircularSliderThumb
Baked into the root. Renders a div with role="slider". Rotated with --angle. The visible knob is a child span on the ring.
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge onto a single child. |
className | string | - | Class names on the thumb. |
| Attribute | Description |
|---|---|
data-slot | circular-slider-thumb |
data-scope | angle-slider |
data-part | thumb |
data-disabled | Present when disabled |
data-invalid | Present when invalid |
data-readonly | Present when read-only |
aria-valuemin={0}, aria-valuemax={360}, aria-valuenow is the current angle.
CircularSliderMarkerGroup / CircularSliderMarker
Baked when markers is set. You can also render them yourself. Marker value is the angle in degrees.
| Attribute | Description |
|---|---|
data-slot | circular-slider-marker-group / circular-slider-marker |
data-scope | angle-slider |
data-part | marker-group / marker |
data-value | Marker angle |
data-state | "under-value", "at-value", or "over-value" |
useCircularSlider
Creates the angle slider API for CircularSliderRootProvider. Accepts the same machine options as CircularSlider except layout-only props (size, thickness, markers, markersAtSteps, asChild, className).
const slider = useCircularSlider({ defaultValue: 45, step: 15 });
slider.setValue(90);CircularSliderContext / useCircularSliderContext
Render-prop or hook access. Use inside CircularSlider or CircularSliderRootProvider.
| Property | Type | Description |
|---|---|---|
value | number | Current angle in degrees. |
valueAsDegree | string | Formatted degree string (for example "45deg"). |
dragging | boolean | Whether the thumb is being dragged. |
setValue | (value: number) => void | Set the angle. |
CircularSliderContext children: (context) => ReactNode.
Accessibility
The thumb is a role="slider" with aria-valuemin={0}, aria-valuemax={360}, and aria-valuenow set to the current value. Pair it with CircularSliderLabel, or pass aria-label / aria-labelledby on CircularSlider. The SVG ring is aria-hidden.
Keyboard support
| Key | Description |
|---|---|
ArrowRight | Increase the value by step. Respects RTL. |
ArrowLeft | Decrease the value by step. |
ArrowUp | Decrease the value by step. |
ArrowDown | Increase the value by step. |
Home | Set the value to 0. |
End | Set the value to 360. |
Shift + Arrow | Larger step (native event step multiplier). |