Skip to content
Native Animation

Build an Animated Countdown Timer in React Native

Updated

A reliable countdown timer in React Native should derive remaining time from an absolute expiration timestamp, not decrement a counter and hope the interval stays accurate. This tutorial builds an animated timer with Expo SDK 57, React Native 0.86, and Reanimated 4.5.1, using one shared tick progress for urgency motion and color while React updates only the displayed second.

What you'll build

The component renders an HH:MM:SS or MM:SS clock, remains correct after delayed timers or app backgrounding, calls onExpire exactly once, and adds a restrained pulse during the final ten seconds. It also exposes a concise screen-reader value and replaces travel with a static state when Reduce Motion is enabled.

The timer is generic: pass an absolute expiresAt date and use it for an offer, reservation hold, workout interval, event start, or verification-code expiry. Business rules stay outside the visual component.

Prerequisites

Use Expo SDK 57, React Native 0.86, Reanimated 4.5.1, react-native-worklets 0.10.1, and expo-linear-gradient. Gesture Handler, Skia, Blur, and Haptics are optional and not used in this implementation.

Install Expo-compatible packages:

npx expo install react-native-reanimated react-native-worklets expo-linear-gradient

The Expo SDK 57 Reanimated documentation covers setup. The animation uses interpolateColor to transition from a neutral clock to an urgent state.

Start from an expiration timestamp

A timer that runs setRemaining(value => value - 1) accumulates drift whenever JavaScript is busy, the interval fires late, or the app is backgrounded. Instead, calculate expiresAt - Date.now() on every tick. The interval only asks for a refresh; wall-clock time remains the source of truth.

Use Math.ceil while time remains so a 1.2-second interval displays 2 rather than appearing to expire early. Clamp at zero and ensure the expiration callback is guarded against repeated interval ticks.

Build the countdown timer

Create AnimatedCountdown.tsx:

import { useEffect, useRef, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
  Easing,
  interpolate,
  interpolateColor,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

type AnimatedCountdownProps = {
  expiresAt: Date;
  onExpire?: () => void;
  label?: string;
};

function secondsUntil(expiresAt: Date) {
  return Math.max(
    0,
    Math.ceil((expiresAt.getTime() - Date.now()) / 1000),
  );
}

function formatTime(totalSeconds: number) {
  const hours = Math.floor(totalSeconds / 3600);
  const minutes = Math.floor((totalSeconds % 3600) / 60);
  const seconds = totalSeconds % 60;
  const clock =
    String(minutes).padStart(2, '0') +
    ':' +
    String(seconds).padStart(2, '0');

  return hours > 0 ? String(hours) + ':' + clock : clock;
}

export function AnimatedCountdown({
  expiresAt,
  onExpire,
  label = 'Time remaining',
}: AnimatedCountdownProps) {
  const [remaining, setRemaining] = useState(() => secondsUntil(expiresAt));
  const expiredRef = useRef(false);
  const reducedMotion = useReducedMotion();
  const progress = useSharedValue(0);

  useEffect(() => {
    expiredRef.current = false;

    function update() {
      setRemaining(secondsUntil(expiresAt));
    }

    update();
    const interval = setInterval(update, 250);
    return () => clearInterval(interval);
  }, [expiresAt]);

  useEffect(() => {
    if (remaining !== 0 || expiredRef.current) return;
    expiredRef.current = true;
    onExpire?.();
  }, [onExpire, remaining]);

  useEffect(() => {
    const urgent = remaining > 0 && remaining <= 10;
    progress.set(0);

    if (!urgent || reducedMotion) {
      progress.set(urgent ? 1 : 0);
      return;
    }

    progress.set(
      withTiming(1, {
        duration: 700,
        easing: Easing.out(Easing.cubic),
      }),
    );
  }, [progress, reducedMotion, remaining]);

  const timerStyle = useAnimatedStyle(() => {
    const phase = progress.get();
    return {
      color: interpolateColor(phase, [0, 1], ['#2A2632', '#D74E5A']),
      transform: [
        {
          scale: reducedMotion
            ? 1
            : interpolate(phase, [0, 0.45, 1], [1, 1.055, 1]),
        },
        {
          translateX: reducedMotion
            ? 0
            : interpolate(phase, [0, 0.3, 0.6, 1], [0, -3, 3, 0]),
        },
      ],
    };
  });

  const formatted = formatTime(remaining);

  return (
    <LinearGradient
      accessibilityLabel={label + ': ' + formatted}
      accessibilityLiveRegion="polite"
      accessibilityRole="timer"
      colors={['#FFFFFF', '#FFF7F1']}
      style={styles.card}
    >
      <Text style={styles.eyebrow}>{label.toUpperCase()}</Text>
      <Animated.Text
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={[styles.timer, timerStyle]}
      >
        {formatted}
      </Animated.Text>
      <View style={styles.track}>
        <View
          style={[
            styles.fill,
            {
              width: `${Math.min(1, remaining / 60) * 100}%` as `${number}%`,
            },
          ]}
        />
      </View>
      <Text style={styles.caption}>
        {remaining === 0 ? 'Time is up' : 'Your session is being held'}
      </Text>
    </LinearGradient>
  );
}

const styles = StyleSheet.create({
  card: {
    alignItems: 'center',
    borderColor: '#FFFFFF',
    borderRadius: 28,
    borderWidth: 1,
    boxShadow: '0px 16px 40px rgba(30,25,45,0.13)',
    padding: 28,
  },
  eyebrow: { color: '#746D7B', fontSize: 11, letterSpacing: 1.4 },
  timer: {
    fontSize: 48,
    fontVariant: ['tabular-nums'],
    letterSpacing: -1.5,
    marginTop: 14,
  },
  track: {
    backgroundColor: '#E8E3EC',
    borderRadius: 3,
    height: 5,
    marginTop: 20,
    overflow: 'hidden',
    width: '100%',
  },
  fill: { backgroundColor: '#7B5CFF', height: '100%' },
  caption: { color: '#756E7C', fontSize: 13, marginTop: 12 },
});

Use an absolute deadline rather than recreating a relative duration on every render:

import { useMemo } from 'react';

export function ReservationScreen() {
  const expiresAt = useMemo(() => new Date(Date.now() + 5 * 60 * 1000), []);

  return (
    <AnimatedCountdown
      expiresAt={expiresAt}
      label="Reservation expires in"
      onExpire={() => releaseReservation()}
    />
  );
}

If the server supplies the expiration timestamp, parse that value once instead. For sensitive offers or reservations, server time and server-side validation remain authoritative; the client clock is presentation, not proof of eligibility.

Animate the final seconds without losing accuracy

Every time the displayed second changes during the last ten seconds, progress restarts from zero and times to one. Scale and horizontal movement are just interpolations of that one phase. The timer text still comes from the timestamp calculation, so delayed animation frames cannot change the deadline.

The urgency animation lasts 700 ms, leaving a quiet gap before the next second. Continuous flashing is harder to read and can create accessibility concerns. Color reinforces the state, but the number and “Time is up” message carry the actual meaning.

The example's progress bar treats 60 seconds as the visible window. In a reusable component, accept the original duration or omit the bar; never infer total progress from remaining time alone when the deadline may be extended.

Handle backgrounding and clock changes

Mobile operating systems throttle JavaScript timers in the background. That is expected. When the app resumes, the next 250 ms refresh recomputes from Date.now(), immediately correcting the display instead of replaying missed ticks.

For a screen that must refresh the instant it becomes active, subscribe to React Native AppState and call the same update function on transition to active. The timestamp model remains unchanged.

If a user changes the device clock, a client-only countdown changes too. For commerce or scarce inventory, calculate a server offset when data loads or refresh eligibility from the server at claim time. Never trust a visual countdown as an authorization boundary.

Changing expiresAt should represent a deliberate new deadline. The effect resets the one-shot expiration guard and immediately recalculates remaining time, so extensions and replacement offers work without remounting. Keep the Date object stable when its timestamp has not changed; repeatedly constructing an equivalent object causes unnecessary interval teardown and setup.

Accessibility

The wrapper exposes the timer role, a label, and the formatted value as one concise phrase. The animated text is hidden from the accessibility tree so the same time is not announced twice. A polite live region may announce changes, but once-per-second speech can be disruptive; test your target screen reader and consider announcing only meaningful milestones such as one minute, ten seconds, and expiration.

Tabular numbers prevent horizontal movement as digits change. Do not rely on red alone for urgency. The label, numerical time, and expiration copy provide redundant information.

useReducedMotion removes the pulse and shake. It still switches to the urgent color immediately, so no state is lost. If Haptics are later added, do not fire them every second; reserve one subtle event for a user-triggered action or a meaningful threshold and respect platform settings.

Performance

The interval runs four times per second only to keep the boundary accurate, but React state settles to the same integer for most ticks and React skips identical values. No animation frames are stored in state. The UI thread derives movement and color from one shared value.

You can schedule closer to the next exact second boundary for a large list of timers. For one visible countdown, the simple interval is clear and inexpensive. Always clear it on unmount.

Avoid mounting dozens of independent timers in a scrolling list. Share one current-time source at the screen level or render only visible items. Network requests and eligibility checks should occur at deliberate milestones, not on every tick.

Test around minute and hour boundaries, at zero, and with a deadline already in the past. Fake timers are useful for the formatting and callback logic, but also run one device test that backgrounds the app beyond expiry and returns to confirm the visible state corrects immediately.

Common pitfalls

  • Decrementing state once per interval. Delayed callbacks accumulate drift. Recalculate from an absolute expiration timestamp.
  • Calling onExpire on every zero tick. Guard the callback with a ref and reset the guard when the deadline changes.
  • Creating new Date(Date.now() + duration) during render. Every render moves the deadline forward, so the timer may never finish.
  • Assuming intervals run in the background. Mobile platforms throttle them. Recompute on the next tick or app activation.
  • Using the device countdown as authorization. Confirm offers and reservations on the server.
  • Announcing every animated layer. Expose one semantic timer value and hide duplicate visual text.
  • Animating every digit with React state. Keep semantic seconds in state and frame-level motion in a shared value.

Skip the build: production-ready version

The production-ready Countdown Offer Paywall combines an accurate expiry timer with polished urgency feedback, pricing hierarchy, benefit content, an animated claim action, color-scheme support, expiration callbacks, and a complete accessible summary. It is configurable and tested on iOS, Android, and web. For a non-time-based changing value, see the rolling animated counter guide.

FAQ

How do I make a countdown timer in React Native?

Store an absolute expiration timestamp and periodically calculate the remaining seconds from expiresAt.getTime() - Date.now(). Format that integer for display and clamp it at zero.

How do I prevent a React Native countdown from drifting?

Do not decrement a local counter on each interval. Treat the interval as a request to recalculate from wall-clock time, so late or skipped callbacks do not accumulate error.

Does setInterval keep running when a React Native app is backgrounded?

You should not rely on it. iOS and Android can throttle or pause JavaScript work, so recompute from the deadline after resume and let the server enforce any sensitive expiration.

How often should the countdown update?

For a seconds display, update the semantic value once per second or sample a few times per second around the boundary. Smooth subsecond animation belongs in Reanimated, not repeated React state updates.