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:
- 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 />
- Create
<Countdown />
component responsible to render 4 providing that the labels - Create a function that help us to scorporate
day
,hours
,minutes
andseconds
from a given future date - to mix all previous points
this demo uses css borders in some. So ```react
- { 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:
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>
);
};
Animation works thanks useSpring()
from @react-spring/web
.
const [style,api] = useSpring(
() => ({
from: { rotateX: "0deg" },
to: { rotateX: "180deg" },
delay: 0,
reset: true,
}),
[labelNumber]
);
This hook returns two fields:
- the first one
style
: This object is a basicstyle
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 callapi.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
andto
need to be of same type. SorotateX
needs to be the same type ofrotateX
insideto
. In this demo we use astring
value. Mixing integers (likerotateX: 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;
`;
and then pass styles
to <Content />
<Container>
<Card className="card">
<Content style={style}>
....
</Content>
</Card>
</Container>
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>
);
}
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>
);
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>
);
}
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,
};
}
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>
);
}
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:
Top comments (0)