Browse the library
Components / Data
Countdown
FreeCountdown to an open house or launch with rolling flip digits for days, hours, minutes and seconds.
View as Markdownmotion
components/facade/countdown.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface CountdownProps {
/** Target date. Accepts a Date, ISO string or timestamp. Include a UTC offset in strings ("2026-11-14T10:00:00-05:00") so the event time is the same for every visitor. */
target?: Date | string | number;
/** Called once when the countdown reaches zero. */
onComplete?: () => void;
/** Labels for each unit. */
labels?: { days: string; hours: string; minutes: string; seconds: string };
/** Hide the seconds column. */
hideSeconds?: boolean;
/** Rendered when time is up. */
completeContent?: React.ReactNode;
size?: "sm" | "md" | "lg";
className?: string;
}
type Parts = { days: number; hours: number; minutes: number; seconds: number };
function getParts(ms: number): Parts {
const total = Math.max(0, Math.floor(ms / 1000));
return {
days: Math.floor(total / 86400),
hours: Math.floor((total % 86400) / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
};
}
// md and lg shrink below the sm breakpoint so four units plus separators fit a 360px viewport.
const sizes = {
sm: { box: "h-10 w-7 text-xl rounded-md", gap: "gap-1", label: "text-[10px]", sep: "text-lg", sepOffset: "mt-1" },
md: { box: "h-10 w-7 text-xl rounded-md sm:h-14 sm:w-10 sm:text-3xl sm:rounded-lg", gap: "gap-1", label: "text-[11px]", sep: "text-lg sm:text-2xl", sepOffset: "mt-1 sm:mt-2.5" },
lg: { box: "h-11 w-8 text-2xl rounded-md sm:h-20 sm:w-14 sm:text-5xl sm:rounded-xl", gap: "gap-1 sm:gap-1.5", label: "text-[11px] sm:text-xs", sep: "text-xl sm:text-4xl", sepOffset: "mt-1.5 sm:mt-4" },
};
function Digit({ value, size }: { value: string; size: keyof typeof sizes }) {
const reduce = useReducedMotion();
return (
<span
className={cn(
"relative inline-flex items-center justify-center overflow-hidden border border-border bg-card font-mono font-semibold tabular-nums text-card-foreground shadow-sm",
sizes[size].box,
)}
>
{/* split line for the flip-card look */}
<span className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-px bg-border/80" aria-hidden="true" />
<span className="pointer-events-none absolute inset-x-0 top-0 h-1/2 bg-foreground/[0.03]" aria-hidden="true" />
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={value}
initial={reduce ? { opacity: 0 } : { y: "-70%", rotateX: 75, opacity: 0, filter: "blur(2px)" }}
animate={{ y: "0%", rotateX: 0, opacity: 1, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { y: "70%", rotateX: -75, opacity: 0, filter: "blur(2px)" }}
transition={{ duration: reduce ? 0.15 : 0.45, ease: [0.22, 1, 0.36, 1] }}
className="inline-block"
style={{ transformPerspective: 300 }}
>
{value}
</motion.span>
</AnimatePresence>
</span>
);
}
function Unit({ value, label, size, pad }: { value: number; label: string; size: keyof typeof sizes; pad: number }) {
const str = String(value).padStart(pad, "0");
return (
<div className="flex flex-col items-center gap-2">
<div className={cn("flex", sizes[size].gap)}>
{str.split("").map((d, i) => (
<Digit key={`${str.length}-${i}`} value={d} size={size} />
))}
</div>
<span className={cn("font-medium uppercase tracking-[0.14em] text-muted-foreground", sizes[size].label)}>{label}</span>
</div>
);
}
export function Countdown({
target,
onComplete,
labels = { days: "Days", hours: "Hours", minutes: "Minutes", seconds: "Seconds" },
hideSeconds = false,
completeContent,
size = "md",
className,
}: CountdownProps) {
const targetMs = React.useMemo(() => {
if (target === undefined) return null;
return target instanceof Date ? target.getTime() : new Date(target).getTime();
}, [target]);
// Render only after mount to avoid hydration mismatch from Date.now().
const [now, setNow] = React.useState<number | null>(null);
const [fallbackTarget, setFallbackTarget] = React.useState<number | null>(null);
const completedRef = React.useRef(false);
const onCompleteRef = React.useRef(onComplete);
React.useEffect(() => {
onCompleteRef.current = onComplete;
});
React.useEffect(() => {
const start = Date.now();
if (targetMs === null) setFallbackTarget(start + ((3 * 24 + 7) * 3600 + 42 * 60 + 18) * 1000);
setNow(start);
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [targetMs]);
const end = targetMs ?? fallbackTarget;
const remaining = now !== null && end !== null ? end - now : null;
const done = remaining !== null && remaining <= 0;
React.useEffect(() => {
if (done && !completedRef.current) {
completedRef.current = true;
onCompleteRef.current?.();
}
if (!done) completedRef.current = false;
}, [done]);
const sep = (
<span className={cn("self-start font-mono font-light text-muted-foreground/60", sizes[size].sep, sizes[size].sepOffset)} aria-hidden="true">
:
</span>
);
// Units are grouped in pairs (days + hours, minutes + seconds). Each pair never breaks, but the
// pairs may wrap to a 2x2 layout on narrow screens. From `sm` up it is one row, as before.
const rootCls = "inline-flex flex-wrap items-start justify-center gap-x-5 gap-y-4 sm:flex-nowrap sm:gap-3";
const pairCls = "flex items-start gap-1.5 sm:gap-3";
if (remaining === null) {
// Placeholder skeleton with stable layout before mount.
const skeleton = (
<div className="flex flex-col items-center gap-2">
<div className={cn("flex", sizes[size].gap)}>
<span className={cn("animate-pulse border border-border bg-muted", sizes[size].box)} />
<span className={cn("animate-pulse border border-border bg-muted", sizes[size].box)} />
</div>
<span className={cn("invisible", sizes[size].label)}>00</span>
</div>
);
return (
<div className={cn(rootCls, className)} aria-hidden="true">
<div className={pairCls}>
{skeleton}
{sep}
{skeleton}
</div>
<span className="hidden sm:contents">{sep}</span>
<div className={pairCls}>
{skeleton}
{!hideSeconds && sep}
{!hideSeconds && skeleton}
</div>
</div>
);
}
if (done && completeContent) return <div className={className}>{completeContent}</div>;
const p = getParts(remaining);
const a11y = `${p.days} days, ${p.hours} hours, ${p.minutes} minutes${hideSeconds ? "" : `, ${p.seconds} seconds`} remaining`;
return (
<div className={cn(rootCls, className)} role="timer" aria-label={a11y}>
<div className={pairCls}>
<Unit value={p.days} label={labels.days} size={size} pad={2} />
{sep}
<Unit value={p.hours} label={labels.hours} size={size} pad={2} />
</div>
{/* Middle colon only when the four groups sit in one row. */}
<span className="hidden sm:contents">{sep}</span>
<div className={pairCls}>
<Unit value={p.minutes} label={labels.minutes} size={size} pad={2} />
{!hideSeconds && (
<>
{sep}
<Unit value={p.seconds} label={labels.seconds} size={size} pad={2} />
</>
)}
</div>
</div>
);
}
export default Countdown;app/page.tsx
"use client";
import * as React from "react";
import { Calendar, MapPin } from "lucide-react";
import { Countdown } from "@/components/facade/countdown";
export default function Demo() {
// Target is computed after mount so the preview always shows a live countdown.
const [target, setTarget] = React.useState<number | undefined>(undefined);
React.useEffect(() => {
setTarget(Date.now() + ((2 * 24 + 14) * 3600 + 27 * 60 + 45) * 1000);
}, []);
return (
<div className="w-full max-w-[560px] rounded-2xl border border-border bg-card p-6 text-center shadow-sm sm:p-8">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-brand">Grand opening open house</p>
<h3 className="mt-2 font-display text-2xl font-semibold tracking-tight text-foreground">The Residences at Old Town</h3>
<p className="mt-2 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Calendar className="size-4" aria-hidden="true" />
Saturday, 11 AM to 3 PM
</span>
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-4" aria-hidden="true" />
7135 E Main St, Scottsdale
</span>
</p>
<div className="mt-6 flex justify-center">
<Countdown
key={target === undefined ? "pending" : "live"}
target={target}
completeContent={<p className="font-display text-xl font-semibold text-foreground">Doors are open. Come on in.</p>}
/>
</div>
<a
href="#rsvp"
className="mt-7 inline-flex h-10 items-center justify-center rounded-xl bg-primary px-5 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
RSVP for a private preview
</a>
</div>
);
}Installation
$
npx shadcn@latest add https://facadeui.com/r/countdown.jsonUsage
import { Countdown } from "@/components/facade/countdown";
export default function Page() {
return <Countdown />;
}Props
CountdownProps is exported, so you can extend or wrap it. Every prop has a default; <Countdown /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| target | Date | string | number | Target date. Accepts a Date, ISO string or timestamp. Include a UTC offset in strings ("2026-11-14T10:00:00-05:00") so the event time is the same for every visitor. |
| onComplete | () => void | Called once when the countdown reaches zero. |
| labels | { days: string; hours: string; minutes: string; seconds: string } | Labels for each unit. |
| hideSeconds | boolean | Hide the seconds column. |
| completeContent | React.ReactNode | Rendered when time is up. |
| size | "sm" | "md" | "lg" | |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade countdown to this page and adapt the copy to our business.