Animated Countdown

A highly customizable countdown timer with per-character staggered animation

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated

Copy and paste the following code into your project. component/micro-interactions/countdown.tsx

import React, { useEffect, useState } from "react";import { View, Text, StyleSheet } from "react-native";import Animated, {  LinearTransition,  withDelay,  withSpring,  withTiming,} from "react-native-reanimated";import type {  AnimationConfig,  CharacterAnimationParams,  TextAnimationProps,  CharacterProps,  TimeRemaining,  CountdownTimerProps,} from "./types";import {  SIZE_PRESETS,  DEFAULT_ANIMATION_CONFIG,  ENTER_FINAL,  EXIT_FINAL,  ENTER_INITIAL,  EXIT_INITIAL,} from "./conf";const mergeDeep = <T extends Record<string, any>>(  target: T,  source: Partial<T>,): T => {  const output = { ...target };  for (const key in source) {    if (      source[key] &&      typeof source[key] === "object" &&      !Array.isArray(source[key])    ) {      output[key] = mergeDeep(        output[key] as Record<string, any>,        source[key] as Record<string, any>,      ) as T[Extract<keyof T, string>];    } else if (source[key] !== undefined) {      output[key] = source[key] as T[Extract<keyof T, string>];    }  }  return output;};const Character: React.FC<CharacterProps> = ({  char,  style,  index,  animationConfig,  enterInitial,  enterFinal,  exitInitial,  exitFinal,}) => {  const animationDelay = (index + 1) * animationConfig.characterDelay;  const enteringAnimation = () => {    "worklet";    const springConfig = animationConfig.spring;    const timingConfig = {      duration: animationConfig.characterEnterDuration,    };    return {      initialValues: {        opacity: enterInitial.opacity,        transform: [          { translateY: enterInitial.translateY },          { scale: enterInitial.scale },        ],      },      animations: {        opacity: withDelay(          animationDelay,          withTiming(enterFinal.opacity, timingConfig),        ),        transform: [          {            translateY: withDelay(              animationDelay,              withSpring(enterFinal.translateY, springConfig),            ),          },          {            scale: withDelay(              animationDelay,              withSpring(enterFinal.scale, springConfig),            ),          },        ],      },    };  };  const exitingAnimation = () => {    "worklet";    const timingConfig = {      duration: animationConfig.characterExitDuration,    };    return {      initialValues: {        opacity: exitInitial.opacity,        transform: [          { translateY: exitInitial.translateY },          { scale: exitInitial.scale },        ],      },      animations: {        opacity: withDelay(          animationDelay,          withTiming(exitFinal.opacity, timingConfig),        ),        transform: [          {            translateY: withDelay(              animationDelay,              withTiming(exitFinal.translateY, timingConfig),            ),          },          {            scale: withDelay(              animationDelay,              withTiming(exitFinal.scale, timingConfig),            ),          },        ],      },    };  };  return (    <Animated.Text      entering={enteringAnimation}      exiting={exitingAnimation}      layout={LinearTransition.duration(180).easing(        animationConfig.timing.easing!,      )}      style={[style]}    >      {char}    </Animated.Text>  );};const StaggeredText: React.FC<  TextAnimationProps & {    readonly animationConfig: AnimationConfig;    readonly enterInitial: CharacterAnimationParams;    readonly enterFinal: CharacterAnimationParams;    readonly exitInitial: CharacterAnimationParams;    readonly exitFinal: CharacterAnimationParams;  }> = ({  text,  style,  animationConfig,  enterInitial,  enterFinal,  exitInitial,  exitFinal,}) => {  const characters = Array.from(text);  return (    <Animated.View      style={styles.textWrapper}      layout={LinearTransition.duration(        animationConfig.buttonTransitionDuration,      ).easing(animationConfig.timing.easing!)}    >      {characters.map((char, index) => (        <Character          key={`${char}-${index}`}          char={char}          style={style}          index={index}          animationConfig={animationConfig}          enterInitial={enterInitial}          enterFinal={enterFinal}          exitInitial={exitInitial}          exitFinal={exitFinal}        />      ))}    </Animated.View>  );};export const CountdownTimer: React.FC<CountdownTimerProps> = ({  targetDate,  size = "medium",  customization = {},}: CountdownTimerProps) => {  const [timeRemaining, setTimeRemaining] = useState<TimeRemaining>({    years: 0,    months: 0,    days: 0,    hours: 0,    minutes: 0,    seconds: 0,  });  const [isFinished, setIsFinished] = useState(false);  const onFinishRef = React.useRef(customization.onFinish);  useEffect(() => {    onFinishRef.current = customization.onFinish;  }, [customization.onFinish]);  const preset = SIZE_PRESETS[size];  const styling = {    numberSize: customization.numberSize ?? preset.numberSize,    labelSize: customization.labelSize ?? preset.labelSize,    numberColor: customization.numberColor ?? "#ffffff",    labelColor: customization.labelColor ?? "#666666",    separatorColor: customization.separatorColor ?? "#ffffff",    gap: customization.gap ?? preset.gap,    letterSpacing: customization.letterSpacing ?? 2,    fontWeight: customization.fontWeight ?? "700",    separatorMargin: preset.separatorMargin,    showLabels: customization.showLabels ?? true,    showDays: customization.showDays ?? true,    showSeparators: customization.showSeparators ?? true,    finishText: customization.finishText ?? "Time's Up!",  };  useEffect(() => {    const calculateTimeRemaining = () => {      const now = new Date().getTime();      const target = targetDate.getTime();      const difference = target - now;      if (difference > 0) {        const years = Math.floor(difference / (1000 * 60 * 60 * 24 * 365));        const months = Math.floor(          (difference % (1000 * 60 * 60 * 24 * 365)) /            (1000 * 60 * 60 * 24 * 30),        );        const days = Math.floor(          (difference % (1000 * 60 * 60 * 24 * 30)) / (1000 * 60 * 60 * 24),        );        const hours = Math.floor(          (difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),        );        const minutes = Math.floor(          (difference % (1000 * 60 * 60)) / (1000 * 60),        );        const seconds = Math.floor((difference % (1000 * 60)) / 1000);        setTimeRemaining({ years, months, days, hours, minutes, seconds });      } else {        setTimeRemaining({          years: 0,          months: 0,          days: 0,          hours: 0,          minutes: 0,          seconds: 0,        });        if (!isFinished) {          setIsFinished(true);          onFinishRef.current?.();        }      }    };    calculateTimeRemaining();    const interval = setInterval(calculateTimeRemaining, 1000);    return () => clearInterval(interval);  }, [targetDate, isFinished]);  const formatNumber = (num: number): string => {    return num.toString().padStart(2, "0");  };  const shouldShowYears = timeRemaining.years > 0;  const shouldShowMonths = timeRemaining.years > 0 || timeRemaining.months > 0;  const shouldShowDays =    styling.showDays &&    (timeRemaining.years > 0 ||      timeRemaining.months > 0 ||      timeRemaining.days > 0);  const shouldShowHours =    timeRemaining.years > 0 ||    timeRemaining.months > 0 ||    timeRemaining.days > 0 ||    timeRemaining.hours > 0;  const shouldShowMinutes =    timeRemaining.years > 0 ||    timeRemaining.months > 0 ||    timeRemaining.days > 0 ||    timeRemaining.hours > 0 ||    timeRemaining.minutes > 0;  return (    <View style={[styles.countdownWrapper, { gap: styling.gap }]}>      {isFinished ? (        <StaggeredText          text={styling.finishText}          style={{            fontSize: styling.numberSize,            fontWeight: styling.fontWeight,            color: styling.numberColor,            letterSpacing: styling.letterSpacing,          }}          animationConfig={DEFAULT_ANIMATION_CONFIG}          enterInitial={ENTER_INITIAL}          enterFinal={ENTER_FINAL}          exitInitial={EXIT_INITIAL}          exitFinal={EXIT_FINAL}        />      ) : (        <>          {shouldShowYears && (            <>              <View style={styles.unitContainer}>                <StaggeredText                  text={formatNumber(timeRemaining.years)}                  style={{                    fontSize: styling.numberSize,                    fontWeight: styling.fontWeight,                    color: styling.numberColor,                    letterSpacing: styling.letterSpacing,                  }}                  animationConfig={DEFAULT_ANIMATION_CONFIG}                  enterInitial={ENTER_INITIAL}                  enterFinal={ENTER_FINAL}                  exitInitial={EXIT_INITIAL}                  exitFinal={EXIT_FINAL}                />                {styling.showLabels && (                  <Text                    style={[                      styles.labelText,                      {                        fontSize: styling.labelSize,                        color: styling.labelColor,                      },                    ]}                  >                    YEARS                  </Text>                )}              </View>              {styling.showSeparators && (                <Text                  style={[                    styles.separator,                    {                      fontSize: styling.numberSize,                      color: styling.separatorColor,                      marginHorizontal: styling.separatorMargin,                    },                  ]}                >                  :                </Text>              )}            </>          )}          {shouldShowMonths && (            <>              <View style={styles.unitContainer}>                <StaggeredText                  text={formatNumber(timeRemaining.months)}                  style={{                    fontSize: styling.numberSize,                    fontWeight: styling.fontWeight,                    color: styling.numberColor,                    letterSpacing: styling.letterSpacing,                  }}                  animationConfig={DEFAULT_ANIMATION_CONFIG}                  enterInitial={ENTER_INITIAL}                  enterFinal={ENTER_FINAL}                  exitInitial={EXIT_INITIAL}                  exitFinal={EXIT_FINAL}                />                {styling.showLabels && (                  <Text                    style={[                      styles.labelText,                      {                        fontSize: styling.labelSize,                        color: styling.labelColor,                      },                    ]}                  >                    MONTHS                  </Text>                )}              </View>              {styling.showSeparators && (                <Text                  style={[                    styles.separator,                    {                      fontSize: styling.numberSize,                      color: styling.separatorColor,                      marginHorizontal: styling.separatorMargin,                    },                  ]}                >                  :                </Text>              )}            </>          )}          {shouldShowDays && (            <>              <View style={styles.unitContainer}>                <StaggeredText                  text={formatNumber(timeRemaining.days)}                  style={{                    fontSize: styling.numberSize,                    fontWeight: styling.fontWeight,                    color: styling.numberColor,                    letterSpacing: styling.letterSpacing,                  }}                  animationConfig={DEFAULT_ANIMATION_CONFIG}                  enterInitial={ENTER_INITIAL}                  enterFinal={ENTER_FINAL}                  exitInitial={EXIT_INITIAL}                  exitFinal={EXIT_FINAL}                />                {styling.showLabels && (                  <Text                    style={[                      styles.labelText,                      {                        fontSize: styling.labelSize,                        color: styling.labelColor,                      },                    ]}                  >                    DAYS                  </Text>                )}              </View>              {styling.showSeparators && (                <Text                  style={[                    styles.separator,                    {                      fontSize: styling.numberSize,                      color: styling.separatorColor,                      marginHorizontal: styling.separatorMargin,                    },                  ]}                >                  :                </Text>              )}            </>          )}          {shouldShowHours && (            <>              <View style={styles.unitContainer}>                <StaggeredText                  text={formatNumber(timeRemaining.hours)}                  style={{                    fontSize: styling.numberSize,                    fontWeight: styling.fontWeight,                    color: styling.numberColor,                    letterSpacing: styling.letterSpacing,                  }}                  animationConfig={DEFAULT_ANIMATION_CONFIG}                  enterInitial={ENTER_INITIAL}                  enterFinal={ENTER_FINAL}                  exitInitial={EXIT_INITIAL}                  exitFinal={EXIT_FINAL}                />                {styling.showLabels && (                  <Text                    style={[                      styles.labelText,                      {                        fontSize: styling.labelSize,                        color: styling.labelColor,                      },                    ]}                  >                    HOURS                  </Text>                )}              </View>              {styling.showSeparators && (                <Text                  style={[                    styles.separator,                    {                      fontSize: styling.numberSize,                      color: styling.separatorColor,                      marginHorizontal: styling.separatorMargin,                    },                  ]}                >                  :                </Text>              )}            </>          )}          {shouldShowMinutes && (            <>              <View style={styles.unitContainer}>                <StaggeredText                  text={formatNumber(timeRemaining.minutes)}                  style={{                    fontSize: styling.numberSize,                    fontWeight: styling.fontWeight,                    color: styling.numberColor,                    letterSpacing: styling.letterSpacing,                  }}                  animationConfig={DEFAULT_ANIMATION_CONFIG}                  enterInitial={ENTER_INITIAL}                  enterFinal={ENTER_FINAL}                  exitInitial={EXIT_INITIAL}                  exitFinal={EXIT_FINAL}                />                {styling.showLabels && (                  <Text                    style={[                      styles.labelText,                      {                        fontSize: styling.labelSize,                        color: styling.labelColor,                      },                    ]}                  >                    MINS                  </Text>                )}              </View>              {styling.showSeparators && (                <Text                  style={[                    styles.separator,                    {                      fontSize: styling.numberSize,                      color: styling.separatorColor,                      marginHorizontal: styling.separatorMargin,                    },                  ]}                >                  :                </Text>              )}            </>          )}          <View style={styles.unitContainer}>            <StaggeredText              text={formatNumber(timeRemaining.seconds)}              style={{                fontSize: styling.numberSize,                fontWeight: styling.fontWeight,                color: styling.numberColor,                letterSpacing: styling.letterSpacing,              }}              animationConfig={DEFAULT_ANIMATION_CONFIG}              enterInitial={ENTER_INITIAL}              enterFinal={ENTER_FINAL}              exitInitial={EXIT_INITIAL}              exitFinal={EXIT_FINAL}            />            {styling.showLabels && (              <Text                style={[                  styles.labelText,                  {                    fontSize: styling.labelSize,                    color: styling.labelColor,                  },                ]}              >                SECS              </Text>            )}          </View>        </>      )}    </View>  );};const styles = StyleSheet.create({  countdownWrapper: {    flexDirection: "row",    alignItems: "center",    justifyContent: "center",  },  unitContainer: {    alignItems: "center",    justifyContent: "center",  },  textWrapper: {    flexDirection: "row",    overflow: "hidden",  },  labelText: {    fontWeight: "600",    letterSpacing: 1.5,    marginTop: 8,  },  separator: {    fontWeight: "700",  },});

Usage

import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { CountdownTimer } from "@/components/micro-interactions/countdown";import { Ionicons } from "@expo/vector-icons";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"),  });  const launchDate = new Date("2026-07-20T14:30:00");  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.content}>        <View style={styles.iconBox}>          <Ionicons name="rocket" size={28} color="#fff" />        </View>        <Text          style={[styles.label, fontLoaded && { fontFamily: "SfProRounded" }]}        >          Launching in        </Text>        <CountdownTimer          targetDate={launchDate}          customization={{            numberColor: "#fff",            labelColor: "#444",            separatorColor: "#333",            showLabels: true,            showSeparators: true,            showDays: false,            numberSize: 30,            fontFamily: fontLoaded ? "SfProRounded" : undefined,          }}        />      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  content: {    alignItems: "center",    gap: 24,    marginTop: 100,  },  iconBox: {    width: 64,    height: 64,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",    marginBottom: 8,  },  label: {    fontSize: 14,    color: "#555",    textTransform: "uppercase",    letterSpacing: 2,  },  date: {    fontSize: 15,    color: "#333",    marginTop: 8,  },});

Props

CountdownCustomization

React Native Reanimated