TimeCircle: A Simple SVG-Based Countdown Ring for React Native (Expo)
A reusable TimeCircle
component for Expo / React Native that counts down from 100% to 0% using strokeDashoffset
on an SVG circle. It’s lightweight, zero-external-animation-dependency by default (uses React Native’s built-in Animated
), and easy to drop into any project. This article covers the implementation, rationale, accessibility, and upgrade path to higher-performance animation.
Why this pattern
Circular progress indicators are a familiar UI affordance for showing time remaining or completion percentage in a compact, glanceable form. Using an SVG circle with strokeDasharray
& strokeDashoffset
gives pixel-perfect control over the “fill” amount, and rotating the circle makes it behave like a conventional clockwise countdown. Similar approaches are used widely in web and mobile; you can find canonical examples and tutorials in the React Native ecosystem.
Features of TimeCircle
- Countdown from full (100%) to empty (0%) over a specified duration.
- Animated via React Native’s built-in
Animated.timing
(no extra dependency). :contentReference[oaicite:1]{index=1} - SVG-based rendering with
react-native-svg
. - Customizable size, stroke, colors, and label formatting.
- Completion callback.
- Easily upgradable to Reanimated for smoother/high-performance scenarios. :contentReference[oaicite:2]{index=2}
The runnable code example
enjoy!
// TimeCircle.tsx
import { Dimensions } from 'react-native';
export const width = Dimensions.get('window').width;
export const height = Dimensions.get('window').height;
// vw/vh are 1% of the screen width/height respectively
export const vw = width / 100;
export const vh = height / 100;
export const vmin = Math.min(vw, vh);
export const vmax = Math.max(vw, vh);
import React, { useEffect, useRef, useCallback } from 'react';
import { View, Text, StyleSheet, Animated, Easing } from 'react-native';
import { Svg, Circle } from 'react-native-svg';
type Props = {
/** Countdown seconds (e.g., 10 for 10 seconds) */
totalSeconds: number;
/** Outer diameter size */
size?: number;
/** Circle thickness */
strokeWidth?: number;
/** Progress color */
color?: string;
/** Background circle color */
trackColor?: string;
/** Is it playing? */
isPlaying?: boolean;
/** Callback called on completion */
onComplete?: () => void;
};
// ingz, display time text error.
const TimeCircle: React.FC<Props> = ({
totalSeconds = 10,
size = 64 * vw,
strokeWidth = 10,
color = '#4caf50',
trackColor = '#eee',
isPlaying = true,
onComplete = () => {
console.log('Timer completed');
},
}) => {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const animatedValue = useRef(new Animated.Value(0)).current; // 0 -> 1
const remainingRef = useRef<number>(totalSeconds);
// Internal: Convert percentage (1->0) to strokeDashoffset
const strokeDashoffset = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, circumference], // When 100%, offset=0, when 0%, offset=circumference
extrapolate: 'clamp',
});
// Start animation
const start = useCallback(() => {
remainingRef.current = totalSeconds;
Animated.timing(animatedValue, {
toValue: 1,
duration: totalSeconds * 1000,
easing: Easing.linear,
useNativeDriver: false,
}).start(({ finished }) => {
if (finished) {
onComplete?.();
}
});
// Internal countdown display per second (update label with setInterval)
const interval = setInterval(() => {
remainingRef.current = totalSeconds--;
if (remainingRef.current <= 0) {
clearInterval(interval);
totalSeconds = 0; // Reset to avoid negative values
}
}, 1000);
return () => clearInterval(interval);
}, [animatedValue, totalSeconds, onComplete]);
useEffect(() => {
let cleanup: (() => void) | undefined;
if (isPlaying) {
cleanup = start();
}
return () => {
if (cleanup) cleanup();
};
}, [isPlaying, start]);
// const remainingTimeText = `${remainingRef.current}s`;
return (
<View
style={{
width: size,
height: size,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Svg width={size} height={size}>
<Circle
stroke={trackColor}
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
/>
<AnimatedCircle
stroke={color}
fill="none"
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference}`}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
rotation="-90"
origin={`${size / 2}, ${size / 2}`}
/>
</Svg>
{/* <View style={styles.labelContainer} pointerEvents="none">
<Text style={styles.labelText}>{remainingTimeText}</Text> // need further fix
</View> */}
</View>
);
};;
// Animated Circle
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
const styles = StyleSheet.create({
labelContainer: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
labelText: {
fontSize: 18,
fontWeight: '600',
fontFamily: 'System',
},
});
export default TimeCircle;
Top comments (0)