The options, briefly
| Approach | Dependencies | Animation | Best for |
|---|---|---|---|
| Two Views | None | With Reanimated | Almost everything |
| react-native-progress | react-native-svg | Built in | Bars, pies, and circles at once |
| react-native-svg (custom) | react-native-svg | strokeDashoffset | Rings, arcs, segmented dials |
| ProgressBarAndroid | Community package | Platform | Nothing — removed from core |
This is one of the few UI problems where writing it yourself is genuinely the right default. A progress bar has no gesture handling, no platform behaviour, and no accessibility tree of its own — it is a rectangle inside a rectangle. Pulling in a dependency buys you a preset colour scheme and an API you then have to learn.
The component
import { StyleSheet, View } from 'react-native';
export default function ProgressBar({ progress, height = 8, color = '#fb923c' }) {
// clamp — a bad API response should not render a fill wider than its track
const pct = Math.max(0, Math.min(1, progress)) * 100;
return (
<View
style={[styles.track, { height, borderRadius: height / 2 }]}
accessibilityRole="progressbar"
accessibilityValue={{ min: 0, max: 100, now: Math.round(pct) }}
>
<View
style={[styles.fill, { width: `${pct}%`, backgroundColor: color, borderRadius: height / 2 }]}
/>
</View>
);
}
const styles = StyleSheet.create({
track: {
width: '100%',
backgroundColor: 'rgba(255,255,255,0.08)',
overflow: 'hidden',
},
fill: { height: '100%' },
});Three details carry more weight than they look like they do. overflow: 'hidden'on the track is what keeps the fill inside the rounded corners on Android, where a child does not clip to its parent’s border radius by default. The clamp stops a server that reports 1.04 from drawing a fill that overhangs its track. And accessibilityRole plus accessibilityValue are the only reason a screen reader announces anything at all here — a styled View has no semantics on its own.
That version is correct and, for a bar that updates a few times a second from an upload callback, entirely sufficient. It has one weakness, and it shows up exactly when the app is busiest.
Why you animate scaleX and not width
width is a layout property. Changing it means the layout engine re-measures the subtree, so it cannot be driven natively — every frame has to round-trip through JavaScript. That is fine when the bar is idle and terrible during the moment a progress bar exists for, because uploading, parsing, and decoding are all things that block the JS thread. The bar stutters precisely while the work it is reporting on is happening.
transform: scaleX is not a layout property. Give the fill its full width up front, anchor it to the left with transformOrigin, and scale it — the animation then runs on the UI thread and keeps moving regardless of what JavaScript is doing:
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
} from 'react-native-reanimated';
export default function AnimatedProgressBar({ progress, height = 8 }) {
const pct = Math.max(0, Math.min(1, progress));
const scale = useSharedValue(pct);
useEffect(() => {
scale.value = withTiming(pct, { duration: 280, easing: Easing.out(Easing.quad) });
}, [pct, scale]);
const fillStyle = useAnimatedStyle(() => ({ transform: [{ scaleX: scale.value }] }));
return (
<View
style={[styles.track, { height, borderRadius: height / 2 }]}
accessibilityRole="progressbar"
accessibilityValue={{ min: 0, max: 100, now: Math.round(pct * 100) }}
>
<Animated.View style={[styles.fill, { borderRadius: height / 2 }, fillStyle]} />
</View>
);
}
const styles = StyleSheet.create({
track: { width: '100%', backgroundColor: 'rgba(255,255,255,0.08)', overflow: 'hidden' },
fill: {
width: '100%', // full width, then scaled down
height: '100%',
backgroundColor: '#fb923c',
transformOrigin: 'left', // otherwise it grows from the centre
},
});Forget transformOrigin and the bar expands outward from the middle in both directions, which is a distinctive enough bug that you will recognise it instantly the first time. On older React Native versions without that style prop, the equivalent trick is to wrap the fill and offset it by half its width with translateX. Reanimated is the general tool here — the animations guide covers the wider set of things it makes cheap.
The circular variant
Rings need a real drawing surface, which means react-native-svg. The trick is a dashed stroke with exactly one dash the length of the whole circle, then offsetting that dash to hide the part you have not completed:
import Svg, { Circle } from 'react-native-svg';
export default function ProgressRing({ progress, size = 120, stroke = 10 }) {
const pct = Math.max(0, Math.min(1, progress));
const r = (size - stroke) / 2;
const circumference = 2 * Math.PI * r;
return (
<Svg width={size} height={size} style={{ transform: [{ rotate: '-90deg' }] }}>
<Circle
cx={size / 2} cy={size / 2} r={r}
stroke="rgba(255,255,255,0.08)" strokeWidth={stroke} fill="none"
/>
<Circle
cx={size / 2} cy={size / 2} r={r}
stroke="#fb923c" strokeWidth={stroke} fill="none"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - pct)}
strokeLinecap="round"
/>
</Svg>
);
}The -90degrotation is not cosmetic. SVG circles start at three o’clock, and a ring that fills starting from the right-hand side reads as broken to everyone who looks at it. To animate it, wrap the second Circle with Animated.createAnimatedComponent and drive strokeDashoffset the same way you drove scaleX.
When a progress bar is the wrong component
A progress bar is a promise about time. If you do not actually know how long something takes, a determinate bar is a lie the user will catch — the familiar bar that reaches 90% and sits there is worse than no bar, because it converts waiting into distrust. Three honest alternatives: an ActivityIndicator when the wait is short and unquantifiable, an indeterminate looping bar when you want to signal ongoing work in a header, and a skeleton screen when what is loading is a screen full of content.
Reserve the real bar for the cases where the number is genuine: bytes uploaded, steps completed, questions answered. If you want the whole flow rather than the component, describe it — “an upload screen with a progress bar, a cancel button, and a retry state” — and ShipNative builds it as real React Native you can run on your phone and export.