React Native Circular Slider: Angles and Steps
By Native Animation · Updated
A React Native circular slider maps travel around a center to a bounded value. A useful dial needs more than a rotating thumb: it must handle the angle seam, snap to valid steps, stop at its limits, and offer precise adjustments without dragging. This tutorial builds a 60–85°F control with a 270-degree sweep and a Reanimated thumb. It deliberately separates continuous visual progress from the accepted whole-degree value.
Choose a dial only when rotation helps
A circular control fits a thermostat or compact instrument where the value and surrounding arc belong together. For a form field, a straight slider, wheel picker, or pair of step buttons may be easier to discover. The SDK 58 community Slider is a native linear alternative; it does not become circular through a style prop.
Angle to value and back
Let p be normalized progress from 0 to 1. The accepted value is min + round(p × (max − min) / step) × step, clamped back into the range. The display angle is −225° + p × 270°. With screen coordinates, positive y points down, so increasing atan2 angles move clockwise. The thumb starts at the lower left, travels through the top, and finishes at the lower right; the bottom gap separates the endpoints.
For a different range, choose a finite positive step and ideally make (max − min) divisible by step. Otherwise decide explicitly whether the maximum is an additional selectable endpoint. The example constants divide evenly; they are not a runtime min/max validation API.
The gesture uses relative angular travel. It records the first pointer angle without jumping the thumb to the touched location, then adds normalized deltas. Crossing atan2's −180°/180° seam therefore produces a small delta instead of a nearly full-circle jump. Progress clamps at both endpoints rather than wrapping from maximum to minimum.
Setup
This file targets Expo SDK 58, RN 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Follow the SDK 58 animation setup and install the compatible gesture dependencies:
npx expo install react-native-gesture-handler react-native-reanimated react-native-workletsUse bunx in Bun projects. Save the file outside src/app and render DialExample in a safe-area screen. The drawing uses ordinary views; Skia is not required for this tutorial.
Complete stepped dial
The Gesture.* builder API remains available in Gesture Handler 3.3, but is deprecated in favor of its hook API. The example retains the builders.
import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withSpring } from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
const MIN = 60, MAX = 85, STEP = 1, SIZE = 280, RADIUS = 108;
function snap(value: number) {
'worklet';
return Math.min(MAX, Math.max(MIN, MIN + Math.round((value - MIN) / STEP) * STEP));
}
function angleAt(x: number, y: number) {
'worklet';
return Math.atan2(y - SIZE / 2, x - SIZE / 2) * 180 / Math.PI;
}
export function CircularSlider() {
const [value, setValue] = useState(72);
const progress = useSharedValue((72 - MIN) / (MAX - MIN));
const previousAngle = useSharedValue(0);
const dragging = useSharedValue(false);
const reduced = useReducedMotion();
useEffect(() => {
const target = (value - MIN) / (MAX - MIN);
progress.set(reduced ? target : withSpring(target, { damping: 24, stiffness: 260 }));
}, [value, progress, reduced]);
function commit(next: number) {
const accepted = snap(next);
setValue(accepted);
// Also settle when rounding returns the already accepted value.
const target = (accepted - MIN) / (MAX - MIN);
progress.set(reduced ? target : withSpring(target, { damping: 24, stiffness: 260 }));
}
const pan = Gesture.Pan().minDistance(0)
.onStart((event) => {
const radius = Math.hypot(event.x - SIZE / 2, event.y - SIZE / 2);
dragging.set(Math.abs(radius - RADIUS) <= 28);
previousAngle.set(angleAt(event.x, event.y));
if (dragging.get()) progress.set(progress.get()); // Interrupt the current spring.
})
.onUpdate((event) => {
if (!dragging.get()) return;
const angle = angleAt(event.x, event.y);
// Normalize across atan2's -180/180 seam before adding clockwise travel.
const delta = ((angle - previousAngle.get() + 540) % 360) - 180;
previousAngle.set(angle);
progress.set(Math.max(0, Math.min(1, progress.get() + delta / 270)));
})
.onFinalize(() => {
if (!dragging.get()) return;
dragging.set(false);
scheduleOnRN(commit, snap(MIN + progress.get() * (MAX - MIN)));
});
const thumb = useAnimatedStyle(() => {
const radians = (-225 + progress.get() * 270) * Math.PI / 180;
return { transform: [
{ translateX: Math.cos(radians) * RADIUS },
{ translateY: Math.sin(radians) * RADIUS },
] };
});
return (
<View style={styles.root}>
<GestureDetector gesture={pan}>
<View accessible accessibilityRole="adjustable" accessibilityLabel="Target temperature"
accessibilityValue={{ min: MIN, max: MAX, now: value, text: value + ' degrees Fahrenheit' }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'increment') commit(value + STEP);
if (event.nativeEvent.actionName === 'decrement') commit(value - STEP);
}} style={styles.dial}>
<View accessible={false} style={styles.ring} />
<Text accessible={false} style={styles.value}>{value}°F</Text>
<Animated.View accessible={false} style={[styles.thumb, thumb]} />
</View>
</GestureDetector>
<Text>Drag the ring clockwise to increase. Range: 60–85°F.</Text>
<View style={styles.actions}>
<Pressable accessibilityRole="button" accessibilityLabel="Decrease temperature"
disabled={value === MIN} accessibilityState={{ disabled: value === MIN }}
onPress={() => commit(value - STEP)} style={styles.button}><Text>− 1°F</Text></Pressable>
<Pressable accessibilityRole="button" accessibilityLabel="Increase temperature"
disabled={value === MAX} accessibilityState={{ disabled: value === MAX }}
onPress={() => commit(value + STEP)} style={styles.button}><Text>+ 1°F</Text></Pressable>
</View>
</View>
);
}
export default function DialExample() {
return <GestureHandlerRootView style={{ flex: 1 }}><CircularSlider /></GestureHandlerRootView>;
}
const styles = StyleSheet.create({
root: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 16, backgroundColor: '#FFFFFF' },
dial: { width: SIZE, height: SIZE, alignItems: 'center', justifyContent: 'center' },
ring: { position: 'absolute', width: RADIUS * 2, height: RADIUS * 2, borderRadius: RADIUS,
borderWidth: 8, borderColor: '#D8D0EB' },
value: { fontSize: 32, color: '#24163D' },
thumb: { position: 'absolute', left: SIZE / 2 - 12, top: SIZE / 2 - 12,
width: 24, height: 24, borderRadius: 12, backgroundColor: '#523587' },
actions: { flexDirection: 'row', gap: 16 },
button: { minWidth: 64, minHeight: 48, padding: 12, borderWidth: 1, borderRadius: 12, justifyContent: 'center' },
});Dragging, snapping, and interruption
The control only changes from touches starting within twenty-eight points of the ring radius. Center touches do not interrupt the spring. This recognizer still occupies the dial's rectangular bounds, so do not use it as a nested-scroll arbitration recipe. Buttons sit outside the GestureDetector.
On valid drag start, setting the current progress interrupts any moving spring. Pointer updates remain on the UI thread. Finalization commits the nearest valid degree through scheduleOnRN and settles the thumb, including when rounding returns the already accepted value. This example keeps the last dragged value when the OS cancels the gesture. If cancellation must restore the previous accepted value, store that value at gesture start and distinguish success in onFinalize.
The numeric label and accessibilityValue expose the accepted value; they update on release, while the thumb previews continuous motion. That policy avoids React state updates for every pointer frame. For live numeric previews, render an animated text or number layer and throttle application callbacks separately. Network writes should use an explicit commit/debounce policy, not an animation completion callback.
The tutorial draws a complete static ring to keep the geometry small. A production progress arc should visibly distinguish the 270-degree active path from the bottom gap. The fixed 280-point dial fits a typical phone content width; narrower containers need measured sizing and proportional radius/hit geometry. Do not simply scale the visual and leave the input coordinates unchanged.
Adjustable accessibility and precise input
The dial exposes the adjustable role, a Fahrenheit value with bounds, and increment/decrement actions through the React Native 0.88 accessibility API. Two named forty-eight-point buttons provide the same one-degree changes and disable at the endpoints. Buttons also give web keyboard users ordinary Tab and activation support; the example does not implement arrow keys on the dial itself. Do not rely on a gesture-only surface for precision input.
Reduced Motion keeps the thumb directly under gesture control but removes the settling spring. useReducedMotion reads the startup preference; subscribe to AccessibilityInfo if changes must apply while the app stays open. Test large text and your unit formatting. Never reduce the user's text scale just to keep a decorative dial centered.
Thermostat Dial: the finished interaction
Thermostat Dial adds a continuous Skia gradient arc, sixty responsive ticks, an illuminated endpoint, rolling temperature, mode surfaces, and an interruptible intro. Its actual range is 60–85°F. The API uses initialTemperature, onTemperatureChange, and ref methods setTemperature and nudge; it does not advertise generic min, max, or step props. Changing the tutorial's constants does not change that component's public API.
Each published piece is reviewed on physical iPhone and Android devices with Expo Go before release. Connect the temperature callback to your own application logic; no thermostat hardware or service is included. Browse value selectors and the animated counter guide for other bounded numeric interfaces.
For a measurement scale with labeled marks, see the ruler picker guide: it covers efficient ticks, displacement mapping, bounded inertia, and unit-aware controlled values.
FAQ
Why does my dial jump at the left side?
atan2 changes representation at −180°/180°. Normalize each angular delta into a half-turn interval before accumulating it. Also avoid moving the thumb to the initial touch if relative dragging is the intended behavior.
Should the value wrap around?
For a bounded temperature, no. Clamp progress and keep the minimum and maximum distinct. A cyclic time or hue selector needs a different model with deliberate wrapping semantics.
Can I use the catalog dial for any range?
Its documented temperature range is fixed at 60–85°F. Adapt the owned source if your product needs another domain, and update geometry, formatting, accessibility bounds, and validation together.