The component takes two properties: endsAt
is a timestamp for where the countdown should end, and precision could be one of four units: days, hours, minutes, and seconds. By default, we'll show all four units.
import React from "react"
import { useRhythmicRerender } from "lib/ui/hooks/useRhythmicRerender"
import {
millisecondsInHour,
millisecondsInMinute,
millisecondsInSecond,
} from "date-fns"
import { HStack, VStack } from "lib/ui/Stack"
import { CountdownPart } from "./CountdownPart"
import { Text } from "lib/ui/Text"
import { capitalizeFirstLetter } from "lib/shared/utils/capitalizeFirstLetter"
const countdownUnits = ["days", "hours", "minutes", "seconds"] as const
type CountdownUnit = (typeof countdownUnits)[number]
const msInUnit: Record<CountdownUnit, number> = {
days: millisecondsInHour * 24,
hours: millisecondsInHour,
minutes: millisecondsInMinute,
seconds: millisecondsInSecond,
}
interface Props {
endsAt: number
precision?: CountdownUnit
}
const formatDuration = (durationInMs: number, units: CountdownUnit[]) => {
const duration = {} as Record<CountdownUnit, number>
units.reduce((msLeft, unit, index) => {
const msInCurrentUnit = msInUnit[unit]
const isLast = index === units.length - 1
const roundFunction = isLast ? Math.round : Math.floor
const period = roundFunction(msLeft / msInCurrentUnit)
duration[unit] = period
return msLeft - period * msInCurrentUnit
}, durationInMs)
return duration
}
export const Countdown = ({ endsAt, precision = "seconds" }: Props) => {
useRhythmicRerender()
const now = Date.now()
const unitsToShow = countdownUnits.slice(
0,
countdownUnits.indexOf(precision) + 1
)
const duration = formatDuration(Math.max(endsAt - now, 0), unitsToShow)
return (
<HStack gap={24}>
{unitsToShow.map((unit) => {
return (
<VStack alignItems="center" key={precision} gap={16}>
<CountdownPart value={duration[unit] || 0} />
<Text size={14}>{capitalizeFirstLetter(unit)}</Text>
</VStack>
)
})}
</HStack>
)
}
To force a rerender every second, we use the useRhytmicRerender
function. It has an interval in the use effect hook updating state with a current timestamp every period.
import { useEffect, useState } from "react"
export const useRhythmicRerender = (durationInMs = 1000) => {
const [time, setTime] = useState<number>()
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), durationInMs)
return () => clearInterval(interval)
}, [setTime, durationInMs])
return time
}
Based on the precision parameter, we create a list of units to display. Then we take this list with the duration in milliseconds and pass it to the formatDuration
function that returns a record of units name with a number that we can take and display to the user. To construct this object, we go over every unit, take milliseconds, and convert it to a number for display. In the end, we subtract that amount from the msLeft.
Once we have all the numbers for the countdown, we iterate over units and display its part. Here we have a container with centered content where we have animated numbers. To achieve this sliding effect, we need to know the previous value, and here where's this hook comes in handy. We render every digit separately to animate only a changed part. Since SlidingCharacter
is an absolute position element, we also display a digit that is not visible to the user to allocate space.
import styled, { keyframes, css } from "styled-components"
import { Text } from "lib/ui/Text"
const getAnimation = (id: string) => keyframes`
0% {
--id: ${id};
top: 0%;
}
`
interface Props {
animationId?: string
}
export const SlidingCharacter = styled(Text)<Props>`
position: absolute;
top: -100%;
${({ animationId }) =>
animationId &&
css`
animation: ${getAnimation(animationId)} 640ms ease-in-out;
`}
`
Keyframes won't animate anything for no reason, so we need to trigger them by forcing a change. To achieve that, we can define a dumb variable that will change every time there is a need to animate a sliding character. We always render both the previous and the current digits, but the previous one won't be visible on the animation finish.