shadcn.io is not affiliated with official shadcn/ui
Shadcn Circular Slider for React and Tailwind
A progress-ring dial for selecting an angle.
45°
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.
Anatomy
CircularSlider
├── CircularSliderLabel
├── CircularSliderThumb
├── CircularSliderValue
├── CircularSliderContext
├── CircularSliderTrack
└── CircularSliderMarker
└── CircularSliderMarkerGroupUsage
import {
CircularSlider,
CircularSliderValue,
} from "@/components/ui/circular-slider";<CircularSlider aria-label="Angle" defaultValue={45}>
<CircularSliderValue suffix="°" />
</CircularSlider>Accessibility
| Key | Description |
|---|---|
ArrowLeft | Decrease the value. |
ArrowUp | Decrease the value. |
ArrowRight | Increase the value. |
ArrowDown | Increase the value. |
Home | Go to the start. |
End | Go to the end. |
Demos
Context
Controlled
Custom Markers
Disabled
Invalid
Read Only
Root Provider
Size
Step
Thickness
With Label
With Markers
With Value
API Reference
CircularSlider
div
PropType
AttributeType
CSS variableType
CircularSliderLabel
label
PropType
AttributeType
CircularSliderThumb
div
PropType
AttributeType
CSS variableType
CircularSliderValue
div
PropType
AttributeType
CircularSliderContext
div
PropType
CircularSliderMarkerGroup
div
PropType
AttributeType
CircularSliderMarker
div
PropType
AttributeType
CSS variableType
CircularSliderRootProvider
div
PropType
AttributeType
CSS variableType