Countdown
Data Display

Countdown

A countdown timer with per-unit digit rolls, large card and compact inline variants, a crossfaded completion state, and ticking that pauses in hidden tabs.

Install

npx shadcn@latest add @paragon/countdown

countdown.tsx

"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

/** One rolling digit: the old value falls out, the new drops in. */
function RollDigit({ ch, roll }: { ch: string; roll: boolean }) {
  return (
    <span
      className="relative inline-flex w-[1ch] justify-center overflow-hidden"
      aria-hidden
    >
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={ch}
          initial={roll ? { y: "-100%", opacity: 0.25 } : false}
          animate={{ y: "0%", opacity: 1 }}
          exit={
            roll
              ? {
                  y: "100%",
                  opacity: 0.25,
                  transition: { duration: 0.18, ease: EASE_EXIT },
                }
              : { opacity: 0, transition: { duration: 0 } }
          }
          transition={{ duration: 0.22, ease: EASE_OUT }}
          className="inline-block"
        >
          {ch}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function RollNumber({ text, roll }: { text: string; roll: boolean }) {
  return (
    <span className="inline-flex">
      {text.split("").map((ch, i) => (
        <RollDigit key={`${text.length}-${i}`} ch={ch} roll={roll} />
      ))}
    </span>
  );
}

export interface CountdownProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** The instant being counted down to. */
  target?: Date;
  /** Base "now". Pass a fixed date for deterministic renders; the
   * timer still ticks forward from it. */
  now?: Date;
  variant?: "large" | "compact";
  /** Shown when the countdown reaches zero. */
  completeLabel?: string;
  onComplete?: () => void;
  /** Disables digit-roll motion. */
  static?: boolean;
}

const pad2 = (n: number) => String(n).padStart(2, "0");

/**
 * A countdown with per-unit digit rolls — old digits fall out, new
 * ones drop in — in large card and compact inline variants. Ticking
 * pauses in hidden tabs and offscreen; hitting zero crossfades into a
 * completion state and fires `onComplete` once.
 */
export function Countdown({
  target,
  now,
  variant = "large",
  completeLabel = "Time's up",
  onComplete,
  static: isStatic = false,
  className,
  ...props
}: CountdownProps) {
  const reduced = useReducedMotion() ?? false;
  const rootRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { amount: 0.1 });

  const [fallbackNow] = React.useState(() => new Date());
  const base = now ?? fallbackNow;
  const baseTime = base.getTime();
  const targetTime = target
    ? target.getTime()
    : baseTime + 72 * 3600 * 1000;

  const [elapsed, setElapsed] = React.useState(0);
  const remaining = Math.max(0, targetTime - (baseTime + elapsed));
  const done = remaining === 0;

  React.useEffect(() => {
    if (!inView || done) return;
    const mountedAt = Date.now();
    const initial = elapsed; // eslint-disable-line react-hooks/exhaustive-deps
    const update = () => setElapsed(initial + (Date.now() - mountedAt));
    const id = setInterval(() => {
      if (!document.hidden) update();
    }, 1000);
    const onVisible = () => {
      if (!document.hidden) update();
    };
    document.addEventListener("visibilitychange", onVisible);
    return () => {
      clearInterval(id);
      document.removeEventListener("visibilitychange", onVisible);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [inView, done, baseTime, targetTime]);

  const completedRef = React.useRef(false);
  React.useEffect(() => {
    if (done && elapsed > 0 && !completedRef.current) {
      completedRef.current = true;
      onComplete?.();
    }
  }, [done, elapsed, onComplete]);

  const days = Math.floor(remaining / 86_400_000);
  const hours = Math.floor((remaining % 86_400_000) / 3_600_000);
  const minutes = Math.floor((remaining % 3_600_000) / 60_000);
  const seconds = Math.floor((remaining % 60_000) / 1000);
  const roll = !isStatic && !reduced;

  const summary = done
    ? completeLabel
    : `${days > 0 ? `${days} ${days === 1 ? "day" : "days"} ` : ""}${hours} hours ${minutes} minutes ${seconds} seconds remaining`;

  const units: { label: string; value: string }[] = [
    ...(days > 0 || variant === "large"
      ? [{ label: days === 1 ? "Day" : "Days", value: String(days).padStart(2, "0") }]
      : []),
    { label: "Hours", value: pad2(hours) },
    { label: variant === "large" ? "Minutes" : "Min", value: pad2(minutes) },
    { label: variant === "large" ? "Seconds" : "Sec", value: pad2(seconds) },
  ];

  return (
    <div
      ref={rootRef}
      role="timer"
      aria-label={summary}
      className={cn("w-fit", className)}
      {...props}
    >
      <AnimatePresence mode="popLayout" initial={false}>
        {done ? (
          <motion.div
            key="done"
            initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            transition={{ duration: 0.25, ease: EASE_OUT }}
            className={cn(
              "flex items-center gap-2",
              variant === "large" ? "h-[68px]" : "h-6",
            )}
          >
            <span
              className={cn(
                "flex items-center justify-center rounded-full bg-success text-success-foreground",
                variant === "large" ? "size-7" : "size-5",
              )}
            >
              <Check
                className={variant === "large" ? "size-4" : "size-3"}
                aria-hidden
              />
            </span>
            <span
              className={cn(
                "font-medium text-foreground",
                variant === "large" ? "text-lg" : "text-sm",
              )}
            >
              {completeLabel}
            </span>
          </motion.div>
        ) : variant === "large" ? (
          <motion.div
            key="timer"
            exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.15, ease: EASE_EXIT } }}
            className="flex items-start gap-1.5"
            aria-hidden
          >
            {units.map((u, i) => (
              <React.Fragment key={u.label}>
                {i > 0 && (
                  <span className="pt-[13px] text-xl font-semibold text-muted-foreground/40 select-none">
                    :
                  </span>
                )}
                <span className="flex w-[3.75rem] flex-col items-center gap-1 rounded-lg bg-secondary/60 px-2 pt-2 pb-1.5 dark:bg-secondary/40">
                  <span className="text-2xl leading-8 font-semibold text-foreground tabular-nums">
                    <RollNumber text={u.value} roll={roll} />
                  </span>
                  <span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
                    {u.label}
                  </span>
                </span>
              </React.Fragment>
            ))}
          </motion.div>
        ) : (
          <motion.div
            key="timer"
            exit={{ opacity: 0, filter: "blur(4px)", transition: { duration: 0.15, ease: EASE_EXIT } }}
            className="flex h-6 items-baseline gap-1 text-sm font-medium text-foreground tabular-nums"
            aria-hidden
          >
            {units.map((u, i) => (
              <span key={u.label} className="inline-flex items-baseline">
                <RollNumber text={u.value} roll={roll} />
                <span className="ml-px text-[11px] font-normal text-muted-foreground">
                  {u.label.charAt(0).toLowerCase()}
                </span>
                {i < units.length - 1 && <span className="w-1" />}
              </span>
            ))}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}