DEV Community

Lysander J
Lysander J

Posted on

How to use react component to create a timer circle (circular progress )

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;

Enter fullscreen mode Exit fullscreen mode

Top comments (0)