shadimage
nexus-labs
componentanimated-countdownsource: ok · hit

Animated Countdown

Flip-style countdown timer with four variants — modern, digital, minimal, and classic. Smooth number transitions powered by Framer Motion.

npx shadcn add "https://nexus-ui.com/r/animated-countdown.json"
free

Visual style

minimal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion

Registry dependencies

https://www.nexus-ui.com/api/registry/nexus-fontutils

Files3 · source cached 10/9/2026, 4:12:38 PM

next-ui/animated-countdown/animated-countdown.tsx
"use client";

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

type CountdownUnitKey = "days" | "hours" | "minutes" | "seconds";
type CountdownVariant = "modern" | "digital" | "minimal" | "classic";
type CountdownSize = "sm" | "md" | "lg";

type TimeLeft = Record<CountdownUnitKey, number>;

export type AnimatedCountdownProps = {
  targetDate?: Date | string | number;
  variant?: CountdownVariant;
  showDays?: boolean;
  showHours?: boolean;
  showMinutes?: boolean;
  showSeconds?: boolean;
  unitOrder?: CountdownUnitKey[];
  backgroundColor?: string;
  accentColor?: string;
  className?: string;
  containerClassName?: string;
  unitClassName?: string;
  accentClassName?: string;
  labelClassName?: string;
  numberClassName?: string;
  separator?: React.ReactNode;
  showSeparators?: boolean;
  completionMessage?: string;
  onComplete?: () => void;
  staticMode?: boolean;
  initialStaticTime?: Partial<TimeLeft>;
  compact?: boolean;
  size?: CountdownSize;
  ariaLabel?: string;
};

const DEFAULT_STATIC_TIME: TimeLeft = {
  days: 7,
  hours: 12,
  minutes: 45,
  seconds: 30,
};

const UNIT_LABELS: Record<CountdownUnitKey, string> = {
  days: "Days",
  hours: "Hours",
  minutes: "Minutes",
  seconds: "Seconds",
};

const variantClasses: Record<CountdownVariant, string> = {
  modern:
    "border-border/70 bg-background/75 shadow-2xl shadow-primary/5 backdrop-blur-xl dark:bg-white/[0.035]",
  digital:
    "border-cyan-400/20 bg-zinc-950 text-white shadow-2xl shadow-cyan-500/10",
  minimal: "border-transparent bg-transparent shadow-none",
  classic: "border-border bg-card shadow-sm dark:bg-zinc-900/80",
};

const unitVariantClasses: Record<CountdownVariant, string> = {
  modern:
    "border-border/70 bg-muted/45 shadow-sm transition hover:border-primary/35 hover:bg-muted/65 hover:shadow-primary/10 dark:bg-white/[0.04]",
  digital:
    "border-cyan-400/20 bg-cyan-400/[0.055] font-mono shadow-[0_0_28px_-18px_rgba(34,211,238,0.9)] transition hover:border-cyan-300/40 hover:bg-cyan-400/[0.09]",
  minimal: "border-transparent bg-transparent transition hover:bg-muted/35",
  classic:
    "border-border bg-background shadow-sm transition hover:border-primary/30 hover:bg-muted/35 dark:bg-white/[0.035]",
};

const sizeClasses: Record<CountdownSize, { container: string; unit: string; number: string; label: string }> = {
  sm: {
    container: "gap-2 p-2",
    unit: "min-w-[4.25rem] rounded-xl px-3 py-3",
    number: "text-2xl",
    label: "text-[10px]",
  },
  md: {
    container: "gap-2.5 p-2.5 sm:gap-3 sm:p-3",
    unit: "min-w-[4.8rem] rounded-2xl px-3.5 py-4 sm:min-w-[5.6rem] sm:px-4",
    number: "text-3xl sm:text-4xl",
    label: "text-[10px] sm:text-[11px]",
  },
  lg: {
    container: "gap-3 p-3 sm:gap-4 sm:p-4",
    unit: "min-w-[5.2rem] rounded-2xl px-4 py-4 sm:min-w-[6.5rem] sm:px-5 sm:py-5",
    number: "text-4xl sm:text-5xl",
    label: "text-[11px] sm:text-xs",
  },
};

function toDateTime(value: AnimatedCountdownProps["targetDate"]) {
  if (!value) return null;
  const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
  return Number.isFinite(time) ? time : null;
}

function getTimeLeft(targetDate?: AnimatedCountdownProps["targetDate"]): TimeLeft {
  const target = toDateTime(targetDate);
  if (!target) return DEFAULT_STATIC_TIME;

  const total = Math.max(0, target - Date.now());
  const secondsTotal = Math.floor(total / 1000);
  const days = Math.floor(secondsTotal / 86400);
  const hours = Math.floor((secondsTotal % 86400) / 3600);
  const minutes = Math.floor((secondsTotal % 3600) / 60);
  const seconds = secondsTotal % 60;

  return { days, hours, minutes, seconds };
}

function isFinished(time: TimeLeft) {
  return time.days === 0 && time.hours === 0 && time.minutes === 0 && time.seconds === 0;
}

function format(value: number) {
  return String(Math.max(0, value)).padStart(2, "0");
}

function useVisibleUnits({
  showDays,
  showHours,
  showMinutes,
  showSeconds,
  unitOrder,
}: Required<Pick<AnimatedCountdownProps, "showDays" | "showHours" | "showMinutes" | "showSeconds" | "unitOrder">>) {
  return React.useMemo(() => {
    const enabled = {
      days: showDays,
      hours: showHours,
      minutes: showMinutes,
      seconds: showSeconds,
    };
    return unitOrder.filter((unit) => enabled[unit]);
  }, [showDays, showHours, showMinutes, showSeconds, unitOrder]);
}

function CountdownNumber({
  value,
  className,
}: {
  value: string;
  className?: string;
}) {
  const reduceMotion = useReducedMotion() === true;

  return (
    <span className={cn("relative inline-grid min-w-[2ch] place-items-center tabular-nums", className)}>
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={value}
          initial={reduceMotion ? false : { y: 12, opacity: 0, filter: "blur(4px)" }}
          animate={reduceMotion ? { opacity: 1 } : { y: 0, opacity: 1, filter: "blur(0px)" }}
          exit={reduceMotion ? { opacity: 0 } : { y: -12, opacity: 0, filter: "blur(4px)" }}
          transition={{ duration: reduceMotion ? 0.05 : 0.25, ease: "easeOut" }}
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function CountdownUnit({
  unit,
  value,
  variant,
  size,
  unitClassName,
  numberClassName,
  labelClassName,
  accentClassName,
  index,
}: {
  unit: CountdownUnitKey;
  value: number;
  variant: CountdownVariant;
  size: CountdownSize;
  unitClassName?: string;
  numberClassName?: string;
  labelClassName?: string;
  accentClassName?: string;
  index: number;
}) {
  const reduceMotion = useReducedMotion() === true;
  const sizePreset = sizeClasses[size];

  return (
    <motion.div
      initial={reduceMotion ? false : { opacity: 0, y: 12, scale: 0.98 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      transition={{ delay: reduceMotion ? 0 : index * 0.055, duration: 0.35, ease: "easeOut" }}
      whileHover={reduceMotion ? undefined : { y: -3 }}
      className={cn(
        "relative flex flex-col items-center justify-center overflow-hidden border text-center",
        unitVariantClasses[variant],
        sizePreset.unit,
        unitClassName,
      )}
    >
      {variant === "modern" && (
        <span
          className={cn(
            "pointer-events-none absolute inset-x-4 top-0 h-px bg-gradient-to-r from-transparent via-primary/55 to-transparent",
            accentClassName,
          )}
        />
      )}
      <CountdownNumber
        value={format(value)}
        className={cn(
          "font-bold leading-none tracking-tight text-foreground",
          variant === "digital" && "font-mono text-cyan-100 drop-shadow-[0_0_18px_rgba(34,211,238,0.45)]",
          variant === "minimal" && "font-semibold",
          sizePreset.number,
          numberClassName,
        )}
      />
      <span
        className={cn(
          "mt-2 font-semibold uppercase tracking-[0.18em] text-muted-foreground",
          variant === "digital" && "text-cyan-200/55",
          sizePreset.label,
          labelClassName,
        )}
      >
        {UNIT_LABELS[unit]}
      </span>
    </motion.div>
  );
}

export function AnimatedCountdown({
  targetDate,
  variant = "modern",
  showDays = true,
  showHours = true,
  showMinutes = true,
  showSeconds = true,
  unitOrder = ["days", "hours", "minutes", "seconds"],
  backgroundColor,
  accentColor,
  className,
  containerClassName,
  unitClassName,
  accentClassName,
  labelClassName,
  numberClassName,
  separator = ":",
  showSeparators = variant === "digital",
  completionMessage = "We're live!",
  onComplete,
  staticMode,
  initialStaticTime,
  compact = false,
  size = compact ? "sm" : "md",
  ariaLabel = "Countdown timer",
}: AnimatedCountdownProps) {
  const reduceMotion = useReducedMotion() === true;
  const isStatic = staticMode ?? !targetDate;
  const completedRef = React.useRef(false);
  const staticTime = React.useMemo<TimeLeft>(
    () => ({ ...DEFAULT_STATIC_TIME, ...initialStaticTime }),
    [initialStaticTime],
  );
  const [timeLeft, setTimeLeft] = React.useState<TimeLeft>(DEFAULT_STATIC_TIME);
  const [mounted, setMounted] = React.useState(false);
  const visibleUnits = useVisibleUnits({ showDays, showHours, showMinutes, showSeconds, unitOrder });
  const sizePreset = sizeClasses[size];
  const displayTimeLeft = isStatic ? staticTime : timeLeft;
  const completed = mounted && !isStatic && isFinished(displayTimeLeft);

  React.useEffect(() => {
    setMounted(true);
    if (isStatic) return;

    setTimeLeft(getTimeLeft(targetDate));
    const interval = window.setInterval(() => {
      setTimeLeft(getTimeLeft(targetDate));
    }, 1000);

    return () => window.clearInterval(interval);
  }, [isStatic, targetDate]);

  React.useEffect(() => {
    if (!completed || completedRef.current) return;
    completedRef.current = true;
    onComplete?.();
  }, [completed, onComplete]);

  const style = {
    ...(backgroundColor ? { backgroundColor } : null),
    ...(accentColor ? { "--countdown-accent": accentColor } as React.CSSProperties : null),
  } as React.CSSProperties;

  return (
    <motion.div
      initial={reduceMotion ? false : { opacity: 0, y: 20, scale: 0.98 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      transition={{ duration: 0.5, ease: "easeOut" }}
      aria-label={ariaLabel}
      className={cn(
        "inline-flex max-w-full flex-col items-center rounded-[1.75rem] border",
        variantClasses[variant],
        sizePreset.container,
        compact && "rounded-2xl",
        containerClassName,
        className,
      )}
      style={style}
    >
      <div
        className={cn(
          "grid max-w-full grid-cols-2 items-stretch gap-2 sm:flex sm:flex-wrap sm:justify-center",
          showSeparators && "sm:gap-0",
        )}
      >
        {visibleUnits.map((unit, index) => (
          <React.Fragment key={unit}>
            <CountdownUnit
              unit={unit}
              value={displayTimeLeft[unit]}
              variant={variant}
              size={size}
              unitClassName={unitClassName}
              numberClassName={numberClassName}
              labelClassName={labelClassName}
              accentClassName={accentClassName}
              index={index}
            />
            {showSeparators && index < visibleUnits.length - 1 && (
              <span
                className={cn(
                  "hidden items-center px-2 text-2xl font-semibold text-muted-foreground/50 sm:flex",
                  variant === "digital" && "font-mono text-cyan-200/45",
                )}
                aria-hidden
              >
                {separator}
              </span>
            )}
          </React.Fragment>
        ))}
      </div>

      <AnimatePresence>
        {completed && completionMessage && (
          <motion.p
            initial={reduceMotion ? false : { opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -4 }}
            transition={{ duration: 0.25 }}
            className="mt-3 text-sm font-medium text-primary"
          >
            {completionMessage}
          </motion.p>
        )}
      </AnimatePresence>
    </motion.div>
  );
}
next-ui/animated-countdown/index.ts
export type { AnimatedCountdownProps } from "./animated-countdown";
export { AnimatedCountdown } from "./animated-countdown";
next-ui/animated-countdown.tsx
export type { AnimatedCountdownProps } from "./animated-countdown/animated-countdown";
export { AnimatedCountdown } from "./animated-countdown/animated-countdown";

More from nexus-labs

Browse all components in this library →