Back to components

Timer

Countdown and count-up timers with optional controls, accurate deadline tracking, customizable digit slots, and opt-out motion.

displaydigit-roll

Countdown

Filled digit slots

Count up

Animated outlined digits

Compact

Static minimal timer

"use client";

import {
  useEffect,
  useImperativeHandle,
  useRef,
  type ComponentProps,
  type Ref,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

import {
  useCountdown,
  useCountUp,
  type TimerControls,
  type TimerStatus,
} from "@/hooks/use-timer";
import { cn } from "@/lib/utils";

const DIGIT_TRANSITION = {
  duration: 0.2,
  ease: [0.645, 0.045, 0.355, 1],
} as const;

type TimerDirection = "down" | "up";

type TimerDisplayProps = Omit<ComponentProps<"time">, "children"> & {
  seconds: number;
  direction: TimerDirection;
  status: TimerStatus;
  animated?: boolean;
  disabled?: boolean;
  valueClassName?: string;
  digitClassName?: string;
  separatorClassName?: string;
};

export type TimerHandle = TimerControls;

type SharedTimerProps = Omit<ComponentProps<"time">, "children"> & {
  /**
   * Imperative pause/resume/reset/restart controls. `ref` points at the
   * rendered `<time>` element, matching every other component in the registry;
   * the controls live here so both are reachable.
   */
  controlsRef?: Ref<TimerHandle>;
  /** Start automatically on mount. */
  autoStart?: boolean;
  /** Declaratively pause or resume the timer. */
  paused?: boolean;
  /** Freeze the timer and prevent resume or restart. */
  disabled?: boolean;
  /** Animate digit changes. Reduced-motion preferences still take precedence. */
  animated?: boolean;
  /** Called once when the timer reaches its boundary. */
  onComplete?: () => void;
  /** Classes applied to each hours, minutes, and seconds segment. */
  valueClassName?: string;
  /** Classes applied to every animated digit slot. */
  digitClassName?: string;
  /** Classes applied to both separators. */
  separatorClassName?: string;
};

export type CountdownProps = SharedTimerProps & {
  /** Countdown duration in seconds. */
  duration: number;
};

export type CountUpProps = SharedTimerProps & {
  /** Initial elapsed time in seconds. */
  startAt?: number;
  /** Optional elapsed-time boundary in seconds. */
  endAt?: number;
};

export function Countdown({
  duration,
  autoStart = true,
  paused,
  disabled = false,
  onComplete,
  controlsRef,
  ...props
}: CountdownProps) {
  const timer = useCountdown({
    duration,
    autoStart,
    disabled,
    onComplete,
  });

  useTimerHandle(controlsRef, timer);
  usePausedProp(paused, timer);

  return (
    <TimerDisplay
      seconds={timer.seconds}
      direction="down"
      status={timer.status}
      disabled={disabled}
      {...props}
    />
  );
}

export function CountUp({
  startAt = 0,
  endAt,
  autoStart = true,
  paused,
  disabled = false,
  onComplete,
  controlsRef,
  ...props
}: CountUpProps) {
  const timer = useCountUp({
    startAt,
    endAt,
    autoStart,
    disabled,
    onComplete,
  });

  useTimerHandle(controlsRef, timer);
  usePausedProp(paused, timer);

  return (
    <TimerDisplay
      seconds={timer.seconds}
      direction="up"
      status={timer.status}
      disabled={disabled}
      {...props}
    />
  );
}

function TimerDisplay({
  seconds: totalSeconds,
  direction,
  status,
  animated = true,
  disabled = false,
  className,
  valueClassName,
  digitClassName,
  separatorClassName,
  "aria-label": ariaLabel,
  "aria-live": ariaLive = "off",
  dateTime,
  ...props
}: TimerDisplayProps) {
  const shouldReduceMotion = useReducedMotion();
  const shouldAnimate = animated && !shouldReduceMotion;
  const { hours, minutes, seconds } = splitTime(totalSeconds);
  const values = [hours, minutes, seconds];
  const labelSuffix = direction === "down" ? "remaining" : "elapsed";

  return (
    <time
      role="timer"
      aria-label={
        ariaLabel ??
        `${formatUnit(hours, "hour")}, ${formatUnit(minutes, "minute")}, ${formatUnit(seconds, "second")} ${labelSuffix}`
      }
      aria-live={ariaLive}
      dateTime={dateTime ?? toDuration(totalSeconds)}
      data-slot={direction === "down" ? "countdown" : "count-up"}
      data-state={status}
      data-value={totalSeconds}
      data-animated={shouldAnimate}
      data-disabled={disabled || undefined}
      className={cn(
        "inline-flex items-center gap-2 font-mono text-2xl font-semibold tabular-nums",
        disabled && "opacity-50",
        className,
      )}
      {...props}
    >
      <span aria-hidden="true" className="contents">
        {values.map((value, index) => (
          <span key={index} className="contents">
            {index > 0 ? (
              <span
                data-slot="timer-separator"
                className={cn(
                  "leading-none text-muted-foreground",
                  separatorClassName,
                )}
              >
                :
              </span>
            ) : null}
            <TimerSegment
              value={value}
              direction={direction}
              animated={shouldAnimate}
              className={valueClassName}
              digitClassName={digitClassName}
            />
          </span>
        ))}
      </span>
    </time>
  );
}

function TimerSegment({
  value,
  direction,
  animated,
  className,
  digitClassName,
}: {
  value: number;
  direction: TimerDirection;
  animated: boolean;
  className?: string;
  digitClassName?: string;
}) {
  const digits = String(value).padStart(2, "0").split("");

  return (
    <span
      data-slot="timer-segment"
      data-value={value}
      className={cn("inline-flex items-center", className)}
    >
      {digits.map((digit, index) => (
        <TimerDigit
          key={`place-${digits.length - index - 1}`}
          digit={digit}
          direction={direction}
          animated={animated}
          className={digitClassName}
        />
      ))}
    </span>
  );
}

function TimerDigit({
  digit,
  direction,
  animated,
  className,
}: {
  digit: string;
  direction: TimerDirection;
  animated: boolean;
  className?: string;
}) {
  const distance = direction === "down" ? "-100%" : "100%";

  return (
    <span
      aria-hidden="true"
      data-digit={digit}
      data-slot="timer-digit"
      className={cn(
        "relative inline-block w-[1ch] overflow-hidden leading-none",
        className,
      )}
    >
      <span className="invisible">0</span>
      {animated ? (
        <AnimatePresence initial={false}>
          <motion.span
            key={digit}
            data-number={digit}
            initial={{ y: distance, opacity: 0 }}
            animate={{ y: 0, opacity: 1 }}
            exit={{
              y: direction === "down" ? "100%" : "-100%",
              opacity: 0,
            }}
            transition={DIGIT_TRANSITION}
            className="absolute inset-0 flex items-center justify-center"
          >
            {digit}
          </motion.span>
        </AnimatePresence>
      ) : (
        <span
          data-number={digit}
          className="absolute inset-0 flex items-center justify-center"
        >
          {digit}
        </span>
      )}
    </span>
  );
}

function useTimerHandle(
  ref: Ref<TimerHandle> | undefined,
  timer: TimerControls,
) {
  useImperativeHandle(
    ref,
    () => ({
      pause: timer.pause,
      resume: timer.resume,
      reset: timer.reset,
      restart: timer.restart,
    }),
    [timer.pause, timer.reset, timer.restart, timer.resume],
  );
}

function usePausedProp(
  paused: boolean | undefined,
  timer: Pick<TimerControls, "pause" | "resume">,
) {
  const { pause, resume } = timer;
  // Pause applies on mount (an initially-paused timer must not run), but
  // resume only fires on a true -> false transition: resuming on every effect
  // run would override `autoStart={false}` on mount and silently undo
  // imperative pauses whenever the control identities change.
  const previousPausedRef = useRef<boolean | undefined>(undefined);

  useEffect(() => {
    const previousPaused = previousPausedRef.current;
    previousPausedRef.current = paused;

    if (paused === true && previousPaused !== true) {
      pause();
    } else if (paused === false && previousPaused === true) {
      resume();
    }
  }, [pause, paused, resume]);
}

function splitTime(totalSeconds: number) {
  return {
    hours: Math.floor(totalSeconds / 3600),
    minutes: Math.floor((totalSeconds % 3600) / 60),
    seconds: totalSeconds % 60,
  };
}

function formatUnit(value: number, unit: string) {
  return `${value} ${unit}${value === 1 ? "" : "s"}`;
}

function toDuration(totalSeconds: number) {
  const { hours, minutes, seconds } = splitTime(totalSeconds);

  return `PT${hours ? `${hours}H` : ""}${minutes ? `${minutes}M` : ""}${seconds || (!hours && !minutes) ? `${seconds}S` : ""}`;
}

Installation