Angle Slider
An interactive circular slider for selecting angles with support for single values and ranges.
import {
AngleSlider,
AngleSliderRange,
AngleSliderThumb,
AngleSliderTrack,
AngleSliderValue,
} from "@/components/ui/angle-slider";
export function AngleSliderDemo() {
return (
<AngleSlider defaultValue={[180]} max={360} min={0} step={1}>
<AngleSliderTrack>
<AngleSliderRange />
</AngleSliderTrack>
<AngleSliderThumb />
<AngleSliderValue />
</AngleSlider>
);
}
Installation
CLI
npx shadcn@latest add "https://diceui.com/r/angle-slider"
Manual
Install the following dependencies:
npm install @radix-ui/react-slot
Copy the refs composition utilities into your lib/compose-refs.ts
file.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
Copy the visually hidden input component into your components/visually-hidden-input.tsx
file.
"use client";
import * as React from "react";
type InputValue = string[] | string;
interface VisuallyHiddenInputProps<T = InputValue>
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "checked" | "onReset"
> {
value?: T;
checked?: boolean;
control: HTMLElement | null;
bubbles?: boolean;
}
function VisuallyHiddenInput<T = InputValue>(
props: VisuallyHiddenInputProps<T>,
) {
const {
control,
value,
checked,
bubbles = true,
type = "hidden",
style,
...inputProps
} = props;
const isCheckInput = React.useMemo(
() => type === "checkbox" || type === "radio" || type === "switch",
[type],
);
const inputRef = React.useRef<HTMLInputElement>(null);
const prevValueRef = React.useRef<{
value: T | boolean | undefined;
previous: T | boolean | undefined;
}>({
value: isCheckInput ? checked : value,
previous: isCheckInput ? checked : value,
});
const prevValue = React.useMemo(() => {
const currentValue = isCheckInput ? checked : value;
if (prevValueRef.current.value !== currentValue) {
prevValueRef.current.previous = prevValueRef.current.value;
prevValueRef.current.value = currentValue;
}
return prevValueRef.current.previous;
}, [isCheckInput, value, checked]);
const [controlSize, setControlSize] = React.useState<{
width?: number;
height?: number;
}>({});
React.useLayoutEffect(() => {
if (!control) {
setControlSize({});
return;
}
setControlSize({
width: control.offsetWidth,
height: control.offsetHeight,
});
if (typeof window === "undefined") return;
const resizeObserver = new ResizeObserver((entries) => {
if (!Array.isArray(entries) || !entries.length) return;
const entry = entries[0];
if (!entry) return;
let width: number;
let height: number;
if ("borderBoxSize" in entry) {
const borderSizeEntry = entry.borderBoxSize;
const borderSize = Array.isArray(borderSizeEntry)
? borderSizeEntry[0]
: borderSizeEntry;
width = borderSize.inlineSize;
height = borderSize.blockSize;
} else {
width = control.offsetWidth;
height = control.offsetHeight;
}
setControlSize({ width, height });
});
resizeObserver.observe(control, { box: "border-box" });
return () => {
resizeObserver.disconnect();
};
}, [control]);
React.useEffect(() => {
const input = inputRef.current;
if (!input) return;
const inputProto = window.HTMLInputElement.prototype;
const propertyKey = isCheckInput ? "checked" : "value";
const eventType = isCheckInput ? "click" : "input";
const currentValue = isCheckInput ? checked : value;
const serializedCurrentValue = isCheckInput
? checked
: typeof value === "object" && value !== null
? JSON.stringify(value)
: value;
const descriptor = Object.getOwnPropertyDescriptor(inputProto, propertyKey);
const setter = descriptor?.set;
if (prevValue !== currentValue && setter) {
const event = new Event(eventType, { bubbles });
setter.call(input, serializedCurrentValue);
input.dispatchEvent(event);
}
}, [prevValue, value, checked, bubbles, isCheckInput]);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
...style,
...(controlSize.width !== undefined && controlSize.height !== undefined
? controlSize
: {}),
border: 0,
clip: "rect(0 0 0 0)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: 0,
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
};
}, [style, controlSize]);
return (
<input
type={type}
{...inputProps}
ref={inputRef}
aria-hidden={isCheckInput}
tabIndex={-1}
defaultChecked={isCheckInput ? checked : undefined}
style={composedStyle}
/>
);
}
export { VisuallyHiddenInput };
Copy and paste the following code into your project.
"use client";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { VisuallyHiddenInput } from "@/components/components/visually-hidden-input";
const ROOT_NAME = "AngleSlider";
const THUMB_NAME = "AngleSliderThumb";
const PAGE_KEYS = ["PageUp", "PageDown"];
const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
function clamp(value: number, [min, max]: [number, number]) {
return Math.min(max, Math.max(min, value));
}
function getNextSortedValues(
prevValues: number[] = [],
nextValue: number,
atIndex: number,
) {
const nextValues = [...prevValues];
nextValues[atIndex] = nextValue;
return nextValues.sort((a, b) => a - b);
}
function getStepsBetweenValues(values: number[]) {
return values.slice(0, -1).map((value, index) => {
const nextValue = values[index + 1];
return nextValue !== undefined ? nextValue - value : 0;
});
}
function hasMinStepsBetweenValues(
values: number[],
minStepsBetweenValues: number,
) {
if (minStepsBetweenValues > 0) {
const stepsBetweenValues = getStepsBetweenValues(values);
const actualMinStepsBetweenValues =
stepsBetweenValues.length > 0 ? Math.min(...stepsBetweenValues) : 0;
return actualMinStepsBetweenValues >= minStepsBetweenValues;
}
return true;
}
function getDecimalCount(value: number) {
return (String(value).split(".")[1] ?? "").length;
}
function roundValue(value: number, decimalCount: number) {
const rounder = 10 ** decimalCount;
return Math.round(value * rounder) / rounder;
}
function getClosestValueIndex(values: number[], nextValue: number) {
if (values.length === 1) return 0;
const distances = values.map((value) => Math.abs(value - nextValue));
const closestDistance = Math.min(...distances);
return distances.indexOf(closestDistance);
}
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
type Direction = "ltr" | "rtl";
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type RootElement = React.ComponentRef<typeof AngleSliderRoot>;
type ThumbElement = React.ComponentRef<typeof AngleSliderThumb>;
interface ThumbData {
id: string;
element: ThumbElement;
index: number;
value: number;
}
interface StoreState {
values: number[];
thumbs: Map<number, ThumbData>;
valueIndexToChange: number;
min: number;
max: number;
step: number;
size: number;
thickness: number;
startAngle: number;
endAngle: number;
minStepsBetweenThumbs: number;
disabled: boolean;
inverted: boolean;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
addThumb: (index: number, thumbData: ThumbData) => void;
removeThumb: (index: number) => void;
updateValue: (
value: number,
atIndex: number,
options?: { commit?: boolean },
) => void;
getValueFromPointer: (
clientX: number,
clientY: number,
rect: DOMRect,
) => number;
getAngleFromValue: (value: number) => number;
getPositionFromAngle: (angle: number) => { x: number; y: number };
}
function createStore(
listenersRef: React.RefObject<Set<() => void>>,
stateRef: React.RefObject<StoreState>,
onValueChange?: (value: number[]) => void,
onValueCommit?: (value: number[]) => void,
): Store {
const store: Store = {
subscribe: (cb) => {
if (listenersRef.current) {
listenersRef.current.add(cb);
return () => listenersRef.current?.delete(cb);
}
return () => {};
},
getState: () =>
stateRef.current ?? {
values: [0],
thumbs: new Map(),
valueIndexToChange: 0,
min: 0,
max: 100,
step: 1,
minStepsBetweenThumbs: 0,
size: 80,
thickness: 8,
startAngle: -90,
endAngle: 270,
disabled: false,
inverted: false,
},
setState: (key, value) => {
const state = stateRef.current;
if (!state || Object.is(state[key], value)) return;
if (key === "values" && Array.isArray(value)) {
const hasChanged = String(state.values) !== String(value);
state.values = value;
if (hasChanged) {
onValueChange?.(value);
}
} else {
state[key] = value;
}
store.notify();
},
addThumb: (index, thumbData) => {
const state = stateRef.current;
if (state) {
state.thumbs.set(index, thumbData);
store.notify();
}
},
removeThumb: (index) => {
const state = stateRef.current;
if (state) {
state.thumbs.delete(index);
store.notify();
}
},
updateValue: (value, atIndex, { commit = false } = {}) => {
const state = stateRef.current;
if (!state) return;
const { min, max, step, minStepsBetweenThumbs } = state;
const decimalCount = getDecimalCount(step);
const snapToStep = roundValue(
Math.round((value - min) / step) * step + min,
decimalCount,
);
const nextValue = clamp(snapToStep, [min, max]);
const nextValues = getNextSortedValues(state.values, nextValue, atIndex);
if (hasMinStepsBetweenValues(nextValues, minStepsBetweenThumbs * step)) {
state.valueIndexToChange = nextValues.indexOf(nextValue);
const hasChanged = String(nextValues) !== String(state.values);
if (hasChanged) {
state.values = nextValues;
onValueChange?.(nextValues);
if (commit) onValueCommit?.(nextValues);
store.notify();
}
}
},
getValueFromPointer: (clientX, clientY, rect) => {
const state = stateRef.current;
if (!state) return 0;
const { min, max, inverted, startAngle, endAngle } = state;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const deltaX = clientX - centerX;
const deltaY = clientY - centerY;
let angle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
if (angle < 0) angle += 360;
angle = (angle - startAngle + 360) % 360;
const totalAngle = (endAngle - startAngle + 360) % 360 || 360;
let percent = angle / totalAngle;
if (inverted) percent = 1 - percent;
return min + percent * (max - min);
},
getAngleFromValue: (value) => {
const state = stateRef.current;
if (!state) return 0;
const { min, max, inverted, startAngle, endAngle } = state;
let percent = (value - min) / (max - min);
if (inverted) percent = 1 - percent;
const totalAngle = (endAngle - startAngle + 360) % 360 || 360;
const angle = startAngle + percent * totalAngle;
return angle;
},
getPositionFromAngle: (angle) => {
const state = stateRef.current;
if (!state) return { x: 0, y: 0 };
const { size } = state;
const radians = (angle * Math.PI) / 180;
return {
x: size * Math.cos(radians),
y: size * Math.sin(radians),
};
},
notify: () => {
if (listenersRef.current) {
for (const cb of listenersRef.current) {
cb();
}
}
},
};
return store;
}
const DirectionContext = React.createContext<Direction | undefined>(undefined);
function useDirection(dirProp?: Direction): Direction {
const contextDir = React.useContext(DirectionContext);
return dirProp ?? contextDir ?? "ltr";
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(selector: (state: StoreState) => T): T {
const store = useStoreContext("useStore");
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface SliderContextValue {
dir: Direction;
name?: string;
form?: string;
}
const SliderContext = React.createContext<SliderContextValue | null>(null);
function useSliderContext(consumerName: string) {
const context = React.useContext(SliderContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface AngleSliderRootProps extends Omit<DivProps, "defaultValue"> {
value?: number[];
defaultValue?: number[];
onValueChange?: (value: number[]) => void;
onValueCommit?: (value: number[]) => void;
min?: number;
max?: number;
step?: number;
minStepsBetweenThumbs?: number;
size?: number;
thickness?: number;
startAngle?: number;
endAngle?: number;
dir?: Direction;
form?: string;
name?: string;
disabled?: boolean;
inverted?: boolean;
}
function AngleSliderRoot(props: AngleSliderRootProps) {
const {
value,
defaultValue = [0],
onValueChange,
onValueCommit,
min = 0,
max = 100,
step = 1,
minStepsBetweenThumbs = 0,
size = 60,
thickness = 8,
startAngle = -90,
endAngle = 270,
dir: dirProp,
form,
name,
disabled = false,
inverted = false,
asChild,
className,
children,
ref,
...rootProps
} = props;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => ({
values: value ?? defaultValue,
thumbs: new Map(),
valueIndexToChange: 0,
min,
max,
step,
minStepsBetweenThumbs,
disabled,
inverted,
size,
thickness,
startAngle,
endAngle,
}));
const store = React.useMemo(
() => createStore(listenersRef, stateRef, onValueChange, onValueCommit),
[listenersRef, stateRef, onValueChange, onValueCommit],
);
useIsomorphicLayoutEffect(() => {
if (value !== undefined) {
store.setState("values", value);
}
}, [value, store]);
useIsomorphicLayoutEffect(() => {
const currentState = store.getState();
if (currentState.min !== min) {
store.setState("min", min);
}
if (currentState.max !== max) {
store.setState("max", max);
}
if (currentState.step !== step) {
store.setState("step", step);
}
if (currentState.minStepsBetweenThumbs !== minStepsBetweenThumbs) {
store.setState("minStepsBetweenThumbs", minStepsBetweenThumbs);
}
if (currentState.size !== size) {
store.setState("size", size);
}
if (currentState.thickness !== thickness) {
store.setState("thickness", thickness);
}
if (currentState.startAngle !== startAngle) {
store.setState("startAngle", startAngle);
}
if (currentState.endAngle !== endAngle) {
store.setState("endAngle", endAngle);
}
if (currentState.disabled !== disabled) {
store.setState("disabled", disabled);
}
if (currentState.inverted !== inverted) {
store.setState("inverted", inverted);
}
}, [
store,
min,
max,
step,
minStepsBetweenThumbs,
size,
thickness,
startAngle,
endAngle,
disabled,
inverted,
]);
const dir = useDirection(dirProp);
const [sliderElement, setSliderElement] = React.useState<RootElement | null>(
null,
);
const composedRef = useComposedRefs(ref, setSliderElement);
const valuesBeforeSlideStartRef = React.useRef(value ?? defaultValue);
const contextValue = React.useMemo<SliderContextValue>(
() => ({
dir,
name,
form,
}),
[dir, name, form],
);
const onSliderStart = React.useCallback(
(pointerValue: number) => {
if (disabled) return;
const values = store.getState().values;
const closestIndex = getClosestValueIndex(values, pointerValue);
store.setState("valueIndexToChange", closestIndex);
store.updateValue(pointerValue, closestIndex);
},
[store, disabled],
);
const onSliderMove = React.useCallback(
(pointerValue: number) => {
if (disabled) return;
const valueIndexToChange = store.getState().valueIndexToChange;
store.updateValue(pointerValue, valueIndexToChange);
},
[store, disabled],
);
const onSliderEnd = React.useCallback(() => {
if (disabled) return;
const state = store.getState();
const prevValue =
valuesBeforeSlideStartRef.current[state.valueIndexToChange];
const nextValue = state.values[state.valueIndexToChange];
const hasChanged = nextValue !== prevValue;
if (hasChanged) {
onValueCommit?.(state.values);
}
}, [store, disabled, onValueCommit]);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<RootElement>) => {
rootProps.onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
const state = store.getState();
const { values, valueIndexToChange, min, max, step } = state;
const currentValue = values[valueIndexToChange] ?? min;
if (event.key === "Home") {
event.preventDefault();
store.updateValue(min, 0, { commit: true });
} else if (event.key === "End") {
event.preventDefault();
store.updateValue(max, values.length - 1, { commit: true });
} else if (PAGE_KEYS.concat(ARROW_KEYS).includes(event.key)) {
event.preventDefault();
const isPageKey = PAGE_KEYS.includes(event.key);
const isSkipKey =
isPageKey || (event.shiftKey && ARROW_KEYS.includes(event.key));
const multiplier = isSkipKey ? 10 : 1;
let direction = 0;
const isDecreaseKey = ["ArrowLeft", "ArrowUp", "PageUp"].includes(
event.key,
);
direction = isDecreaseKey ? -1 : 1;
if (inverted) direction *= -1;
const stepInDirection = step * multiplier * direction;
store.updateValue(currentValue + stepInDirection, valueIndexToChange, {
commit: true,
});
}
},
[rootProps.onKeyDown, disabled, store, inverted],
);
const onPointerDown = React.useCallback(
(event: React.PointerEvent<RootElement>) => {
rootProps.onPointerDown?.(event);
if (event.defaultPrevented || disabled) return;
const target = event.target as HTMLElement;
target.setPointerCapture(event.pointerId);
event.preventDefault();
if (!disabled) {
valuesBeforeSlideStartRef.current = store.getState().values;
const thumbs = Array.from(store.getState().thumbs.values());
const clickedThumb = thumbs.find((thumb) =>
thumb.element.contains(target),
);
if (clickedThumb) {
clickedThumb.element.focus();
store.setState("valueIndexToChange", clickedThumb.index);
} else if (sliderElement) {
const rect = sliderElement.getBoundingClientRect();
const pointerValue = store.getValueFromPointer(
event.clientX,
event.clientY,
rect,
);
onSliderStart(pointerValue);
}
}
},
[rootProps.onPointerDown, disabled, store, sliderElement, onSliderStart],
);
const onPointerMove = React.useCallback(
(event: React.PointerEvent<RootElement>) => {
rootProps.onPointerMove?.(event);
if (event.defaultPrevented || disabled) return;
const target = event.target as HTMLElement;
if (target.hasPointerCapture(event.pointerId) && sliderElement) {
const rect = sliderElement.getBoundingClientRect();
const pointerValue = store.getValueFromPointer(
event.clientX,
event.clientY,
rect,
);
onSliderMove(pointerValue);
}
},
[rootProps.onPointerMove, disabled, sliderElement, store, onSliderMove],
);
const onPointerUp = React.useCallback(
(event: React.PointerEvent<RootElement>) => {
rootProps.onPointerUp?.(event);
if (event.defaultPrevented) return;
const target = event.target as RootElement;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
onSliderEnd();
}
},
[rootProps.onPointerUp, onSliderEnd],
);
const RootPrimitive = asChild ? Slot : "div";
return (
<StoreContext.Provider value={store}>
<SliderContext.Provider value={contextValue}>
<RootPrimitive
data-disabled={disabled ? "" : undefined}
data-slot="angle-slider"
dir={dir}
{...rootProps}
ref={composedRef}
className={cn(
"relative touch-none select-none",
disabled && "opacity-50",
className,
)}
style={{
width: `${size * 2 + 40}px`,
height: `${size * 2 + 40}px`,
}}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
{children}
</RootPrimitive>
</SliderContext.Provider>
</StoreContext.Provider>
);
}
function AngleSliderTrack(props: React.ComponentProps<"svg">) {
const { className, children, ...trackProps } = props;
const disabled = useStore((state) => state.disabled);
const size = useStore((state) => state.size);
const thickness = useStore((state) => state.thickness);
const startAngle = useStore((state) => state.startAngle);
const endAngle = useStore((state) => state.endAngle);
const center = size + 20;
const trackRadius = size;
const totalAngle = (endAngle - startAngle + 360) % 360 || 360;
const isFullCircle = totalAngle >= 359;
const startRadians = (startAngle * Math.PI) / 180;
const endRadians = (endAngle * Math.PI) / 180;
const startX = center + trackRadius * Math.cos(startRadians);
const startY = center + trackRadius * Math.sin(startRadians);
const endX = center + trackRadius * Math.cos(endRadians);
const endY = center + trackRadius * Math.sin(endRadians);
const largeArcFlag = totalAngle > 180 ? 1 : 0;
return (
<svg
aria-hidden="true"
focusable="false"
data-disabled={disabled ? "" : undefined}
data-slot="angle-slider-track"
width={center * 2}
height={center * 2}
{...trackProps}
className={cn("absolute inset-0", className)}
>
{isFullCircle ? (
<circle
data-slot="angle-slider-track-rail"
cx={center}
cy={center}
r={trackRadius}
fill="none"
stroke="currentColor"
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className="stroke-muted"
/>
) : (
<path
data-slot="angle-slider-track-rail"
d={`M ${startX} ${startY} A ${trackRadius} ${trackRadius} 0 ${largeArcFlag} 1 ${endX} ${endY}`}
fill="none"
stroke="currentColor"
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className="stroke-muted"
/>
)}
{children}
</svg>
);
}
function AngleSliderRange(props: React.ComponentProps<"path">) {
const { className, ...rangeProps } = props;
const values = useStore((state) => state.values);
const min = useStore((state) => state.min);
const max = useStore((state) => state.max);
const disabled = useStore((state) => state.disabled);
const size = useStore((state) => state.size);
const thickness = useStore((state) => state.thickness);
const startAngle = useStore((state) => state.startAngle);
const endAngle = useStore((state) => state.endAngle);
const center = size + 20;
const trackRadius = size;
const sortedValues = [...values].sort((a, b) => a - b);
const rangeStart = values.length <= 1 ? min : (sortedValues[0] ?? min);
const rangeEnd =
values.length <= 1
? (sortedValues[0] ?? min)
: (sortedValues[sortedValues.length - 1] ?? max);
const rangeStartPercent = (rangeStart - min) / (max - min);
const rangeEndPercent = (rangeEnd - min) / (max - min);
const totalAngle = (endAngle - startAngle + 360) % 360 || 360;
const rangeStartAngle = startAngle + rangeStartPercent * totalAngle;
const rangeEndAngle = startAngle + rangeEndPercent * totalAngle;
const rangeStartRadians = (rangeStartAngle * Math.PI) / 180;
const rangeEndRadians = (rangeEndAngle * Math.PI) / 180;
const startX = center + trackRadius * Math.cos(rangeStartRadians);
const startY = center + trackRadius * Math.sin(rangeStartRadians);
const endX = center + trackRadius * Math.cos(rangeEndRadians);
const endY = center + trackRadius * Math.sin(rangeEndRadians);
const rangeAngle = (rangeEndAngle - rangeStartAngle + 360) % 360;
const largeArcFlag = rangeAngle > 180 ? 1 : 0;
if (rangeStart === rangeEnd) return null;
return (
<path
data-disabled={disabled ? "" : undefined}
data-slot="angle-slider-range"
d={`M ${startX} ${startY} A ${trackRadius} ${trackRadius} 0 ${largeArcFlag} 1 ${endX} ${endY}`}
fill="none"
stroke="currentColor"
strokeWidth={thickness}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
{...rangeProps}
className={cn("stroke-primary", className)}
/>
);
}
interface AngleSliderThumbProps extends DivProps {
index?: number;
}
function AngleSliderThumb(props: AngleSliderThumbProps) {
const { index: indexProp, className, asChild, ref, ...thumbProps } = props;
const context = useSliderContext(THUMB_NAME);
const store = useStoreContext(THUMB_NAME);
const values = useStore((state) => state.values);
const min = useStore((state) => state.min);
const max = useStore((state) => state.max);
const step = useStore((state) => state.step);
const disabled = useStore((state) => state.disabled);
const size = useStore((state) => state.size);
const thumbId = React.useId();
const [thumbElement, setThumbElement] = React.useState<ThumbElement | null>(
null,
);
const composedRef = useComposedRefs(ref, setThumbElement);
const isFormControl = thumbElement
? context.form || !!thumbElement.closest("form")
: true;
const index = indexProp ?? 0;
const value = values[index];
React.useEffect(() => {
if (thumbElement && value !== undefined) {
store.addThumb(index, {
id: thumbId,
element: thumbElement,
index,
value,
});
return () => {
store.removeThumb(index);
};
}
}, [thumbElement, thumbId, index, value, store]);
const thumbStyle = React.useMemo<React.CSSProperties>(() => {
if (value === undefined) return {};
const angle = store.getAngleFromValue(value);
const position = store.getPositionFromAngle(angle);
const center = size + 20;
return {
position: "absolute",
left: `${center + position.x}px`,
top: `${center + position.y}px`,
transform: "translate(-50%, -50%)",
};
}, [value, store, size]);
const onFocus = React.useCallback(
(event: React.FocusEvent<ThumbElement>) => {
props.onFocus?.(event);
if (event.defaultPrevented) return;
store.setState("valueIndexToChange", index);
},
[props.onFocus, store, index],
);
const ThumbPrimitive = asChild ? Slot : "div";
if (value === undefined) return null;
return (
<span style={thumbStyle}>
<ThumbPrimitive
id={thumbId}
role="slider"
aria-valuemin={min}
aria-valuenow={value}
aria-valuemax={max}
aria-orientation="vertical"
data-disabled={disabled ? "" : undefined}
data-slot="angle-slider-thumb"
tabIndex={disabled ? undefined : 0}
{...thumbProps}
ref={composedRef}
className={cn(
"block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:outline-hidden focus-visible:ring-4 disabled:pointer-events-none disabled:opacity-50",
className,
)}
onFocus={onFocus}
/>
{isFormControl && value !== undefined && (
<VisuallyHiddenInput
key={index}
control={thumbElement}
name={
context.name
? context.name + (values.length > 1 ? "[]" : "")
: undefined
}
form={context.form}
value={value.toString()}
type="number"
min={min}
max={max}
step={step}
disabled={disabled}
/>
)}
</span>
);
}
interface AngleSliderValueProps extends DivProps {
unit?: string;
formatValue?: (value: number | number[]) => string;
}
function AngleSliderValue(props: AngleSliderValueProps) {
const {
unit = "°",
formatValue,
className,
style,
asChild,
children,
...valueProps
} = props;
const values = useStore((state) => state.values);
const size = useStore((state) => state.size);
const disabled = useStore((state) => state.disabled);
const center = size + 20;
const displayValue = React.useMemo(() => {
if (formatValue) {
return formatValue(values.length === 1 ? (values[0] ?? 0) : values);
}
if (values.length === 1) {
return `${values[0] ?? 0}${unit}`;
}
const sortedValues = [...values].sort((a, b) => a - b);
return `${sortedValues[0]}${unit} - ${sortedValues[sortedValues.length - 1]}${unit}`;
}, [values, formatValue, unit]);
const valueStyle = React.useMemo<React.CSSProperties>(
() => ({
position: "absolute",
left: `${center}px`,
top: `${center}px`,
transform: "translate(-50%, -50%)",
}),
[center],
);
const ValuePrimitive = asChild ? Slot : "div";
return (
<ValuePrimitive
data-disabled={disabled ? "" : undefined}
data-slot="angle-slider-value"
{...valueProps}
className={cn(
"pointer-events-none flex select-none items-center justify-center font-medium text-foreground text-sm",
className,
)}
style={{
...valueStyle,
...style,
}}
>
{children ?? displayValue}
</ValuePrimitive>
);
}
export {
AngleSliderRoot as Root,
AngleSliderTrack as Track,
AngleSliderRange as Range,
AngleSliderThumb as Thumb,
AngleSliderValue as Value,
//
AngleSliderRoot as AngleSlider,
AngleSliderTrack,
AngleSliderRange,
AngleSliderThumb,
AngleSliderValue,
//
useStore as useAngleSlider,
//
type AngleSliderRootProps as AngleSliderProps,
};
Layout
Import the parts and compose them together.
import {
AngleSlider,
AngleSliderRange,
AngleSliderThumb,
AngleSliderTrack,
AngleSliderValue,
} from "@/components/ui/angle-slider";
<AngleSlider>
<AngleSliderTrack>
<AngleSliderRange />
</AngleSliderTrack>
<AngleSliderThumb />
<AngleSliderValue />
</AngleSlider>
Examples
Controlled State
A slider with controlled state management and custom actions.
"use client";
import { RotateCcwIcon, ShuffleIcon } from "lucide-react";
import { animate } from "motion/react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
AngleSlider,
AngleSliderRange,
AngleSliderThumb,
AngleSliderTrack,
AngleSliderValue,
} from "@/components/ui/angle-slider";
export function AngleSliderControlledDemo() {
const [value, setValue] = React.useState([180]);
const animationRef = React.useRef<ReturnType<typeof animate> | null>(null);
const animateToValue = React.useCallback(
(targetValue: number) => {
if (animationRef.current) {
animationRef.current.stop();
}
const currentValue = value[0] ?? 0;
let diff = targetValue - currentValue;
if (diff > 180) {
diff -= 360;
} else if (diff < -180) {
diff += 360;
}
animationRef.current = animate(0, diff, {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
onUpdate: (progress: number) => {
const animatedValue = currentValue + progress;
const normalizedValue = Math.round(
((animatedValue % 360) + 360) % 360,
);
setValue([normalizedValue]);
},
onComplete: () => {
setValue([targetValue]);
animationRef.current = null;
},
});
},
[value],
);
const onReset = React.useCallback(() => {
animateToValue(0);
}, [animateToValue]);
const onRandomize = React.useCallback(() => {
animateToValue(Math.floor(Math.random() * 360));
}, [animateToValue]);
React.useEffect(() => {
return () => {
if (animationRef.current) {
animationRef.current.stop();
}
};
}, []);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={onReset}>
<RotateCcwIcon />
Reset
</Button>
<Button size="sm" onClick={onRandomize}>
<ShuffleIcon />
Randomize
</Button>
</div>
<AngleSlider
value={value}
onValueChange={setValue}
max={360}
min={0}
step={1}
size={80}
>
<AngleSliderTrack>
<AngleSliderRange />
</AngleSliderTrack>
<AngleSliderThumb />
<AngleSliderValue />
</AngleSlider>
</div>
);
}
Range Selection
Use multiple thumbs to create angle ranges with minimum step constraints.
"use client";
import * as React from "react";
import {
AngleSlider,
AngleSliderRange,
AngleSliderThumb,
AngleSliderTrack,
AngleSliderValue,
} from "@/components/ui/angle-slider";
export function AngleSliderRangeDemo() {
const [value, setValue] = React.useState([90, 270]);
return (
<div className="flex flex-col items-center gap-4">
<AngleSlider
value={value}
onValueChange={setValue}
max={360}
min={0}
step={5}
size={80}
minStepsBetweenThumbs={2}
>
<AngleSliderTrack>
<AngleSliderRange />
</AngleSliderTrack>
<AngleSliderThumb index={0} />
<AngleSliderThumb index={1} />
<AngleSliderValue />
</AngleSlider>
<div className="flex flex-col gap-2 text-center text-sm">
<p>
<strong>Range:</strong> {value[0]}° - {value[1]}°
</p>
<p>
<strong>Arc Length:</strong>{" "}
{Math.abs((value[1] ?? 0) - (value[0] ?? 0))}°
</p>
</div>
</div>
);
}
Themes
Slider variants with different themes.
import { cn } from "@/lib/utils";
import {
AngleSlider,
AngleSliderRange,
AngleSliderThumb,
AngleSliderTrack,
AngleSliderValue,
} from "@/components/ui/angle-slider";
const themes = [
{
name: "Default",
value: 60,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-muted-foreground/20",
rangeClass: "stroke-primary",
thumbClass: "border-primary bg-background ring-primary/50",
textClass: "text-foreground",
},
{
name: "Success",
value: 120,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-green-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-green-900",
rangeClass: "stroke-green-500",
thumbClass:
"border-green-500 bg-green-50 ring-green-500/50 dark:bg-green-950",
textClass: "text-green-700 dark:text-green-300",
},
{
name: "Warning",
value: 180,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-yellow-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-yellow-900",
rangeClass: "stroke-yellow-500",
thumbClass:
"border-yellow-500 bg-yellow-50 ring-yellow-500/50 dark:bg-yellow-950",
textClass: "text-yellow-700 dark:text-yellow-300",
},
{
name: "Destructive",
value: 240,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-red-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-red-900",
rangeClass: "stroke-red-500",
thumbClass: "border-red-500 bg-red-50 ring-red-500/50 dark:bg-red-950",
textClass: "text-red-700 dark:text-red-300",
},
{
name: "Purple",
value: 300,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-purple-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-purple-900",
rangeClass: "stroke-purple-500",
thumbClass:
"border-purple-500 bg-purple-50 ring-purple-500/50 dark:bg-purple-950",
textClass: "text-purple-700 dark:text-purple-300",
},
{
name: "Orange",
value: 45,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-orange-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-orange-900",
rangeClass: "stroke-orange-500",
thumbClass:
"border-orange-500 bg-orange-50 ring-orange-500/50 dark:bg-orange-950",
textClass: "text-orange-700 dark:text-orange-300",
},
{
name: "Blue",
value: 90,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-blue-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-blue-900",
rangeClass: "stroke-blue-500",
thumbClass: "border-blue-500 bg-blue-50 ring-blue-500/50 dark:bg-blue-950",
textClass: "text-blue-700 dark:text-blue-300",
},
{
name: "Pink",
value: 270,
trackClass:
"[&>[data-slot='angle-slider-track-rail']]:stroke-pink-200 dark:[&>[data-slot='angle-slider-track-rail']]:stroke-pink-900",
rangeClass: "stroke-pink-500",
thumbClass: "border-pink-500 bg-pink-50 ring-pink-500/50 dark:bg-pink-950",
textClass: "text-pink-700 dark:text-pink-300",
},
];
export function AngleSliderThemesDemo() {
return (
<>
<div className="hidden grid-cols-4 gap-4 sm:grid">
{themes.map((theme) => (
<AngleSliderCard key={theme.name} theme={theme} />
))}
</div>
<div className="grid grid-cols-2 gap-4 sm:hidden">
{themes.slice(0, 4).map((theme) => (
<AngleSliderCard key={theme.name} theme={theme} />
))}
</div>
</>
);
}
interface AngleSliderCardProps {
theme: (typeof themes)[0];
}
function AngleSliderCard({ theme }: AngleSliderCardProps) {
return (
<div className="flex flex-col items-center gap-1">
<AngleSlider
defaultValue={[theme.value]}
max={360}
min={0}
step={1}
size={60}
>
<AngleSliderTrack className={theme.trackClass}>
<AngleSliderRange className={theme.rangeClass} />
</AngleSliderTrack>
<AngleSliderThumb className={theme.thumbClass} />
<AngleSliderValue
className={cn("font-semibold text-sm", theme.textClass)}
/>
</AngleSlider>
<div className="flex flex-col items-center gap-1 text-center">
<h4 className="font-medium text-sm">{theme.name}</h4>
<p className="text-muted-foreground text-xs">{theme.value}°</p>
</div>
</div>
);
}
With Form
Integrate the angle slider with form validation and submission.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
AngleSlider,
AngleSliderRange,
AngleSliderThumb,
AngleSliderTrack,
AngleSliderValue,
} from "@/components/ui/angle-slider";
const formSchema = z.object({
rotation: z.array(z.number()).length(1),
range: z.array(z.number()).length(2),
});
type FormSchema = z.infer<typeof formSchema>;
export function AngleSliderFormDemo() {
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
rotation: [45],
range: [90, 270],
},
});
const onSubmit = React.useCallback((data: FormSchema) => {
toast.success(
<pre className="w-full">{JSON.stringify(data, null, 2)}</pre>,
);
}, []);
const onReset = React.useCallback(() => {
form.reset();
}, [form]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="rotation"
render={({ field }) => (
<FormItem className="place-items-center rounded-lg border p-6">
<FormLabel>Rotation angle</FormLabel>
<FormControl>
<AngleSlider
value={field.value}
onValueChange={field.onChange}
max={360}
min={0}
step={1}
size={60}
name={field.name}
>
<AngleSliderTrack>
<AngleSliderRange />
</AngleSliderTrack>
<AngleSliderThumb />
<AngleSliderValue />
</AngleSlider>
</FormControl>
<FormDescription>
Set the rotation angle in degrees (0-360°)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="range"
render={({ field }) => (
<FormItem className="place-items-center rounded-lg border p-6">
<FormLabel>Angle range</FormLabel>
<FormControl>
<AngleSlider
value={field.value}
onValueChange={field.onChange}
max={360}
min={0}
step={5}
size={60}
minStepsBetweenThumbs={1}
name={field.name}
>
<AngleSliderTrack>
<AngleSliderRange />
</AngleSliderTrack>
<AngleSliderThumb index={0} />
<AngleSliderThumb index={1} />
<AngleSliderValue />
</AngleSlider>
</FormControl>
<FormDescription>
Define a range of angles for the operation
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" onClick={onReset}>
Reset
</Button>
<Button type="submit">Submit</Button>
</div>
</form>
</Form>
);
}
Theming
You can customize the appearance by targeting specific components:
Track Theming
Use [&>[data-slot='angle-slider-track-rail']]
to style the background track:
<AngleSliderTrack className="[&>[data-slot='angle-slider-track-rail']]:stroke-green-100" />
Range Theming
<AngleSliderRange className="stroke-green-500" />
Thumb Theming
<AngleSliderThumb className="border-green-500 bg-green-50 ring-green-500/50" />
Value Theming
<AngleSliderValue className="text-green-600 dark:text-green-400" />
API Reference
Root
The main container component for the angle slider.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when the angle slider is disabled. |
Track
The circular track that represents the full range of possible values.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when the angle slider is disabled. |
[data-slot='angle-slider-track-rail'] | Present on the rail of the track. |
Range
The portion of the track that represents the selected range.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when the angle slider is disabled. |
Thumb
The draggable handle for selecting values.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when the angle slider is disabled. |
Value
Displays the current value(s) with customizable formatting.
Prop
Type
Data Attribute | Value |
---|---|
[data-disabled] | Present when the angle slider is disabled. |
Accessibility
The angle slider component includes comprehensive accessibility features:
Keyboard Interactions
Key | Description |
---|---|
ArrowUpArrowRight | Increase the value by one step. |
ArrowDownArrowLeft | Decrease the value by one step. |
PageUp | Increase the value by ten steps. |
PageDown | Decrease the value by ten steps. |
Shift + Arrow Keys | Increase/decrease the value by ten steps. |
Home | Set the value to the minimum. |
End | Set the value to the maximum. |
Features
- Optimized for touch interactions on mobile devices
- Smooth dragging experience with proper pointer handling
- Full right-to-left language support
- Comprehensive keyboard navigation and screen reader support
- Angle ranges with minimum step constraints with multiple thumbs
- Controlled and uncontrolled state management