Dice UI
Components

Angle Slider

An interactive circular slider for selecting angles with support for single values and ranges.

API
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 AttributeValue
[data-disabled]Present when the angle slider is disabled.

Track

The circular track that represents the full range of possible values.

Prop

Type

Data AttributeValue
[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 AttributeValue
[data-disabled]Present when the angle slider is disabled.

Thumb

The draggable handle for selecting values.

Prop

Type

Data AttributeValue
[data-disabled]Present when the angle slider is disabled.

Value

Displays the current value(s) with customizable formatting.

Prop

Type

Data AttributeValue
[data-disabled]Present when the angle slider is disabled.

Accessibility

The angle slider component includes comprehensive accessibility features:

Keyboard Interactions

KeyDescription
ArrowUpArrowRightIncrease the value by one step.
ArrowDownArrowLeftDecrease the value by one step.
PageUpIncrease the value by ten steps.
PageDownDecrease the value by ten steps.
Shift + Arrow KeysIncrease/decrease the value by ten steps.
HomeSet the value to the minimum.
EndSet 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