DEV Community

Diego Tonini
Diego Tonini

Posted on

Animated countdown in react with @react-spring/web

Today I want to show steps to develop an animated countdown in react.

Demo: https://multivoltage.github.io/react-animated-countdown/
Source https://github.com/multivoltage/react-animated-countdown

Tool used:

  • vite for fast development
  • @react-spring/web for animation part
  • date-fns for operations with dates.
  • styled-components for styles elements (no mandatory since basic css-in-js can be used)

Separation of goals:
In my experience I learnt that to achive a problem probably I can achive this goal separating into smaller ones. Since we want to "create a count-down which render remaining time starting from a date in the future" we can found 3 main goal:

  1. Create an abstract <Box /> component responsible only to render a label inside and animate itself each time this label change. In this demo we can find 4 <Box />
  2. Create <Countdown /> component responsible to render 4 providing that the labels
  3. Create a function that help us to scorporate day,hours,minutes and seconds from a given future date
  4. to mix all previous points

this demo uses css borders in some. So

* {
  box-sizing: border-box!important;
}


is used into index.css


Create an abstract <Box />

As I said before we want to built a reusable component. This component needs only two props:

  • labelPeriod (string): we'll pass "day" "hours" etc.. but we can also pass "day ramaining" or "hours remaining" or also "days of waiting"
  • labelNumber (number): this will be a value coming from the helper function. Each time this value changes, animation starts.

Since we want an animation where current number will be covered and next number will be show, we need to create two different faces on this component and use a very cool css utility called transform-style: preserve-3d. Thanks to this property combined with transform: rotateX(180deg) we can have 2 adiacent faces where the backface is rotated and covered by the main face.

Full final code for is available on github but here I show up my first implementation before:

basic box

export const Box: React.FC<Props> = ({ labelNumber, labelPeriod }) => {
  return (
    <Container>
      <Card className="card">
        <Content>
          <ContentFront>
            <span>{labelNumber}</span>
            <LabelPeriod>{labelPeriod}</LabelPeriod>
          </ContentFront>
          <ContentBack>
            <span>{labelNumber}</span>
            <LabelPeriod>{labelPeriod}</LabelPeriod>
          </ContentBack>
        </Content>
      </Card>
    </Container>
  );
};

Enter fullscreen mode Exit fullscreen mode

Animation works thanks useSpring() from @react-spring/web.

  const [style,api] = useSpring(
    () => ({
      from: { rotateX: "0deg" },
      to: { rotateX: "180deg" },
      delay: 0,
      reset: true,
    }),
    [labelNumber]
  );
Enter fullscreen mode Exit fullscreen mode

This hook returns two fields:

  • the first one style: This object is a basic style object and we can pass that to <animated.div /> element (provided by @react-spring/web).
  • the second one api: Not used in this demo, but the hook returns also an object where you can call api.start() in imperative mode.

Like normal hooks, we add [labelNumber] as dependency. In this way the animation depends on labelNumber. So when labelNumber changes, the animation will start again.

from and to need to be of same type. So rotateX needs to be the same type of rotateX inside to. In this demo we use a string value. Mixing integers (like rotateX: 0) and string does not work.

Magic happends when we pass style into animated.div. Since we use styled-components to add basic style we need to create a component like this:

import styled from "styled-components";
import { animated } from "@react-spring/web";

const Content = styled(animated.div)`
  position: absolute;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  perspective: 500px;
`;
Enter fullscreen mode Exit fullscreen mode

and then pass styles to <Content />

    <Container>
      <Card className="card">
        <Content style={style}>
          ....
        </Content>
      </Card>
    </Container>
Enter fullscreen mode Exit fullscreen mode

So after that if we setup a basic container like that:

import useTimer from "./hooks/useTimer";
import { Box } from "./components/card2";

export default function App() {
  // use timer start a timer
  // seconds will be update each 1000ms
  const { seconds } = useTimer(1000, () => {});

  return (
    <main>
      <div
        style={{
          width: 400,
        }}
      >
        <Box labelPeriod="day" labelNumber={seconds} />
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

we have our first animation
animation with bug

This is pretty cool but we have a small bug. Animation starts correctly when label changes but since the react render is fast, user can see the new number even if animation is not ended. That happends because inside <ContentBack /> we render same value of <ContentFront />. We can avoid this behavior using a custom hook called usePrevious(). This hooks (source code here) return us the previous value of a given value. You can use different implementation like this one.

Adding usePrevious() we edit code and pass it to <ContentBack />:

  const previous = usePrevious(labelNumber);

  return (
    <Container>
      <Card className="card">
        <Content style={props}>
          <ContentFront>
            <span>{labelNumber}</span>
            <LabelPeriod>{labelPeriod}</LabelPeriod>
          </ContentFront>
          <ContentBack>
            <span>{previous}</span>
            <LabelPeriod>{labelPeriod}</LabelPeriod>
          </ContentBack>
        </Content>
      </Card>
    </Container>
  );
Enter fullscreen mode Exit fullscreen mode

The result will be the same of demo link (but only for one single <Box />).

Create <Countdown /> component

This is only a basic wrapper. Since we have day, hours, minutes and seconds we setup a component like this:

export default function Countdown({ startDate, endDate }: Props) {
  // We will discuss this works later
  const { days, hours, minutes, seconds } = getRemamingRangeTimes(
    startDate,
    endDate
  );

  return (
    <Container>
      <Box labelNumber={days} labelPeriod="days" />
      <Box labelNumber={hours} labelPeriod="hours" />
      <Box labelNumber={minutes} labelPeriod="minutes" />
      <Box labelNumber={seconds} labelPeriod="seconds" />
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

Create a function that help us to scorporate

Now we try to get days hours etc from a future Date thanks to a library called date-dns. Probably we can do same calculation with window.Date but a lot of work is necessary.

import {
  addDays,
  differenceInDays,
  differenceInHours,
  differenceInMinutes,
  differenceInSeconds,
} from "date-fns";

export function generateFutureDay() {
  const future = addDays(new Date(), 10);
  return future;
}

export function getRemamingRangeTimes(startDate: Date, endDate: Date) {
  const days = Math.max(0, differenceInDays(endDate, startDate));
  const hours = Math.max(0, differenceInHours(endDate, startDate) % 24);
  const minutes = Math.max(0, differenceInMinutes(endDate, startDate) % 60);
  const seconds = Math.max(0, differenceInSeconds(endDate, startDate) % 60);

  return {
    days,
    hours,
    minutes,
    seconds,
  };
}
Enter fullscreen mode Exit fullscreen mode

getRemamingRangeTimes function is the core. We use Math.max only because if for some reasons we pass as endDate a date in the past we do not want that countdown renders negative number :).

Since differenceInMinutes , differenceInHours and differenceInSeconds return the same time we use % 24 to calculate the the remaining hours only for the last day. Same logic for minutes.


Mix all previous points

Since we want to trigger animation each seconds, we need to "generate" labelNumbers based on Date.now(). So we can write:

import { generateFutureDay } from "./utils";
import useTimer from "./hooks/useTimer";
import { useRef, useState } from "react";
import Countdown from "./components/countdown";

export default function App() {
  const futureDate = useRef(generateFutureDay());
  const [now, setNow] = useState(new Date());

  const {} = useTimer(1000, () => {
    // refresh now
    setNow(new Date());
  });

  return (
    <main>
      <Countdown startDate={now} endDate={futureDate.current} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Probably my implementation can be better or maybe web offert a ready-to-use solution. I started this tutorial after worked on a real production product havy inspired by this implementation.

We should arrive to a result like that:

final result

Top comments (0)