cubby-ui
uicircular-slidersource: ok · hit
Circular Slider
A circular-slider component.
npx shadcn add "https://www.cubby-ui.dev/r/circular-slider.json"free
Interaction
carousel
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightclass-variance-authority
Registry dependencies
@cubby-ui/use-controllable-state
Files2 · source cached 10/12/2026, 1:35:57 AM
registry/default/circular-slider/circular-slider.tsx
"use client";
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@/lib/utils";
import { useControllableState } from "@/registry/default/hooks/use-controllable-state";
import {
angleToProgress,
angleToUnclampedProgress,
angularDistance,
arcStroke,
clamp,
continueDrag,
createDial,
easeOutExpoTimeAt,
getActiveSpan,
isInSpan,
mod,
pickOverlappedThumb,
pointToAngle,
ringHeight,
trimOriginCap,
progressToAngle,
progressToValue,
round,
snapValue,
tickProgresses,
valueToProgress,
widenSpan,
wrapDelta,
placeRangeThumb,
type CircularSliderDirection,
type Dial,
type Span,
type ThumbCollision,
} from "./lib/geometry";
/** Duration of a value change that animates (click, Home/End/Page, external). */
const SETTLE_MS = 350;
/** Gap between a knob face and the tick band around it, in px. */
const KNOB_GAP = 6;
/** Pixels of vertical drag that cover the whole sweep in `dragMode="vertical"`. */
const VERTICAL_DRAG_PX = 200;
export type CircularSliderChangeReason = "drag" | "click" | "keyboard";
export interface CircularSliderChangeDetails {
reason: CircularSliderChangeReason;
/** Index of the thumb that moved; `-1` when a range span moved as a whole. */
activeThumbIndex: number;
}
type SliderValue = number | readonly number[];
interface CircularSliderContextValue {
dial: Dial;
values: readonly number[];
/** Values before the latest change, for timing the tick sweep. */
previousValues: readonly number[];
origin: number;
size: number;
thickness: number;
/** How far the band sits in from the edge, making room for a pill thumb. */
inset: number;
thumbShape: "bead" | "pill";
variant: "ring" | "knob";
pressed: boolean;
dragging: boolean;
activeIndex: number;
focusVisibleIndex: number | null;
/** The latest change lands without a transition. */
instant: boolean;
formatValue?: (value: number, index: number) => string;
}
const CircularSliderContext =
React.createContext<CircularSliderContextValue | null>(null);
function useCircularSliderContext(): CircularSliderContextValue {
const context = React.useContext(CircularSliderContext);
if (!context) {
throw new Error(
"CircularSlider parts must be rendered inside <CircularSliderRoot>.",
);
}
return context;
}
function toArray(value: SliderValue): readonly number[] {
return typeof value === "number" ? [value] : value;
}
function sameValues(a: readonly number[], b: readonly number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
/** Thumb nearest a screen angle; ties go to the last one moved. */
function nearestThumb(
dial: Dial,
values: readonly number[],
angle: number,
preferred: number,
): number {
let best = preferred;
let bestDistance = Infinity;
values.forEach((value, index) => {
const distance = angularDistance(
progressToAngle(dial, valueToProgress(dial, value)),
angle,
);
if (
distance < bestDistance - 0.001 ||
(Math.abs(distance - bestDistance) <= 0.001 && index === preferred)
) {
best = index;
bestDistance = distance;
}
});
return best;
}
/** Radial slop, in px, a press may land outside the band and still count. */
const BAND_SLOP = 8;
/** Pointer travel, in px of arc, that settles which overlapped thumb is held. */
const PICK_TRAVEL = 4;
/** Share of a knob's radius around its center where drags are ignored. */
const KNOB_DEAD_ZONE = 0.15;
interface PointerGeometry {
angle: number;
distance: number;
/** Rendered px per authored px, for a root resized with CSS. */
scale: number;
/** Radius of the band's center line, in screen px. */
bandRadius: number;
/** Degrees of arc a thumb answers to either side of its center. */
grabDegrees: number;
}
/**
* Where a pointer sits relative to the dial. Press and hover both read this,
* so what a hover marks as grabbable is exactly what a press grabs.
*/
function measurePointer(
event: React.PointerEvent<HTMLElement>,
size: number,
thickness: number,
inset: number,
): PointerGeometry {
const rect = event.currentTarget.getBoundingClientRect();
const scale = rect.width / size;
const cx = rect.left + rect.width / 2;
// The dial is the square at the top of the root (a half dial is shorter).
const cy = rect.top + rect.width / 2;
const bandRadiusPx = rect.width / 2 - (inset + thickness / 2) * scale || 1;
return {
angle: pointToAngle(event.clientX, event.clientY, cx, cy),
distance: Math.hypot(event.clientX - cx, event.clientY - cy),
scale,
bandRadius: bandRadiusPx,
grabDegrees:
((thickness * scale * 0.75 + 8 * scale) / bandRadiusPx) * (180 / Math.PI),
};
}
function slopDegrees(px: number, radius: number): number {
return ((px / radius) * 180) / Math.PI;
}
interface DragBase {
pointerId: number;
/** A value changed during the gesture, so release commits. */
changed: boolean;
}
/** Holding one thumb; it follows the pointer at the offset it was grabbed. */
interface ThumbDrag extends DragBase {
mode: "thumb";
index: number;
/** Raw pointer progress at the last sample. */
lastPointer: number;
grabOffset: number;
}
/** Pressed where a range's thumbs overlap; the first clear move picks one. */
interface PendingDrag extends DragBase {
mode: "pending";
downPointer: number;
bandRadius: number;
}
/** Holding a range's filled span; both ends shift together. */
interface SpanDrag extends DragBase {
mode: "span";
lastPointer: number;
/** Thumb progresses when the drag began; the shift is measured from these. */
start: number[];
shift: number;
}
/** Turning a knob, or dragging up and down in `dragMode="vertical"`. */
interface TurnDrag extends DragBase {
mode: "knob" | "vertical";
index: number;
/** Progress the gesture has accumulated, unsnapped. */
progress: number;
lastAngle: number;
lastY: number;
}
type DragState = ThumbDrag | PendingDrag | SpanDrag | TurnDrag;
export interface CircularSliderRootProps<
Value extends SliderValue = number,
> extends Omit<
useRender.ComponentProps<"div">,
"defaultValue" | "onChange" | "dir"
> {
/** Controlled value. Pass an array of two numbers for a range. */
value?: Value;
/** Uncontrolled initial value. Pass an array of two numbers for a range. */
defaultValue?: Value;
onValueChange?: (value: Value, details: CircularSliderChangeDetails) => void;
/** Fires when an interaction ends: pointer release or a keyboard step. */
onValueCommitted?: (value: Value) => void;
min?: number;
max?: number;
step?: number;
/** Step for Page Up / Page Down and Shift + arrow keys. */
largeStep?: number;
/** Arc length of the dial in degrees, from 1 to 360. */
sweep?: number;
/**
* Screen angle of `min`, in degrees clockwise from 12 o'clock. Defaults to
* centering the sweep on the top so a partial dial's gap sits at the bottom.
*/
startAngle?: number;
direction?: CircularSliderDirection;
/**
* On a full circle, let values loop past `max` back to `min` (angles,
* hours). Max and min then share a position.
*/
wrap?: boolean;
/**
* Where the indicator grows from. Defaults to `min`; set it to a midpoint
* for a bipolar control, or to a live reading to show the distance to a
* target. Ignored by ranges.
*/
origin?: number;
/** Diameter in px. */
size?: number;
/** Width in px of the outer band: the ring's stroke or the knob's ticks. */
thickness?: number;
/**
* `ring` is a stroke you grab anywhere along. `knob` is a raised face you
* turn: grabbing it never jumps the value.
*/
variant?: "ring" | "knob";
/**
* `angular` follows the pointer around the center. `vertical` turns the
* value with up and down drags, like an audio knob.
*/
dragMode?: "angular" | "vertical";
disabled?: boolean;
name?: string;
form?: string;
/**
* On a ring, `bead` sits inside the band and `pill` is a slim handle that
* overhangs it (the band moves in, so the dial keeps its size). On a knob
* it is the face's marker: a dot or a line. Defaults to `bead` on a ring
* and `pill` on a knob.
*/
thumbShape?: "bead" | "pill";
/** Least distance, in steps, between a range's thumbs. */
minStepsBetweenValues?: number;
/**
* What a range thumb does when it runs into the other: `push` carries the
* other along, `none` stops it there.
*/
thumbCollisionBehavior?: ThumbCollision;
/**
* Formats each value for display, e.g. `(v) => \`${v}°\``. Read by
* `CircularSliderValue`, and spoken by screen readers unless
* `getAriaValueText` says otherwise.
*/
formatValue?: (value: number, index: number) => string;
/**
* Accessible name for each thumb of a range. Defaults to "Start" and
* "End", with `aria-label` naming the range as a whole.
*/
getAriaLabel?: (index: number) => string;
/** Spoken value for a thumb. Defaults to `formatValue`. */
getAriaValueText?: (value: number, index: number) => string;
}
function CircularSliderRoot<Value extends SliderValue = number>({
className,
render,
style,
children,
value: valueProp,
defaultValue,
onValueChange,
onValueCommitted,
min = 0,
max = 100,
step = 1,
largeStep = 10,
sweep = 270,
startAngle,
direction = "clockwise",
wrap = false,
origin: originProp,
size = 144,
thickness: thicknessProp = 12,
variant = "ring",
dragMode = "angular",
disabled = false,
name,
form,
id,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
"aria-describedby": ariaDescribedby,
formatValue,
getAriaLabel,
getAriaValueText,
thumbShape: thumbShapeProp,
minStepsBetweenValues = 0,
thumbCollisionBehavior = "push",
...props
}: CircularSliderRootProps<Value>) {
// Past about 30% of the diameter a band stops reading as a ring and the
// middle closes into a disc, so cap it there.
const thickness = Math.min(thicknessProp, size * 0.3);
const thumbShape = thumbShapeProp ?? (variant === "knob" ? "pill" : "bead");
const dial = React.useMemo(
() => createDial({ min, max, step, sweep, startAngle, direction, wrap }),
[min, max, step, sweep, startAngle, direction, wrap],
);
const [rawValue, setRawValue] = useControllableState<SliderValue>({
value: valueProp,
defaultValue: defaultValue ?? min,
});
// Clamped (or folded, when wrapping) and snapped once, so the text, arc,
// hidden input and screen reader all agree, as with a native input.
const values = toArray(rawValue).map((v) => snapValue(dial, v));
const isRange = typeof rawValue !== "number";
const origin = clamp(originProp ?? min, min, max);
const inset =
variant === "ring" && thumbShape === "pill" ? pillOverhang(thickness) : 0;
const height =
variant === "ring" ? ringHeight(dial, size, thickness, inset) : size;
const thumbGap = Math.max(0, minStepsBetweenValues) * step;
const spokenValue = getAriaValueText ?? formatValue;
// Latest values for event handlers, which outlive the render they came from.
const valuesRef = React.useRef(values);
React.useEffect(() => {
valuesRef.current = values;
});
// Held arrow keys repeat fast, so those steps land instantly instead of
// trailing the value. Keyed by the values they produced and cleared by any
// other change, so later changes still animate.
const [instantKey, setInstantKey] = React.useState<string | null>(null);
// Values before the latest change, for the tick sweep. Stored during render
// ("previous props" pattern) so a change's first frame knows its origin.
const valuesKey = values.join(",");
const [history, setHistory] = React.useState({
key: valuesKey,
previous: values,
current: values,
});
if (history.key !== valuesKey) {
setHistory({ key: valuesKey, previous: history.current, current: values });
if (instantKey !== valuesKey) setInstantKey(null);
}
// A small step across the seam of a wrapping dial (359° to 0°) would spin
// the arc and thumb nearly a full turn the wrong way, so it lands without
// a transition. A large jump (a click across the dial) still sweeps.
const crossedSeam =
dial.wrap &&
history.previous.length === values.length &&
history.previous.some((previous, i) => {
const from = valueToProgress(dial, previous);
const to = valueToProgress(dial, values[i]);
return (
Math.abs(to - from) > 180 && Math.abs(wrapDelta(from, to, 360)) < 90
);
});
const instant = crossedSeam || instantKey === valuesKey;
const [pressed, setPressed] = React.useState(false);
// Over a thumb, or a range's filled span: something a press would hold
// rather than jump.
const [grabHover, setGrabHover] = React.useState(false);
const [dragging, setDragging] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(0);
const [focusVisibleIndex, setFocusVisibleIndex] = React.useState<
number | null
>(null);
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
const dragRef = React.useRef<DragState | null>(null);
// `:focus-visible` always matches a focused range input, so pointer-driven
// focus is told apart by hand: set before the pointer focuses the input,
// consumed by its focus handler.
const focusFromPointer = React.useRef(false);
// Focus a thumb after a pointer press so arrow keys work. The flag is only
// set when a focus event will fire (an already-focused input fires none).
const focusThumbInput = React.useCallback((index: number) => {
const input = inputRefs.current[index];
if (!input || document.activeElement === input) return;
focusFromPointer.current = true;
input.focus({ preventScroll: true });
}, []);
const emit = React.useCallback(
(next: readonly number[]): Value =>
(isRange ? next : next[0]) as unknown as Value,
[isRange],
);
const commitValues = React.useCallback(
(next: number[], details: CircularSliderChangeDetails): boolean => {
if (sameValues(next, valuesRef.current)) return false;
valuesRef.current = next;
setRawValue(isRange ? next : next[0]);
onValueChange?.(emit(next), details);
return true;
},
[isRange, setRawValue, onValueChange, emit],
);
// Pointer, keyboard and assistive tech all move thumbs through here.
const moveThumb = React.useCallback(
(
index: number,
candidate: number,
reason: CircularSliderChangeReason,
): number[] => {
const next = placeRangeThumb(
dial,
valuesRef.current,
index,
snapValue(dial, candidate),
thumbGap,
thumbCollisionBehavior,
);
commitValues(next, { reason, activeThumbIndex: index });
return next;
},
[dial, thumbGap, thumbCollisionBehavior, commitValues],
);
const handlePointerDown = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
const root = event.currentTarget;
const g = measurePointer(event, size, thickness, inset);
const outer = (size / 2) * g.scale;
const slop = BAND_SLOP * g.scale;
if (variant === "ring") {
// A ring only answers along its band, so whatever sits in the middle
// stays clickable, and not in the gap of a partial sweep.
const bandInner = outer - (inset + thickness) * g.scale;
if (g.distance < bandInner - slop || g.distance > outer + slop) return;
if (!dial.wrap && dial.sweep < 360) {
const p = angleToUnclampedProgress(dial, g.angle);
const edge = slopDegrees(
(thickness * g.scale) / 2 + slop,
g.bandRadius,
);
if (p < -edge || p > dial.sweep + edge) return;
}
} else if (g.distance > outer + slop) {
return;
}
const current = valuesRef.current;
const pointer = angleToProgress(dial, g.angle);
const index =
current.length > 1
? nearestThumb(dial, current, g.angle, activeIndex)
: 0;
const thumbProgress = valueToProgress(dial, current[index]);
const base = { pointerId: event.pointerId, changed: false };
let drag: DragState;
if (dragMode === "vertical" || variant === "knob") {
drag = {
...base,
mode: dragMode === "vertical" ? "vertical" : "knob",
index,
progress: thumbProgress,
lastAngle: g.angle,
lastY: event.clientY,
};
} else {
const near = (v: number) =>
angularDistance(
progressToAngle(dial, valueToProgress(dial, v)),
g.angle,
) <= g.grabDegrees;
if (current.filter(near).length > 1) {
drag = {
...base,
mode: "pending",
downPointer: pointer,
bandRadius: g.bandRadius,
};
} else if (near(current[index])) {
// Grabbing the thumb itself holds on without moving it.
drag = {
...base,
mode: "thumb",
index,
lastPointer: pointer,
grabOffset: dial.wrap
? wrapDelta(pointer, thumbProgress, 360)
: thumbProgress - pointer,
};
} else if (
current.length > 1 &&
isInSpan(dial, pointer, getActiveSpan(dial, current, origin))
) {
drag = {
...base,
mode: "span",
lastPointer: pointer,
start: current.map((v) => valueToProgress(dial, v)),
shift: 0,
};
} else {
// Elsewhere on the band: jump the nearest thumb there, then hold it.
const next = moveThumb(
index,
progressToValue(dial, pointer),
"click",
);
drag = {
...base,
changed: !sameValues(next, current),
mode: "thumb",
index,
lastPointer: pointer,
grabOffset: 0,
};
}
}
dragRef.current = drag;
// Synthetic events (tests, automation) carry no live pointer to capture.
try {
root.setPointerCapture(event.pointerId);
} catch {}
setPressed(true);
const held = "index" in drag ? drag.index : 0;
if ("index" in drag) setActiveIndex(drag.index);
// preventDefault stops the compatibility mousedown from moving focus
// straight back off the input.
event.preventDefault();
focusThumbInput(held);
},
[
disabled,
size,
thickness,
inset,
variant,
dragMode,
dial,
origin,
activeIndex,
moveThumb,
focusThumbInput,
],
);
const handlePointerMove = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) {
if (variant === "ring" && event.pointerType === "mouse") {
const g = measurePointer(event, size, thickness, inset);
const current = valuesRef.current;
const overThumb = current.some(
(v) =>
angularDistance(
progressToAngle(dial, valueToProgress(dial, v)),
g.angle,
) <= g.grabDegrees,
);
const overSpan =
current.length > 1 &&
isInSpan(
dial,
angleToProgress(dial, g.angle),
getActiveSpan(dial, current, origin),
);
const over = overThumb || overSpan;
if (over !== grabHover) setGrabHover(over);
}
return;
}
if (drag.pointerId !== event.pointerId) return;
if (!dragging) setDragging(true);
const g = measurePointer(event, size, thickness, inset);
const period = dial.sweep;
const settle = (progress: number): number =>
dial.wrap ? mod(progress, period) : clamp(progress, 0, period);
const mark = (changed: boolean) => {
if (changed) drag.changed = true;
};
if (drag.mode === "knob" || drag.mode === "vertical") {
if (drag.mode === "knob") {
// Near the center a pixel of travel swings the angle wildly, so
// ignore it there, without moving the reference angle.
const radius = (size / 2) * g.scale;
if (g.distance < radius * KNOB_DEAD_ZONE) return;
}
const delta =
drag.mode === "knob"
? wrapDelta(drag.lastAngle, g.angle, 360) * dial.dir
: ((drag.lastY - event.clientY) / VERTICAL_DRAG_PX) * period;
drag.lastAngle = g.angle;
drag.lastY = event.clientY;
drag.progress = settle(drag.progress + delta);
const before = valuesRef.current;
const next = moveThumb(
drag.index,
progressToValue(dial, drag.progress),
"drag",
);
mark(!sameValues(next, before));
// Blocked by the other thumb or a gap: restart from where the thumb
// actually is, so turning back responds at once.
const wanted = snapValue(dial, progressToValue(dial, drag.progress));
if (next[drag.index] !== wanted) {
drag.progress = valueToProgress(dial, next[drag.index]);
}
return;
}
if (drag.mode === "span") {
const pointer = continueDrag(
dial,
drag.lastPointer,
angleToProgress(dial, g.angle),
);
drag.shift += dial.wrap
? wrapDelta(drag.lastPointer, pointer, 360)
: pointer - drag.lastPointer;
drag.lastPointer = pointer;
// Shift both ends by the pointer's total travel since the drag began
// (re-snapping per sample would drift), stopping at the dial's ends.
if (!dial.wrap) {
drag.shift = clamp(
drag.shift,
-Math.min(...drag.start),
period - Math.max(...drag.start),
);
}
const next = drag.start.map((p) =>
snapValue(dial, progressToValue(dial, settle(p + drag.shift))),
);
mark(commitValues(next, { reason: "drag", activeThumbIndex: -1 }));
return;
}
// The pointer may run past an end into the gap; add the grab offset
// before clamping so a thumb held off-center still reaches min and max.
const raw = dial.wrap
? angleToProgress(dial, g.angle)
: angleToUnclampedProgress(dial, g.angle);
let held: ThumbDrag;
if (drag.mode === "pending") {
const moved = dial.wrap
? wrapDelta(drag.downPointer, raw, 360)
: raw - drag.downPointer;
// Wait for a clear direction, measured on screen so finger jitter
// does not decide it.
if ((Math.abs(moved) * Math.PI * drag.bandRadius) / 180 < PICK_TRAVEL) {
return;
}
const index = pickOverlappedThumb(dial, valuesRef.current, moved > 0);
const thumbProgress = valueToProgress(dial, valuesRef.current[index]);
held = {
pointerId: drag.pointerId,
changed: drag.changed,
mode: "thumb",
index,
lastPointer: drag.downPointer,
grabOffset: dial.wrap
? wrapDelta(drag.downPointer, thumbProgress, 360)
: thumbProgress - drag.downPointer,
};
dragRef.current = held;
setActiveIndex(index);
focusThumbInput(index);
} else if (drag.mode === "thumb") {
held = drag;
} else {
return;
}
const pointer = continueDrag(dial, held.lastPointer, raw);
held.lastPointer = pointer;
// Pinned at an end after crossing the seam or the gap's far side: sit
// exactly on that end rather than an offset away from it.
const target =
pointer !== raw ? pointer : settle(pointer + held.grabOffset);
const before = valuesRef.current;
const next = moveThumb(held.index, progressToValue(dial, target), "drag");
if (!sameValues(next, before)) held.changed = true;
},
[
dragging,
dial,
variant,
size,
thickness,
inset,
origin,
grabHover,
moveThumb,
commitValues,
focusThumbInput,
],
);
const handlePointerEnd = React.useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
dragRef.current = null;
setPressed(false);
setDragging(false);
// Guarded: on the cancel and lost-capture paths the capture may already
// be gone, and releasing an inactive pointer throws.
const root = event.currentTarget;
if (root.hasPointerCapture(event.pointerId)) {
root.releasePointerCapture(event.pointerId);
}
if (drag.changed) onValueCommitted?.(emit(valuesRef.current));
},
[onValueCommitted, emit],
);
const handleKeyDown = React.useCallback(
(index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
// Keyboard use means keyboard modality, even if focus arrived by pointer.
setFocusVisibleIndex(index);
const current = valuesRef.current[index];
const big = event.shiftKey ? largeStep : step;
let candidate: number | null = null;
switch (event.key) {
case "ArrowUp":
case "ArrowRight":
candidate = current + big;
break;
case "ArrowDown":
case "ArrowLeft":
candidate = current - big;
break;
case "PageUp":
candidate = current + largeStep;
break;
case "PageDown":
candidate = current - largeStep;
break;
case "Home":
candidate = min;
break;
case "End":
// On a wrapping dial max is min's position, so stop one step short.
candidate = dial.wrap ? max - step : max;
break;
default:
return;
}
event.preventDefault();
const before = valuesRef.current;
const next = moveThumb(index, candidate, "keyboard");
setInstantKey(event.key.startsWith("Arrow") ? next.join(",") : null);
setActiveIndex(index);
if (!sameValues(next, before)) onValueCommitted?.(emit(next));
},
[
disabled,
largeStep,
step,
min,
max,
dial,
moveThumb,
onValueCommitted,
emit,
],
);
// Assistive tech that adjusts the native input directly (VoiceOver swipes)
// lands here rather than in keydown.
const handleInputChange = React.useCallback(
(index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const before = valuesRef.current;
const next = moveThumb(index, Number(event.target.value), "keyboard");
setInstantKey(next.join(","));
if (!sameValues(next, before)) onValueCommitted?.(emit(next));
},
[moveThumb, onValueCommitted, emit],
);
const contextValue = React.useMemo<CircularSliderContextValue>(
() => ({
dial,
values,
previousValues: history.previous,
origin,
size,
thickness,
inset,
thumbShape,
variant,
pressed,
dragging,
activeIndex,
focusVisibleIndex,
instant,
formatValue,
}),
// `values` is a fresh array for scalar values; key it by content.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
dial,
valuesKey,
history.previous,
origin,
size,
thickness,
inset,
thumbShape,
variant,
pressed,
dragging,
activeIndex,
focusVisibleIndex,
instant,
formatValue,
],
);
const vertical = dragMode === "vertical";
const defaultProps = {
"data-slot": "circular-slider",
"data-variant": variant,
"data-drag-mode": dragMode,
"data-pressed": pressed || undefined,
"data-dragging": dragging || undefined,
"data-instant": instant || undefined,
"data-disabled": disabled || undefined,
"data-range": isRange || undefined,
"data-grab-hover": grabHover || undefined,
// A range is two sliders; the group carries the shared name and each
// thumb its own ("Start", "End").
...(isRange && {
role: "group",
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
}),
className: cn(
// A knob is all face, so it owns every touch on it. A ring only owns
// touches on its band (the band layer below); swipes that start in the
// middle still scroll the page.
"group/circular-slider relative shrink-0 select-none",
variant === "knob" && "touch-none",
"data-disabled:pointer-events-none data-disabled:opacity-60",
// A partial ring is shorter than its square. Clip just below the bottom
// so the empty rest never catches clicks (clip-path clips hit-testing
// too); other sides keep room for focus rings and the pill.
height < size && "[clip-path:inset(-24px_-24px_-8px_-24px)]",
variant === "knob" &&
!disabled &&
(vertical
? "cursor-ns-resize"
: "cursor-grab data-pressed:cursor-grabbing"),
className,
),
style: {
width: size,
aspectRatio: `${size} / ${height}`,
"--circular-slider-size": `${size}px`,
// The open middle of the dial, for content centered in it.
"--circular-slider-hole": `${Math.max(0, size - 2 * (variant === "knob" ? thickness + KNOB_GAP : inset + thickness))}px`,
...style,
} as React.CSSProperties,
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerEnd,
// An OS-cancelled gesture (touch interruption, context menu) never sends
// pointerup; these still end the drag and commit.
onPointerCancel: handlePointerEnd,
onLostPointerCapture: handlePointerEnd,
onPointerLeave: () => setGrabHover(false),
children: (
// The dial's square, anchored to the top so a half dial can be shorter.
<div
data-slot="circular-slider-dial"
className="absolute inset-x-0 top-0 isolate grid aspect-square place-items-center"
>
{variant === "ring" && (
<div
data-slot="circular-slider-band"
aria-hidden
className={cn(
"absolute inset-0 z-1 touch-none",
// A vertical drag never jumps, so no click-to-jump pointer.
vertical
? "cursor-ns-resize"
: "cursor-pointer group-data-grab-hover/circular-slider:cursor-grab group-data-pressed/circular-slider:cursor-grabbing",
)}
style={{ clipPath: bandClipPath(dial, size, thickness, inset) }}
/>
)}
{values.map((thumbValue, index) => (
<input
// Thumbs are positional and never reorder.
key={index}
ref={(node) => {
inputRefs.current[index] = node;
}}
type="range"
min={min}
max={max}
step={step}
value={thumbValue}
disabled={disabled}
name={name}
form={form}
id={index === 0 ? id : undefined}
aria-label={
getAriaLabel?.(index) ??
(isRange ? (index === 0 ? "Start" : "End") : ariaLabel)
}
aria-labelledby={isRange ? undefined : ariaLabelledby}
aria-describedby={ariaDescribedby}
aria-valuetext={spokenValue?.(thumbValue, index)}
className="sr-only"
onChange={(event) => handleInputChange(index, event)}
onKeyDown={(event) => handleKeyDown(index, event)}
onFocus={() => {
setActiveIndex(index);
setFocusVisibleIndex(focusFromPointer.current ? null : index);
focusFromPointer.current = false;
}}
onBlur={() => setFocusVisibleIndex(null)}
/>
))}
{children}
</div>
),
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return (
<CircularSliderContext.Provider value={contextValue}>
{element}
</CircularSliderContext.Provider>
);
}
/**
* Clip for the band's hit layer: the swept arc plus slop. clip-path also
* clips hit-testing, so the middle and a partial sweep's gap stay free.
*/
function bandClipPath(
dial: Dial,
size: number,
thickness: number,
inset: number,
): string {
const c = size / 2;
const outer = c + BAND_SLOP;
const inner = Math.max(0, c - inset - thickness - BAND_SLOP);
const circle = (r: number): string =>
`M ${c - r} ${c} a ${r} ${r} 0 1 0 ${2 * r} 0 a ${r} ${r} 0 1 0 ${-2 * r} 0 Z`;
if (dial.sweep >= 360) {
return `path(evenodd, "${circle(outer)} ${circle(inner)}")`;
}
const radius = bandRadius(size, thickness, inset);
const edge = slopDegrees(thickness / 2 + BAND_SLOP, radius);
const first = (dial.dir === 1 ? dial.start : dial.start - dial.sweep) - edge;
const span = Math.min(359.9, dial.sweep + edge * 2);
const last = first + span;
const at = (r: number, angle: number): string => {
const rad = (angle * Math.PI) / 180;
return `${round(c + r * Math.sin(rad))} ${round(c - r * Math.cos(rad))}`;
};
const large = span > 180 ? 1 : 0;
return `path("M ${at(outer, first)} A ${outer} ${outer} 0 ${large} 1 ${at(outer, last)} L ${at(inner, last)} A ${inner} ${inner} 0 ${large} 0 ${at(inner, first)} Z")`;
}
/** Classes that animate a value change and stand still while dragging. */
const SETTLE_TRANSITION =
"duration-350 ease-(--ease-out-expo) group-data-dragging/circular-slider:transition-none group-data-instant/circular-slider:transition-none motion-reduce:transition-none";
function overlayProps(slot: string, size: number, className?: string) {
return {
"data-slot": slot,
"aria-hidden": true,
viewBox: `0 0 ${size} ${size}`,
className: cn(
"pointer-events-none absolute inset-0 size-full overflow-visible",
className,
),
};
}
function bandRadius(size: number, thickness: number, inset = 0): number {
return size / 2 - inset - thickness / 2;
}
/**
* How far a pill thumb reaches past each side of the band. The band moves in
* by the same amount so the pill stays inside the dial's box.
*/
function pillOverhang(thickness: number): number {
// At least long enough (12px) to see and aim at, however thin the band.
return Math.max(
3,
Math.round(thickness * 0.3),
Math.ceil((12 - thickness) / 2),
);
}
function pillWidth(thickness: number): number {
return clamp(Math.round(thickness / 3), 4, 6);
}
/**
* Progress the track is drawn over. With a pill, the ends pull in so the
* round cap ends flush with the pill instead of a sliver showing past it,
* like Base UI's `thumbAlignment="edge"`.
*/
function trackSpan(
dial: Dial,
radius: number,
thickness: number,
thumbShape: "bead" | "pill",
): Span {
if (thumbShape !== "pill" || dial.sweep >= 360) {
return { from: 0, length: dial.sweep };
}
const px = thickness / 2 - pillWidth(thickness) / 2;
const degrees = ((px / radius) * 180) / Math.PI;
return { from: degrees, length: Math.max(0, dial.sweep - degrees * 2) };
}
const svgRotateStyle = {
transformBox: "view-box",
transformOrigin: "50% 50%",
} as const satisfies React.CSSProperties;
export type CircularSliderTrackProps = useRender.ComponentProps<"svg">;
/** The full sweep of the band, drawn behind the indicator. */
function CircularSliderTrack({
className,
render,
...props
}: CircularSliderTrackProps) {
const { dial, size, thickness, inset, thumbShape } =
useCircularSliderContext();
const radius = bandRadius(size, thickness, inset);
const stroke = arcStroke(
dial,
radius,
trackSpan(dial, radius, thickness, thumbShape),
);
return useRender({
defaultTagName: "svg",
render,
props: mergeProps<"svg">(
overlayProps("circular-slider-track", size, className),
props,
{
children: (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={thickness}
strokeLinecap="round"
className="stroke-foreground/9 dark:stroke-foreground/10"
style={{
...svgRotateStyle,
strokeDasharray: stroke.strokeDasharray,
rotate: stroke.rotate,
}}
/>
),
},
),
});
}
export type CircularSliderIndicatorProps = useRender.ComponentProps<"svg">;
/**
* The filled arc: from `origin` to the value, or between a range's thumbs.
* Sweeps on click, Home/End/Page and outside changes; arrow steps and drags
* land at once.
*/
function CircularSliderIndicator({
className,
render,
...props
}: CircularSliderIndicatorProps) {
const { dial, values, origin, size, thickness, inset, thumbShape } =
useCircularSliderContext();
const radius = bandRadius(size, thickness, inset);
const capDegrees = ((thickness / 2 / radius) * 180) / Math.PI;
const span = getActiveSpan(dial, values, origin);
// From a reference reading (not min), start the arc at the reading itself.
const fromReference = values.length === 1 && origin !== dial.min;
const drawn = fromReference
? trimOriginCap(
span,
valueToProgress(dial, values[0]),
valueToProgress(dial, origin),
capDegrees,
)
: span;
const hidden = fromReference && drawn.length === 0;
// useId output can hold characters that break a url(#...) reference.
const maskId = `circular-slider-mask-${React.useId().replace(/[^\w-]/g, "")}`;
const isPill = thumbShape === "pill";
// A round end would bulge past a slim pill, so with a pill the ends are cut
// flat and the arc is masked to the track, letting the free end fill the
// track's rounded tip without poking outside it.
let pillSpan: Span | null = null;
if (isPill) {
const startsAtMin = values.length === 1 && origin === dial.min;
const reach = dial.sweep < 360 && startsAtMin ? capDegrees : 0;
pillSpan = { from: span.from - reach, length: span.length + reach };
}
const track = trackSpan(dial, radius, thickness, thumbShape);
const indicatorArc = (piece: Span, cap: "round" | "butt") => {
const stroke = arcStroke(dial, radius, piece);
return (
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
strokeWidth={thickness}
strokeLinecap={cap}
className={cn(
"stroke-primary transition-[stroke-dasharray,rotate,opacity]",
SETTLE_TRANSITION,
hidden && "opacity-0",
)}
style={{
...svgRotateStyle,
strokeDasharray: stroke.strokeDasharray,
rotate: stroke.rotate,
}}
/>
);
};
const trackStroke = arcStroke(dial, radius, track);
const c = size / 2;
return useRender({
defaultTagName: "svg",
render,
props: mergeProps<"svg">(
overlayProps("circular-slider-indicator", size, className),
props,
{
children: isPill ? (
<>
<defs>
<mask id={maskId} maskUnits="userSpaceOnUse">
<circle
cx={c}
cy={c}
r={radius}
fill="none"
stroke="white"
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={trackStroke.strokeDasharray}
// An attribute rather than CSS: CSS transforms inside mask
// content are not reliable across engines.
transform={`rotate(${parseFloat(trackStroke.rotate)} ${c} ${c})`}
/>
</mask>
</defs>
<g mask={`url(#${maskId})`}>
{indicatorArc(pillSpan as Span, "butt")}
</g>
</>
) : (
indicatorArc(drawn, "round")
),
},
),
});
}
export interface CircularSliderThumbProps extends useRender.ComponentProps<"svg"> {
/**
* Bead diameter in px. Defaults to the band's thickness less 4px. Applies
* to `thumbShape="bead"` only; a pill's shape follows the band.
*/
beadSize?: number;
}
/**
* One thumb per value, set into the end of the indicator.
*
* Drawn in SVG in the indicator's rotated space so both share a rasterizer:
* an HTML bead over an SVG arc reads as off-center at fractional scales.
* Rotating about the center makes value changes travel along the arc.
*/
function CircularSliderThumb({
className,
render,
beadSize,
...props
}: CircularSliderThumbProps) {
const {
dial,
values,
size,
thickness,
inset,
thumbShape,
pressed,
activeIndex,
focusVisibleIndex,
} = useCircularSliderContext();
const isPill = thumbShape === "pill";
const bead = beadSize ?? Math.max(4, thickness - 4);
const radius = bandRadius(size, thickness, inset);
const c = size / 2;
// Center of the band at 12 o'clock, before each thumb is rotated into place.
const cy = inset + thickness / 2;
const pillW = pillWidth(thickness);
const pillH = thickness + inset * 2;
const progresses = values.map((v) => valueToProgress(dial, v));
// When a range's beads would touch, a bridge fills the short way between
// them so they read as one pill. Always mounted so it sweeps with them.
let bridge: Span | null = null;
let merged = false;
if (!isPill && progresses.length === 2) {
const [p0, p1] = progresses;
let from = Math.min(p0, p1);
let length = Math.abs(p1 - p0);
if (dial.wrap) {
const forward = mod(p1 - p0, 360);
from = forward <= 180 ? p0 : p1;
length = Math.min(forward, 360 - forward);
}
bridge = { from, length };
merged = (length * Math.PI * radius) / 180 < bead + 2;
}
// The thumb in front is the one last moved.
const order = values
.map((_, index) => index)
.sort((a, b) => Number(a === activeIndex) - Number(b === activeIndex));
const rotated = (angle: number): React.CSSProperties => ({
...svgRotateStyle,
rotate: `${round(angle)}deg`,
});
const capsule = (
w: number,
h: number,
extra: React.SVGProps<SVGRectElement>,
) => (
<rect
x={c - w / 2}
y={cy - h / 2}
width={w}
height={h}
rx={w / 2}
{...extra}
/>
);
const thumbState = (index: number) => ({
"data-slot": "circular-slider-thumb",
"data-index": index,
"data-shape": thumbShape,
"data-active": index === activeIndex || undefined,
"data-pressed": (pressed && index === activeIndex) || undefined,
});
// Grows over the grab zone and on grab; a bead stays inside the band.
const growClasses = cn(
"[transform-origin:center] [transform-box:fill-box]",
"transition-[scale] duration-150 ease-out motion-reduce:transition-none",
isPill
? "group-data-grab-hover/circular-slider:scale-108 data-pressed:scale-112 group-data-grab-hover/circular-slider:data-pressed:scale-112"
: "group-data-grab-hover/circular-slider:scale-110 data-pressed:scale-115 group-data-grab-hover/circular-slider:data-pressed:scale-115",
);
const focusIndex =
focusVisibleIndex !== null && progresses[focusVisibleIndex] !== undefined
? focusVisibleIndex
: null;
const ring = isPill ? { w: pillW + 7, h: pillH + 7 } : null;
const children = (
<>
{bridge && (
<circle
data-slot="circular-slider-thumb-bridge"
data-merged={merged || undefined}
cx={c}
cy={c}
r={radius}
fill="none"
strokeWidth={bead}
strokeLinecap="butt"
className={cn(
"stroke-primary-foreground transition-[stroke-dasharray,rotate,opacity]",
SETTLE_TRANSITION,
merged ? "opacity-100" : "opacity-0",
)}
style={{
...svgRotateStyle,
strokeDasharray: arcStroke(dial, radius, bridge).strokeDasharray,
rotate: arcStroke(dial, radius, bridge).rotate,
// Appears late, once the beads have nearly met; leaves at once.
transitionDelay: merged ? "0ms, 0ms, 200ms" : "0ms",
}}
/>
)}
{order.map((index) => (
<g
key={index}
className={cn("transition-[rotate]", SETTLE_TRANSITION)}
style={rotated(progressToAngle(dial, progresses[index]))}
>
{isPill ? (
// The pill crosses both grey track and blue arc, so an even
// hairline edge keeps it distinct on both.
<g {...thumbState(index)} className={growClasses}>
{capsule(pillW + 1, pillH + 1, { className: "fill-black/14" })}
{capsule(pillW, pillH, {
className: "fill-primary-foreground",
})}
</g>
) : (
<circle
{...thumbState(index)}
cx={c}
cy={cy}
r={bead / 2}
className={cn("fill-primary-foreground", growClasses)}
/>
)}
</g>
))}
{/* Focus: a full-strength ring, edged with the page color on both
sides so it stays distinct where it crosses the blue arc. */}
{focusIndex !== null && (
<g
data-slot="circular-slider-thumb-focus"
className={cn("transition-[rotate]", SETTLE_TRANSITION)}
style={rotated(progressToAngle(dial, progresses[focusIndex]))}
>
{ring ? (
<>
{capsule(ring.w, ring.h, {
fill: "none",
strokeWidth: 5,
className: "stroke-background",
})}
{capsule(ring.w, ring.h, {
fill: "none",
strokeWidth: 2,
className: "stroke-ring",
})}
</>
) : (
<>
<circle
cx={c}
cy={cy}
r={bead / 2 + 3.5}
fill="none"
strokeWidth={5}
className="stroke-background"
/>
<circle
cx={c}
cy={cy}
r={bead / 2 + 3.5}
fill="none"
strokeWidth={2}
className="stroke-ring"
/>
</>
)}
</g>
)}
</>
);
return useRender({
defaultTagName: "svg",
render,
props: mergeProps<"svg">(
overlayProps("circular-slider-thumbs", size, className),
props,
{ children },
),
});
}
export interface CircularSliderTicksProps extends useRender.ComponentProps<"svg"> {
/** Number of intervals across the sweep. */
count?: number;
/** Tick length in px. */
length?: number;
}
/**
* A scale of ticks: inside a ring's band, or as a knob's band. Ticks up to
* the value light, in step with the indicator's sweep.
*/
function CircularSliderTicks({
className,
render,
count,
length,
...props
}: CircularSliderTicksProps) {
const {
dial,
values,
previousValues,
origin,
size,
thickness,
inset,
variant,
dragging,
instant,
} = useCircularSliderContext();
const isKnob = variant === "knob";
const tickCount = count ?? (isKnob ? 30 : 20);
const tickLength = length ?? (isKnob ? thickness : 4);
const outerY = isKnob ? 1 : inset + thickness + 4;
const tickWidth = isKnob ? 2 : 1.5;
// Also light the nearest tick, so a value just short of a tick still lights
// it. Just under half an interval, so a midpoint never lights both.
const halfInterval =
(dial.sweep / Math.max(1, Math.round(tickCount)) / 2) * 0.999;
const span = widenSpan(getActiveSpan(dial, values, origin), halfInterval);
const previousSpan = widenSpan(
getActiveSpan(dial, previousValues, origin),
halfInterval,
);
// The tick sweep follows the one thumb that moved. A range shifted as a
// whole, or several thumbs at once, changes its ticks together.
const movedIndex =
previousValues.length === values.length
? values.findIndex((v, i) => v !== previousValues[i])
: -1;
const movedCount = values.filter((v, i) => v !== previousValues[i]).length;
const headFrom =
movedIndex >= 0 ? valueToProgress(dial, previousValues[movedIndex]) : 0;
const headTo =
movedIndex >= 0 ? valueToProgress(dial, values[movedIndex]) : 0;
const travel = Math.abs(headTo - headFrom);
const ticks = tickProgresses(dial, tickCount).map((progress) => {
const lit = isInSpan(dial, progress, span);
const wasLit = isInSpan(dial, progress, previousSpan);
let delay = 0;
if (
!dragging &&
!instant &&
lit !== wasLit &&
movedCount === 1 &&
travel > 0
) {
const share = clamp(Math.abs(progress - headFrom) / travel, 0, 1);
delay = Math.round(easeOutExpoTimeAt(share) * SETTLE_MS);
}
return { progress, lit, delay };
});
return useRender({
defaultTagName: "svg",
render,
props: mergeProps<"svg">(
overlayProps("circular-slider-ticks", size, className),
props,
{
children: ticks.map((tick) => (
<line
key={tick.progress}
data-active={tick.lit || undefined}
x1={size / 2}
x2={size / 2}
y1={outerY}
y2={outerY + tickLength}
strokeWidth={tickWidth}
strokeLinecap="round"
transform={`rotate(${progressToAngle(dial, tick.progress)} ${size / 2} ${size / 2})`}
className={cn(
"transition-[stroke] [transition-delay:var(--tick-delay)] duration-150 ease-out motion-reduce:[transition-delay:0ms]",
isKnob
? "stroke-foreground/14 data-active:stroke-primary"
: "stroke-foreground/14 data-active:stroke-foreground/40",
)}
style={{ "--tick-delay": `${tick.delay}ms` } as React.CSSProperties}
/>
)),
},
),
});
}
export interface CircularSliderMarkProps extends useRender.ComponentProps<"svg"> {
/** The reading to mark, e.g. the current temperature. */
value: number;
}
/** A read-only mark across the band for a second value, such as a current reading. */
function CircularSliderMark({
className,
render,
value,
...props
}: CircularSliderMarkProps) {
const { dial, size, thickness, inset, thumbShape, variant } =
useCircularSliderContext();
const angle = progressToAngle(
dial,
valueToProgress(dial, clamp(value, dial.min, dial.max)),
);
// Rendered rotation, kept unwrapped so crossing a wrapping dial's seam
// (355 to 5) turns the short way. Updated during render ("previous props").
const [shown, setShown] = React.useState(angle);
let rotation = shown;
if (round(mod(shown, 360)) !== round(mod(angle, 360))) {
rotation = dial.wrap
? shown + wrapDelta(mod(shown, 360), mod(angle, 360), 360)
: angle;
setShown(rotation);
}
const isKnob = variant === "knob";
// Beside a pill thumb the mark spans the same length, so the reading and
// the target read as a matching pair of lines bracketing the span.
const matchPill = !isKnob && thumbShape === "pill";
const y1 = isKnob ? -2 : matchPill ? 0 : inset + 1;
const y2 = isKnob
? thickness + 2
: matchPill
? inset * 2 + thickness
: inset + thickness + 4;
return useRender({
defaultTagName: "svg",
render,
props: mergeProps<"svg">(
overlayProps("circular-slider-mark", size, className),
props,
{
children: (
// Rotated as one group so the edge and the line never drift apart.
<g
className="transition-[rotate] duration-350 ease-(--ease-out-expo) motion-reduce:transition-none"
style={{ ...svgRotateStyle, rotate: `${round(rotation)}deg` }}
>
{/* A page-colored edge keeps the mark legible on the blue arc. */}
<line
x1={size / 2}
x2={size / 2}
y1={y1}
y2={y2}
strokeWidth={5}
strokeLinecap="round"
className="stroke-background"
/>
<line
x1={size / 2}
x2={size / 2}
y1={y1}
y2={y2}
strokeWidth={2}
strokeLinecap="round"
className="stroke-foreground"
/>
</g>
),
},
),
});
}
export type CircularSliderKnobProps = useRender.ComponentProps<"div">;
/**
* The raised face of a `knob` dial. It turns with the value, carrying a
* notch, and presses in slightly while held.
*/
function CircularSliderKnob({
className,
render,
children,
...props
}: CircularSliderKnobProps) {
const {
dial,
values,
previousValues,
size,
thickness,
thumbShape,
focusVisibleIndex,
} = useCircularSliderContext();
// The face follows the thumb whose value last changed, not the focused
// one, so moving focus between a range's thumbs never turns it.
const [faceIndex, setFaceIndex] = React.useState(0);
const moved = values.findIndex((v, i) => v !== previousValues[i]);
if (moved >= 0 && moved !== faceIndex) setFaceIndex(moved);
const value =
values[Math.min(moved >= 0 ? moved : faceIndex, values.length - 1)];
const angle = progressToAngle(dial, valueToProgress(dial, value));
const insetPercent = ((thickness + KNOB_GAP) / size) * 100;
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(
{
"aria-hidden": true,
"data-slot": "circular-slider-knob",
"data-focus-visible": focusVisibleIndex !== null || undefined,
className: cn(
"absolute overflow-hidden rounded-full bg-surface-4 shadow-(--surface-shadow-combined-4) group-hover/circular-slider:shadow-(--surface-shadow-combined-5) group-data-disabled/circular-slider:shadow-(--surface-shadow-combined-4)",
"transition-[scale,box-shadow,outline-color] duration-150 ease-out",
"group-data-pressed/circular-slider:scale-[0.98] motion-reduce:group-data-pressed/circular-slider:scale-100",
"outline-2 outline-offset-2 outline-transparent data-focus-visible:outline-ring",
className,
),
style: { inset: `${insetPercent}%` },
children: (
<span
data-slot="circular-slider-knob-face"
className={cn(
"absolute inset-0 rounded-full transition-[rotate]",
SETTLE_TRANSITION,
)}
style={{ rotate: `${angle}deg` }}
>
{children ?? (
<span
data-slot="circular-slider-knob-notch"
data-shape={thumbShape}
className={cn(
"bg-foreground absolute left-1/2 -translate-x-1/2 rounded-full",
thumbShape === "bead"
? "top-[11%] aspect-square w-[10%]"
: "top-[9%] h-[16%] w-[3px]",
)}
/>
)}
</span>
),
} as React.ComponentProps<"div">,
props,
),
});
return element;
}
export interface CircularSliderValueProps extends Omit<
useRender.ComponentProps<"div">,
"children"
> {
/**
* Formats each value. Defaults to the root's `formatValue`, which screen
* readers also speak; set it on the root to keep the two in step.
*/
formatValue?: (value: number, index: number) => string;
/** Joins the two ends of a range. */
separator?: string;
}
/** The current value as text, centered in the dial. */
function CircularSliderValue({
className,
render,
formatValue,
separator = " – ",
...props
}: CircularSliderValueProps) {
const { values, formatValue: rootFormat } = useCircularSliderContext();
const format = formatValue ?? rootFormat;
const text = values
.map((value, index) => (format ? format(value, index) : String(value)))
.join(separator);
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(
{
"data-slot": "circular-slider-value",
"aria-hidden": true,
className: cn(
// Truncates inside the open middle rather than running over the
// arc (screen readers read the hidden input). leading-tight keeps
// the truncation clip off descenders and tall accents.
"pointer-events-none relative max-w-[calc(var(--circular-slider-hole)-0.5rem)] truncate text-[length:max(0.6875rem,calc(var(--circular-slider-size)*0.17))] leading-tight font-semibold tabular-nums",
className,
),
children: text,
} as React.ComponentProps<"div">,
props,
),
});
}
export interface CircularSliderProps<
Value extends SliderValue = number,
> extends Omit<CircularSliderRootProps<Value>, "variant" | "formatValue"> {
/**
* Formats the centered value, and what screen readers speak unless
* `getAriaValueText` says otherwise. Pass `null` to hide the value.
*/
formatValue?: ((value: number, index: number) => string) | null;
}
/**
* The ready-made ring: the band with its indicator, a thumb per value and
* the value in the middle. Compose the parts with `CircularSliderRoot` for
* anything else (a knob, ticks, a reference mark, custom center content).
*/
function CircularSlider<Value extends SliderValue = number>({
formatValue,
children,
...props
}: CircularSliderProps<Value>) {
return (
<CircularSliderRoot formatValue={formatValue ?? undefined} {...props}>
<CircularSliderTrack />
<CircularSliderIndicator />
<CircularSliderThumb />
{formatValue !== null && <CircularSliderValue />}
{children}
</CircularSliderRoot>
);
}
export type { CircularSliderDirection, ThumbCollision };
export {
CircularSlider,
CircularSliderRoot,
CircularSliderTrack,
CircularSliderIndicator,
CircularSliderThumb,
CircularSliderTicks,
CircularSliderMark,
CircularSliderKnob,
CircularSliderValue,
};
registry/default/circular-slider/lib/geometry.ts
/**
* Geometry for the circular slider. Angles are degrees, 0° at 12 o'clock,
* clockwise. Values map to a *progress* along the sweep (0 to `sweep`), and
* progress maps to a screen angle through the start and direction. Keeping
* the two apart lets partial, counterclockwise and wrapping dials share code.
*/
export type CircularSliderDirection = "clockwise" | "counterclockwise";
export interface Dial {
min: number;
max: number;
step: number;
/** Arc length of the dial in degrees, 1 to 360. */
sweep: number;
/** Screen angle of progress 0. */
start: number;
/** 1 for clockwise, -1 for counterclockwise. */
dir: 1 | -1;
/** Max and min share a position and values loop. Full sweeps only. */
wrap: boolean;
}
export interface CreateDialOptions {
min: number;
max: number;
step: number;
sweep: number;
startAngle?: number;
direction: CircularSliderDirection;
wrap: boolean;
}
/** Rounds to 4 decimals so server and client render identical markup. */
export function round(n: number): number {
return Math.round(n * 10000) / 10000;
}
export function normalizeAngle(angle: number): number {
const normalized = angle % 360;
return normalized < 0 ? normalized + 360 : normalized;
}
/** Remainder that is never negative: `mod(-1, 360)` is 359. */
export function mod(x: number, n: number): number {
return ((x % n) + n) % n;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/** Centers a partial sweep on 12 o'clock so its gap sits at the bottom. */
export function resolveStartAngle(
sweep: number,
direction: CircularSliderDirection,
startAngle?: number,
): number {
if (startAngle !== undefined) return startAngle;
if (sweep >= 360) return 0;
const half = sweep / 2;
return direction === "clockwise" ? 360 - half : half;
}
export function createDial({
min,
max,
step,
sweep,
startAngle,
direction,
wrap,
}: CreateDialOptions): Dial {
const clampedSweep = clamp(sweep, 1, 360);
return {
min,
max,
step,
sweep: clampedSweep,
start: resolveStartAngle(clampedSweep, direction, startAngle),
dir: direction === "clockwise" ? 1 : -1,
wrap: wrap && clampedSweep >= 360,
};
}
function stepDecimals(step: number): number {
const text = String(step);
const dot = text.indexOf(".");
return dot === -1 ? 0 : text.length - dot - 1;
}
/**
* Snaps to the step grid from `min`, then clamps. On a wrapping dial, where
* max and min share a position, it wraps by grid position instead, so a
* range that `step` doesn't divide (0 to 100 by 3) still stays on the grid.
*/
export function snapValue(dial: Dial, value: number): number {
const { min, max, step } = dial;
const range = max - min;
const steps = Math.round((value - min) / step);
if (dial.wrap) {
const positions = Math.max(1, Math.ceil(range / step - 1e-9));
const wrapped = min + mod(steps, positions) * step;
return Number(wrapped.toFixed(stepDecimals(step)));
}
return clamp(
Number((min + steps * step).toFixed(stepDecimals(step))),
min,
max,
);
}
export function valueToProgress(dial: Dial, value: number): number {
const range = dial.max - dial.min;
if (range <= 0) return 0;
return clamp(((value - dial.min) / range) * dial.sweep, 0, dial.sweep);
}
export function progressToValue(dial: Dial, progress: number): number {
return dial.min + (progress / dial.sweep) * (dial.max - dial.min);
}
export function progressToAngle(dial: Dial, progress: number): number {
return round(dial.start + dial.dir * progress);
}
/** Screen angle of a point around a center (0° at 12 o'clock, clockwise). */
export function pointToAngle(
x: number,
y: number,
centerX: number,
centerY: number,
): number {
const degrees = (Math.atan2(y - centerY, x - centerX) * 180) / Math.PI + 90;
return normalizeAngle(degrees);
}
/**
* Like `angleToProgress`, but angles in the gap run past the nearer end
* instead of clamping to it. A thumb held at an offset from the pointer needs
* this, or clamping before the offset stops it short of the end.
*/
export function angleToUnclampedProgress(dial: Dial, angle: number): number {
const offset = normalizeAngle((angle - dial.start) * dial.dir);
if (offset <= dial.sweep) return offset;
const gap = 360 - dial.sweep;
return offset - dial.sweep < gap / 2 ? offset : offset - 360;
}
/** Progress for a screen angle; angles in the gap clamp to the nearer end. */
export function angleToProgress(dial: Dial, angle: number): number {
return clamp(angleToUnclampedProgress(dial, angle), 0, dial.sweep);
}
/**
* A jump of over half the sweep between samples means the pointer crossed
* the seam or the gap, so a non-wrapping dial pins to the end it came from
* instead of flipping to the other.
*/
export function continueDrag(
dial: Dial,
previous: number,
next: number,
): number {
if (dial.wrap) return next;
if (Math.abs(next - previous) > dial.sweep / 2) {
return previous > dial.sweep / 2 ? dial.sweep : 0;
}
return next;
}
/** Signed shortest difference between two progresses on a wrapping dial. */
export function wrapDelta(from: number, to: number, period: number): number {
const delta = mod(to - from, period);
return delta > period / 2 ? delta - period : delta;
}
/** Positive distance between two screen angles, 0 to 180. */
export function angularDistance(a: number, b: number): number {
const delta = normalizeAngle(a - b);
return delta > 180 ? 360 - delta : delta;
}
export interface Span {
/** Progress where the span begins. */
from: number;
/** Length in degrees of progress; may run past `sweep` on a wrapping dial. */
length: number;
}
/**
* The span the indicator and lit ticks cover: between a range's values
* (clockwise from the first on a wrapping dial, so 22:00 to 07:00 runs through
* midnight), or between `origin` and a single value.
*/
export function getActiveSpan(
dial: Dial,
values: readonly number[],
origin: number,
): Span {
if (values.length > 1) {
const a = valueToProgress(dial, values[0]);
const b = valueToProgress(dial, values[values.length - 1]);
if (dial.wrap) {
return { from: a, length: mod(b - a, 360) };
}
return { from: Math.min(a, b), length: Math.abs(b - a) };
}
const value = valueToProgress(dial, values[0]);
const originProgress = valueToProgress(dial, origin);
return {
from: Math.min(value, originProgress),
length: Math.abs(value - originProgress),
};
}
export function isInSpan(dial: Dial, progress: number, span: Span): boolean {
const epsilon = 0.0001;
if (dial.wrap) {
const offset = mod(progress - span.from, 360);
return offset <= span.length + epsilon;
}
return (
progress >= span.from - epsilon &&
progress <= span.from + span.length + epsilon
);
}
/**
* Draws an arc on a `<circle>` as one dash, rotated from the stroke's 3
* o'clock start. A value change is then two numbers CSS can transition, so
* the arc sweeps along the ring instead of being redrawn as path data.
*/
export function arcStroke(
dial: Dial,
radius: number,
span: Span,
): { strokeDasharray: string; rotate: string } {
const circumference = 2 * Math.PI * radius;
const length = (span.length / 360) * circumference;
// Counterclockwise arcs are drawn clockwise from their far end.
const firstAngle =
dial.dir === 1
? dial.start + span.from
: dial.start - span.from - span.length;
return {
strokeDasharray: `${round(length)} ${round(circumference)}`,
rotate: `${round(firstAngle - 90)}deg`,
};
}
/** Tick positions; a full circle skips the last, which would land on the first. */
export function tickProgresses(dial: Dial, count: number): number[] {
const intervals = Math.max(1, Math.round(count));
const total = dial.sweep >= 360 ? intervals : intervals + 1;
return Array.from({ length: total }, (_, i) =>
round((i / intervals) * dial.sweep),
);
}
/**
* Inverse of ease-out-expo: the share of the duration the arc needs to cover
* `x` of its distance, so ticks light as its head passes them.
*/
export function easeOutExpoTimeAt(x: number): number {
if (x <= 0) return 0;
if (x >= 0.999) return 1;
return clamp(-Math.log2(1 - x) / 10, 0, 1);
}
/**
* Pulls the indicator's origin end in by its round cap, so an arc from a
* reference reading starts at the reading, not half a stroke behind it.
*/
export function trimOriginCap(
span: Span,
valueProgress: number,
originProgress: number,
capDegrees: number,
): Span {
const length = Math.max(0, span.length - capDegrees);
if (originProgress <= valueProgress) {
return { from: span.from + (span.length - length), length };
}
return { from: span.from, length };
}
/**
* Height a ring needs from the top of its square: the band's lowest point (an
* end, or 6 o'clock if the sweep passes it) plus the cap and any pill `inset`.
* A 180° dial comes out about half as tall as it is wide.
*/
export function ringHeight(
dial: Dial,
size: number,
thickness: number,
inset = 0,
): number {
const c = size / 2;
const r = size / 2 - inset - thickness / 2;
const yAt = (angle: number): number =>
c - r * Math.cos((angle * Math.PI) / 180);
const startAngle = dial.start;
const endAngle = dial.start + dial.dir * dial.sweep;
const passesBottom =
dial.sweep >= 360 ||
normalizeAngle((180 - startAngle) * dial.dir) <= dial.sweep;
const lowest = passesBottom
? c + r
: Math.max(yAt(startAngle), yAt(endAngle));
// Leave room for the centered value below a shallow sweep.
const contentFloor = c + size * 0.1;
return round(
Math.min(size, Math.max(lowest + thickness / 2 + inset, contentFloor)),
);
}
export type ThumbCollision = "push" | "none";
/**
* Moves one thumb of a range, keeping the thumbs `gap` apart: `push` carries
* the other along, as in Base UI's Slider, and `none` stops at it. On a
* wrapping dial moves go the short way, so a thumb can push through the seam.
*/
export function placeRangeThumb(
dial: Dial,
values: readonly number[],
index: number,
candidate: number,
gap: number,
collision: ThumbCollision,
): number[] {
const next = [...values];
if (values.length !== 2) {
next[index] = candidate;
return next;
}
const [a, b] = values;
if (!dial.wrap) {
if (index === 0) {
let c = clamp(candidate, dial.min, dial.max - gap);
if (c > b - gap) {
if (collision === "push") next[1] = c + gap;
else c = Math.max(dial.min, b - gap);
}
next[0] = c;
} else {
let c = clamp(candidate, dial.min + gap, dial.max);
if (c < a + gap) {
if (collision === "push") next[0] = c - gap;
else c = Math.min(dial.max, a + gap);
}
next[1] = c;
}
return next;
}
const range = dial.max - dial.min;
const span = mod(b - a, range);
let delta = wrapDelta(values[index], candidate, range);
// The span must stay within [gap, range - gap].
const after = index === 0 ? span - delta : span + delta;
const short = after < gap ? gap - after : 0;
const long = after > range - gap ? after - (range - gap) : 0;
const other = 1 - index;
if (short || long) {
const shift = index === 0 ? short - long : long - short;
if (collision === "push") {
next[other] = snapValue(dial, values[other] + shift);
} else {
delta -= index === 0 ? short - long : long - short;
}
}
next[index] = snapValue(dial, values[index] + delta);
return next;
}
/** Grows a span by `by` degrees at both ends. */
export function widenSpan(span: Span, by: number): Span {
return { from: span.from - by, length: span.length + by * 2 };
}
/**
* For a press on both of a range's thumbs: the one that can move the way the
* pointer went, the thumb ahead going forward and the one behind going back.
*/
export function pickOverlappedThumb(
dial: Dial,
values: readonly number[],
forward: boolean,
): number {
const range = dial.max - dial.min;
const endAhead = dial.wrap
? mod(values[1] - values[0], range) < range / 2
: values[1] >= values[0];
const ahead = endAhead ? 1 : 0;
return forward ? ahead : 1 - ahead;
}