Countdown Timer in React: Days, Hours, Flip Animation Variants
Build a React countdown timer with days, hours, minutes, seconds — plus flip card animations. Real code, zero dependencies beyond React 18.
The Core Hook: useCountdown
Before touching any DOM, get your time math right. Everything else is cosmetic. The hook below runs on a 1000ms interval and returns { days, hours, minutes, seconds } — the four values every countdown needs. It cleans up after itself, so you won't get memory leaks when the component unmounts.
import { useState, useEffect } from 'react';
function getTimeLeft(target: Date) {
const total = target.getTime() - Date.now();
if (total <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
return {
days: Math.floor(total / (1000 * 60 * 60 * 24)),
hours: Math.floor((total / (1000 * 60 * 60)) % 24),
minutes: Math.floor((total / 1000 / 60) % 60),
seconds: Math.floor((total / 1000) % 60),
};
}
export function useCountdown(target: Date) {
const [timeLeft, setTimeLeft] = useState(getTimeLeft(target));
useEffect(() => {
const id = setInterval(() => setTimeLeft(getTimeLeft(target)), 1000);
return () => clearInterval(id);
}, [target]);
return timeLeft;
}One thing people get wrong: passing new Date(...) as a prop and then putting it in useEffect's dependency array. That creates a new object reference every render, so your interval restarts constantly. Store the target in a useRef or useMemo, or just accept it as a stable primitive (timestamp number). In React 18 StrictMode you'll see double-mount behavior in dev — the cleanup function handles that correctly here.
Worth noting: Math.floor on the total is intentional. You want the counter to show the *current* second, not round up. If you use Math.round you'll briefly show the next higher value and then snap back, which looks like a bug even though the math is technically correct.
The Basic Display Component
Simple layout first, animation second. Here's a clean, unstyled component that renders four digit blocks. You can drop Tailwind classes on it directly, or layer any of the glassmorphism components on top for that frosted-card look that's been everywhere since 2022.
const pad = (n: number) => String(n).padStart(2, '0');
function CountdownUnit({ value, label }: { value: number; label: string }) {
return (
<div className="flex flex-col items-center gap-1">
<div className="text-5xl font-mono font-bold tabular-nums">
{pad(value)}
</div>
<span className="text-xs uppercase tracking-widest text-gray-400">
{label}
</span>
</div>
);
}
export function Countdown({ target }: { target: Date }) {
const { days, hours, minutes, seconds } = useCountdown(target);
return (
<div className="flex gap-6">
<CountdownUnit value={days} label="days" />
<CountdownUnit value={hours} label="hours" />
<CountdownUnit value={minutes} label="min" />
<CountdownUnit value={seconds} label="sec" />
</div>
);
}The tabular-nums font feature is one of those details that separates polished work from rushed work. Without it, the digits shift left and right as their widths change — 1 is narrower than 8 in most fonts — so the whole block jitters every second. One CSS class. Don't skip it.
Honestly, most products ship this exact pattern and call it done. It works. But if you're building a launch page or a sale timer, you probably want more visual drama — which is where the flip animation comes in.
Flip Card Animation: The CSS-Only Approach
The classic flip animation splits each digit block into a top half and a bottom half, then rotates them on change. No Framer Motion required. Here's the key CSS — you'll animate the bottom panel dropping down from rotateX(-180deg) to rotateX(0deg) when the digit updates.
.flip-card {
position: relative;
width: 64px;
height: 80px;
perspective: 200px;
}
.flip-card__top,
.flip-card__bottom {
position: absolute;
width: 100%;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.flip-card__top {
top: 0;
border-radius: 4px 4px 0 0;
background: #1a1a2e;
}
.flip-card__bottom {
bottom: 0;
border-radius: 0 0 4px 4px;
transform-origin: top center;
transform: rotateX(-180deg);
animation: flipDown 0.4s ease-in-out forwards;
}
@keyframes flipDown {
from { transform: rotateX(-180deg); }
to { transform: rotateX(0deg); }
}The trick is triggering a *fresh* animation when the digit changes. In React you can do this by changing a key prop on the animated element — React will unmount and remount it, which restarts the CSS animation from scratch. This is a lot simpler than managing animation-play-state or imperatively calling element.animate().
function FlipDigit({ value }: { value: number }) {
const display = String(value).padStart(2, '0');
return (
<div className="flip-card">
<div className="flip-card__top">{display}</div>
{/* key change = remount = animation restart */}
<div key={display} className="flip-card__bottom">{display}</div>
</div>
);
}That 0.4s duration is intentional — any faster and the flip looks like a glitch, any slower and it drags. Try cubic-bezier(0.455, 0.03, 0.515, 0.955) instead of ease-in-out if you want a more mechanical, clock-like snap at the end. Quick aside: the perspective: 200px on the parent is what gives the flip its depth. Drop it to 100px for a more aggressive curve, raise it to 400px for something subtler.
Framer Motion Variant: Slide and Fade
Not every project uses CSS animations directly. If you're already on Framer Motion (and you probably are in 2026), here's a cleaner variant that slides the old digit up and fades the new one in. It's a bit more readable than the CSS flip and handles interrupted animations gracefully.
import { AnimatePresence, motion } from 'framer-motion';
function AnimatedDigit({ value }: { value: number }) {
const display = String(value).padStart(2, '0');
return (
<div className="relative h-16 w-16 overflow-hidden">
<AnimatePresence mode="popLayout">
<motion.div
key={display}
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -40, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="absolute inset-0 flex items-center justify-center text-4xl font-mono font-bold"
>
{display}
</motion.div>
</AnimatePresence>
</div>
);
}The mode="popLayout" on AnimatePresence is key here — it removes the exiting element from the layout immediately, so the new digit doesn't push content around during the transition. Without it you'd see a brief double-height flash. In practice, popLayout is what you want any time you're animating list items or swapping single elements in place.
Look, you could also use the scale variant — animate from scale: 0.6 to scale: 1 with an opacity fade. That's what a lot of e-commerce sale timers do. It's more energetic and works well on mobile where the flip effect can look choppy at 60px wide.
If you want to push the visual design further, pair this with the gradient generator to build background gradients that shift colors as time runs out — red-orange when under 24 hours, for example. That kind of environmental cue genuinely improves user behavior on time-pressure pages.
Handling Edge Cases and Zero States
What happens when the countdown hits zero? You need a plan. The naive approach — just showing 00:00:00:00 — is fine for a product timer, but feels wrong on a launch page. Usually you want to swap in a 'Live now' or 'Sale ended' message.
export function SmartCountdown({ target, onExpire }: {
target: Date;
onExpire?: () => void;
}) {
const { days, hours, minutes, seconds } = useCountdown(target);
const isExpired = days === 0 && hours === 0 && minutes === 0 && seconds === 0;
useEffect(() => {
if (isExpired) onExpire?.();
}, [isExpired, onExpire]);
if (isExpired) {
return <div className="text-2xl font-bold text-green-400">We're live!</div>;
}
return <Countdown target={target} />;
}There's also the SSR problem. If you're on Next.js App Router and you render this component server-side, Date.now() on the server will differ from the client's first render, causing a hydration mismatch. The standard fix is to add 'use client' at the top of the file and let the whole component run client-side. Alternatively, pass suppressHydrationWarning on the digit elements, though that's more of a workaround than a fix.
One more thing — if your target date comes from a server (database, API), make sure you're comparing against a consistent timezone. new Date(isoString) in the browser always parses to local time, so a UTC timestamp from your API will be correct. A date-only string like '2026-08-01' will be interpreted as midnight UTC, which might not be what you want for a US-targeted sale.
For timezone-aware countdowns in production, consider date-fns-tz or Temporal (now widely supported as of 2025) rather than trying to wrangle native Date APIs. The Temporal.ZonedDateTime API makes time zone math dramatically less painful.
Styling Variants: Dark Cards, Neon, and Glass
The component logic is the same regardless of style — it's all about what you put in CountdownUnit. Three visual directions worth knowing: dark card (most common), neon/cyberpunk, and glass.
Dark card is basically bg-gray-900 rounded-lg p-4 with a mono font and a 1px border in border-gray-700. Boring, but it works everywhere and doesn't clash with anything. It's what you reach for when the timer is secondary to the content.
Neon pairs well with anything on the cyberpunk or vaporwave end of the spectrum. Add text-cyan-400 drop-shadow-[0_0_8px_rgba(34,211,238,0.8)] to the digit and a box-shadow: 0 0 20px rgba(34,211,238,0.3) on the card container. That 8px blur on the drop-shadow is the sweet spot — below that it's invisible, above 12px it bleeds too much on small screens.
Glass treatment uses backdrop-filter: blur(12px) with bg-white/10 border border-white/20 — basically the glassmorphism generator output applied to the digit card. It looks fantastic over a gradient or hero image but needs decent contrast between the digit color and the blurred background, so lean toward white or very light text.
In practice, glass timers on dark hero images are the highest-converting combination I've seen on launch pages. The frosted card draws the eye without screaming for attention. That said, test on mobile — backdrop-filter is GPU-intensive and can drop frames on mid-range Android devices if you stack more than 2-3 blurred elements on screen.
Putting It All Together: A Launch Page Timer
Here's the full composition you'd actually ship — the hook, animated digits, zero state, and a minimal layout. Drop this into your Next.js page and adjust the target date.
'use client';
import { useCountdown } from '@/hooks/useCountdown';
import { AnimatePresence, motion } from 'framer-motion';
const LAUNCH = new Date('2026-09-01T00:00:00Z');
function Digit({ value, label }: { value: number; label: string }) {
const display = String(value).padStart(2, '0');
return (
<div className="flex flex-col items-center gap-2">
<div className="relative h-20 w-20 overflow-hidden rounded-xl bg-white/10 backdrop-blur-md border border-white/20">
<AnimatePresence mode="popLayout">
<motion.span
key={display}
initial={{ y: 32, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -32, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className="absolute inset-0 flex items-center justify-center text-4xl font-mono font-bold text-white tabular-nums"
>
{display}
</motion.span>
</AnimatePresence>
</div>
<span className="text-xs uppercase tracking-widest text-white/50">{label}</span>
</div>
);
}
export default function LaunchTimer() {
const { days, hours, minutes, seconds } = useCountdown(LAUNCH);
const expired = !days && !hours && !minutes && !seconds;
if (expired) {
return (
<p className="text-3xl font-bold text-white">We're live — go explore!</p>
);
}
return (
<div className="flex gap-4 sm:gap-6">
<Digit value={days} label="days" />
<Digit value={hours} label="hours" />
<Digit value={minutes} label="min" />
<Digit value={seconds} label="sec" />
</div>
);
}That's a complete, production-ready timer. Glass styling, slide animation, zero state, SSR-safe with 'use client'. You'd want to add aria-live="polite" on the container for accessibility — screen readers don't need to announce every second, but announcing every minute is reasonable.
For more component inspiration and ready-to-use UI patterns — especially if you're building around a specific aesthetic — browse components to see what fits your stack. A countdown timer sitting inside a glassmorphism hero section is one of those combinations that just works.
FAQ
In the getTimeLeft function, check if (total <= 0) and return all zeros. The useCountdown hook above already does this — the interval keeps running but always returns zeros once the target passes.
Server and client run Date.now() at different times, so the initial values differ. Add 'use client' at the top of your timer component to skip SSR entirely — it's the cleanest fix for a purely interactive element.
Yes. The CSS-only flip variant in the third section uses only @keyframes and works with any styling system. Framer Motion adds smoother interrupted-animation handling but it's not required.
Pass an onExpire callback and fire it inside a useEffect that watches the isExpired boolean. The SmartCountdown wrapper in the edge-cases section shows the exact pattern.