Flip Countdown
Flip-clock countdown to any date, with days, hours, minutes and seconds.
--
Days
--
Hours
--
Minutes
--
Seconds
components/ui/flip-countdown.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
function Card({ value }: { value: string }) {
return (
<span className="relative inline-block h-16 w-12 overflow-hidden rounded-xl bg-zinc-900 text-4xl font-semibold tabular-nums text-white shadow-lg [perspective:300px] sm:h-20 sm:w-14 sm:text-5xl">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={value}
initial={{ rotateX: -90, opacity: 0 }}
animate={{ rotateX: 0, opacity: 1 }}
exit={{ rotateX: 90, opacity: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="absolute inset-0 grid origin-center place-items-center"
>
{value}
</motion.span>
</AnimatePresence>
<span aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-black/50" />
</span>
);
}
/** Flip-clock countdown to a date. Renders dashes until mounted so static HTML and the browser agree. */
export function FlipCountdown({ to }: { to: Date | number }) {
const target = typeof to === "number" ? to : to.getTime();
const [left, setLeft] = useState<number | null>(null);
useEffect(() => {
const tick = () => setLeft(Math.max(0, target - Date.now()));
tick();
const t = setInterval(tick, 1000);
return () => clearInterval(t);
}, [target]);
const s = left === null ? null : Math.floor(left / 1000);
const parts: [string, number | null][] = [
["Days", s === null ? null : Math.floor(s / 86400)],
["Hours", s === null ? null : Math.floor(s / 3600) % 24],
["Minutes", s === null ? null : Math.floor(s / 60) % 60],
["Seconds", s === null ? null : s % 60],
];
return (
<div className="flex items-start gap-3 sm:gap-5" role="timer" aria-live="off">
{parts.map(([label, v]) => {
const text = v === null ? "--" : String(v).padStart(2, "0");
return (
<div key={label} className="text-center">
<div className="flex gap-1">
<Card value={text[0]} />
<Card value={text[1]} />
</div>
<p className="mt-2 text-[11px] font-medium uppercase tracking-widest text-zinc-500">{label}</p>
</div>
);
})}
</div>
);
}
export default function Demo() {
// 3 days, 4 hours and a bit from when the page opens.
const [to] = useState(() => Date.now() + (3 * 86400 + 4 * 3600 + 125) * 1000);
return <FlipCountdown to={to} />;
}
Edit with AIPro
Describe a change to Flip Countdown and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/flip-countdown.tsxcomponents/ui/flip-countdown.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { useEffect, useState } from "react"; function Card({ value }: { value: string }) { return ( <span className="relative inline-block h-16 w-12 overflow-hidden rounded-xl bg-zinc-900 text-4xl font-semibold tabular-nums text-white shadow-lg [perspective:300px] sm:h-20 sm:w-14 sm:text-5xl"> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={value} initial={{ rotateX: -90, opacity: 0 }} animate={{ rotateX: 0, opacity: 1 }} exit={{ rotateX: 90, opacity: 0 }} transition={{ duration: 0.35, ease: "easeOut" }} className="absolute inset-0 grid origin-center place-items-center" > {value} </motion.span> </AnimatePresence> <span aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-black/50" /> </span> ); } /** Flip-clock countdown to a date. Renders dashes until mounted so static HTML and the browser agree. */ export function FlipCountdown({ to }: { to: Date | number }) { const target = typeof to === "number" ? to : to.getTime(); const [left, setLeft] = useState<number | null>(null); useEffect(() => { const tick = () => setLeft(Math.max(0, target - Date.now())); tick(); const t = setInterval(tick, 1000); return () => clearInterval(t); }, [target]); const s = left === null ? null : Math.floor(left / 1000); const parts: [string, number | null][] = [ ["Days", s === null ? null : Math.floor(s / 86400)], ["Hours", s === null ? null : Math.floor(s / 3600) % 24], ["Minutes", s === null ? null : Math.floor(s / 60) % 60], ["Seconds", s === null ? null : s % 60], ]; return ( <div className="flex items-start gap-3 sm:gap-5" role="timer" aria-live="off"> {parts.map(([label, v]) => { const text = v === null ? "--" : String(v).padStart(2, "0"); return ( <div key={label} className="text-center"> <div className="flex gap-1"> <Card value={text[0]} /> <Card value={text[1]} /> </div> <p className="mt-2 text-[11px] font-medium uppercase tracking-widest text-zinc-500">{label}</p> </div> ); })} </div> ); } export default function Demo() { // 3 days, 4 hours and a bit from when the page opens. const [to] = useState(() => Date.now() + (3 * 86400 + 4 * 3600 + 125) * 1000); return <FlipCountdown to={to} />; }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.