React Native Progress Bar, Ring, and Value Slider
By Native Animation · Updated
A React Native progress bar should show a measurable task's completion, while a slider lets the user choose a value. This tutorial builds both from normalized progress between zero and one, then draws the same completion as a Skia ring. Application data remains authoritative; Reanimated interpolates the visual state without pretending that elapsed animation time is real progress.
What you'll build
The example combines a horizontal bar, a circular ring, and a value slider with increase and decrease controls. Dragging the slider updates its thumb and fill on the UI thread, then reports the committed value to React on release. The parent sends that value to the bar and ring, which animate together toward the new target.
For an actual upload, pass uploaded bytes divided by total bytes instead of the slider's value. If the total is unknown, show indeterminate activity or a loading skeleton rather than assigning a guessed percentage. The progress display and the input control have different accessibility roles because they answer different tasks.
Prerequisites
The examples use Expo SDK 57, React Native 0.86.3, Reanimated 4.5.1, and react-native-worklets 0.10.1. Gesture examples use Gesture Handler ~2.32.0. Resolve compatible versions with Expo rather than choosing the latest npm release:
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler @shopify/react-native-skiaUse bunx expo install if your project uses Bun. The SDK 57 Reanimated documentation explains the automatic Babel setup. Keep gesture screens inside a GestureHandlerRootView as shown in the sample; if your application already provides one, reuse that root. These examples belong in a component file outside the Expo Router route tree. Render the exported example from your screen.
Build the component
Create AnimatedProgress.tsx. The file includes the reusable component and a small usage example.
import { useEffect, useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Canvas, Group, Path, Skia } from '@shopify/react-native-skia';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
cancelAnimation,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
function clamp(value: number) {
'worklet';
return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0));
}
export function AnimatedProgress({
value,
label = 'Upload progress',
}: {
value: number;
label?: string;
}) {
const target = clamp(value);
const progress = useSharedValue(target);
const reducedMotion = useReducedMotion();
const circle = useMemo(() => Skia.Path.Make().addCircle(60, 60, 50), []);
useEffect(() => {
progress.set(
reducedMotion ? target : withTiming(target, { duration: 240 }),
);
}, [progress, reducedMotion, target]);
const fillStyle = useAnimatedStyle(() => ({
width: (progress.get() * 100 + '%') as `${number}%`,
}));
return (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel={label}
accessibilityValue={{ min: 0, max: 100, now: Math.round(target * 100) }}
style={styles.progress}
>
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<View style={styles.track}>
<Animated.View style={[styles.fill, fillStyle]} />
</View>
<Canvas
style={{
width: 120,
height: 120,
alignSelf: 'center',
marginTop: 20,
}}
>
<Group
origin={{ x: 60, y: 60 }}
transform={[{ rotate: -Math.PI / 2 }]}
>
<Path
path={circle}
color="#E8E2F3"
style="stroke"
strokeWidth={8}
/>
<Path
path={circle}
color="#6550B5"
style="stroke"
strokeWidth={8}
strokeCap="round"
start={0}
end={progress}
/>
</Group>
</Canvas>
<Text style={styles.percent}>{Math.round(target * 100)}%</Text>
</View>
</View>
);
}
export function ValueSlider({
initialValue = 0.5,
onChange,
}: {
initialValue?: number;
onChange?: (value: number) => void;
}) {
const [value, setValue] = useState(clamp(initialValue));
const [width, setWidth] = useState(0);
const progress = useSharedValue(clamp(initialValue));
const origin = useSharedValue(0);
const reducedMotion = useReducedMotion();
const travel = Math.max(1, width - 44);
function commit(next: number) {
setValue(next);
onChange?.(next);
}
function choose(next: number) {
const target = clamp(Math.round(next * 100) / 100);
progress.set(
reducedMotion ? target : withTiming(target, { duration: 160 }),
);
commit(target);
}
const pan = Gesture.Pan()
.enabled(width > 44)
.activeOffsetX([-4, 4])
.failOffsetY([-18, 18])
.onBegin(() => {
cancelAnimation(progress);
origin.set(progress.get());
})
.onUpdate((event) =>
progress.set(clamp(origin.get() + event.translationX / travel)),
)
.onEnd(() => scheduleOnRN(commit, progress.get()))
.onFinalize((_event, success) => {
if (!success)
progress.set(
reducedMotion ? value : withTiming(value, { duration: 160 }),
);
});
const thumbStyle = useAnimatedStyle(() => ({
transform: [{ translateX: progress.get() * travel }],
}));
const fillStyle = useAnimatedStyle(() => ({
width: progress.get() * travel + 22,
}));
return (
<View style={styles.progress}>
<GestureDetector gesture={pan}>
<Animated.View
accessible
accessibilityRole="adjustable"
accessibilityLabel="Intensity"
accessibilityValue={{
min: 0,
max: 100,
now: Math.round(value * 100),
}}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'increment')
choose(value + 0.1);
if (event.nativeEvent.actionName === 'decrement')
choose(value - 0.1);
}}
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
style={styles.slider}
>
<View pointerEvents="none" style={styles.sliderTrack}>
<Animated.View style={[styles.fill, fillStyle]} />
</View>
<Animated.View
pointerEvents="none"
style={[styles.thumb, thumbStyle]}
/>
</Animated.View>
</GestureDetector>
<View style={styles.controls}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Decrease intensity"
disabled={value === 0}
onPress={() => choose(value - 0.1)}
style={styles.button}
>
<Text>Decrease</Text>
</Pressable>
<Text>{Math.round(value * 100)}%</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Increase intensity"
disabled={value === 1}
onPress={() => choose(value + 0.1)}
style={styles.button}
>
<Text>Increase</Text>
</Pressable>
</View>
</View>
);
}
const styles = StyleSheet.create({
progress: { width: '100%', maxWidth: 360, alignSelf: 'center' },
track: {
height: 8,
borderRadius: 4,
backgroundColor: '#E8E2F3',
overflow: 'hidden',
},
fill: { height: '100%', backgroundColor: '#6550B5', borderRadius: 4 },
percent: {
textAlign: 'center',
color: '#272132',
fontVariant: ['tabular-nums'],
marginTop: 8,
},
slider: { height: 48, justifyContent: 'center' },
sliderTrack: {
height: 8,
position: 'absolute',
left: 0,
right: 0,
backgroundColor: '#E8E2F3',
borderRadius: 4,
overflow: 'hidden',
},
thumb: {
height: 44,
width: 44,
borderRadius: 22,
backgroundColor: '#6550B5',
},
controls: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
button: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 8 },
});
export function ProgressExample() {
const [value, setValue] = useState(0.5);
return (
<GestureHandlerRootView
style={{ flex: 1, justifyContent: 'center', gap: 24, padding: 24 }}
>
<AnimatedProgress value={value} label="Selected intensity" />
<ValueSlider initialValue={value} onChange={setValue} />
</GestureHandlerRootView>
);
}Normalize and clamp the value
clamp accepts finite input and keeps it inside zero through one. Negative input becomes zero, values above one become one, and nonfinite values become zero. Choose an explicit unknown or error state at the parent level if a missing total has meaning; silently displaying zero is only the sample's defensive fallback.
The display keeps the actual target separate from the animated shared value. Its visible percentage and accessible value report the target immediately, while the bar and ring take 240 milliseconds to catch up. That distinction prevents assistive technology from receiving dozens of intermediate animation numbers. A new target replaces the current timing animation from its current visual value.
Do not derive completion from the animation callback. The application knows when the task is complete or failed; reaching the visual end of a bar only means the display reached its latest target. For resumable uploads, retries, or multiple phases, decide how real task data maps into progress before designing the motion.
Horizontal bar geometry
The track clips its colored fill inside rounded corners, and the fill width is the animated fraction times one hundred percent. This approach adapts to the parent width without measuring it. Animating width can perform layout work, so keep the bar compact and profile a screen with many simultaneous indicators before adopting the same strategy everywhere.
For a larger surface, you can measure width once and animate a left-anchored transform inside a clipped track. That reduces layout churn but requires care with transform origin, minimum visible fill, and corner shape. The simple implementation favors clear geometry over a speculative optimization.
Draw the progress ring with Skia
The ring path is constructed once with useMemo, centered at sixty points with a fifty-point radius. One static stroke draws the track. A second stroke uses start={0} and the shared progress as end, trimming the path to its completed fraction. A group rotates the circle by negative pi over two so completion begins at the top instead of the default rightmost point.
Skia reads the shared value directly; there is no React setState call for each intermediate arc. Rounded stroke caps add a softer endpoint. At exactly zero, the trimmed path is empty; test tiny fractions and the full-circle seam against your chosen stroke width. The Skia path documentation describes path trimming.
The sample uses Skia 2.6.2, resolved for this repository's Expo SDK. Install it through Expo and follow the SDK 57 Skia reference for native and web setup. CanvasKit must be loaded before rendering Skia on web. Keep that initialization in your application's web entry point, outside this reusable component. A bar-only implementation does not need a canvas dependency.
Turn the same range into a value slider
The slider measures its own width, subtracts the forty-four-point thumb, and uses the remaining travel distance to map finger movement into a normalized value. The thumb translation is progress times travel. The fill adds half the thumb width so it ends beneath the thumb center instead of stopping at its left edge.
On gesture begin, any prior animation is canceled and the current progress becomes the origin. During update, movement is clamped to the range. On release, scheduleOnRN(commit, progress.get()) sends a semantic value to the parent. See the Worklets runtime scheduling reference for this boundary.
A canceled gesture restores the last committed value. The slider intentionally reports only the final selection, so the external bar changes after release rather than following every drag frame. If an audio or lighting preview needs continuous feedback, design a throttled application callback separately; do not put heavy business work into every gesture event.
The increase and decrease controls move by one tenth and round to hundredths before clamping. This avoids floating-point residue leaving an endpoint just below one. initialValue is used only on mount. To synchronize external changes after mount, add a controlled value API with explicit behavior during active drags instead of expecting the initial prop to reset the component.
Accessibility and reduced motion
The completion container has a progressbar role and one label with a numeric value from zero through one hundred. Its bar, canvas, and visible percentage are decorative children excluded from the accessibility tree. The slider uses an adjustable role and exposes increment and decrement actions, plus visible labeled buttons with forty-four-point targets.
With reduced motion, new completion values apply directly, and slider button changes do not animate. Direct finger tracking remains because it communicates the user's input. An accessible value updates only when selection commits, avoiding continuous announcements while dragging.
Do not rely on a fill color to communicate success or failure. Use visible status text supplied by the parent. For a long-running task, announce meaningful milestones or completion rather than every percentage point. Test the final interface with a screen reader, large text, both color schemes, and the device's reduced-motion setting.
Performance, loading states, and verification
One shared progress drives the bar and ring, while the independently interactive slider has its own shared value. They represent different sources of truth during a drag, so forcing them into a single value would misrepresent the sample's commit-on-release behavior. React renders at target changes and accepted selections, not for animation frames.
Use determinate progress when a reliable total exists. Use an indeterminate indicator when work is active but cannot be quantified, and a skeleton loader when the destination layout is already known. A loading bar that races to ninety percent and stalls can mislead users if those numbers have no relationship to the task.
Test zero, one, out-of-range values, nonfinite input, rapid target replacement, narrow widths, dragging beyond both edges, canceled gestures, repeated buttons at endpoints, and resizing. Validate web CanvasKit loading separately from TypeScript compilation, and profile drawing cost on target devices before claiming smoothness or universal compatibility.
Published catalog alternatives
The published Orbit Chase Loader accepts real determinate progress as well as an indeterminate mode, draws a dimensional orbital scene with Skia, and exposes a completion animation callback. It is a richer waiting illustration rather than a direct copy of this bar. Password Strength Meter uses animated segments for requirement feedback; that score is different from task completion.
Use the wheel picker guide when values are a short discrete list, and the animated counter guide when numeric changes need rolling feedback. The slider code here is complete without depending on an unpublished catalog slider.
FAQ
How do I animate a React Native progress bar?
Clamp the real target to zero through one, animate a shared value toward it, and derive fill width from that fraction. Keep the task's state separate from the display's interpolation.
How do I build a circular progress animation?
Create a circle path and draw its track plus a trimmed foreground stroke. Skia can read Reanimated progress directly as the path's end fraction, with a rotation to begin at the top.
Is a progress bar the same as a slider?
A progress bar reports completion; a slider accepts input. Use progressbar semantics for the display and adjustable semantics with equivalent non-gesture controls for the slider.
Should I show percentages when the total is unknown?
No. Choose indeterminate feedback and explain the current task. A guessed percentage can imply progress that the application has not measured.