Skip to content
Component

Countdown

A live countdown to a target time, in large or compact style.

  • #arc
  • #component
  • #time
  • #timer
  • #date
  • #live

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/countdown.json

countdown.module.csscss
.countdown { position: relative; min-width: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.countdown[data-variant="large"] { container-type: inline-size; display: flex; justify-content: center; }
.countdown[data-variant="compact"] { display: inline-flex; vertical-align: middle; }

/* The shell springs to whatever it holds; the compact pill shows that morph as its own outline. */
.shell { position: relative; display: flex; justify-content: center; }
.content { position: relative; display: inline-flex; flex: none; align-items: center; justify-content: center; }
.countdown[data-variant="compact"] .shell { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
.countdown[data-variant="compact"] .content { min-height: 32px; padding: 0 var(--space-3); }
.countdown[data-variant="compact"][data-complete] .shell { border-color: transparent; background: color-mix(in oklab, var(--success) 9%, var(--surface)); }

.units { display: inline-flex; align-items: flex-start; white-space: nowrap; }
.unit { display: inline-flex; align-items: flex-start; }
.group { display: inline-grid; justify-items: center; gap: 6px; }
.number { display: inline-flex; font-variant-numeric: tabular-nums; transition: opacity var(--duration-considered) var(--ease-standard); }
.countdown[data-pending] .number { opacity: .3; }
.name { color: var(--text-muted); font-size: var(--text-xs); line-height: 1; }
.colon { color: var(--text-muted); opacity: .6; }

/* Large: display digits sized to the container, so four groups fit at 320px and breathe on a wide card. */
.countdown[data-variant="large"] .units { gap: 0; font-family: var(--font-display); font-size: clamp(1.75rem, 12.5cqi, var(--text-5xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; }
.countdown[data-variant="large"] .colon { padding-inline: .12em; line-height: 1; transform: translateY(-.06em); }
/* On a narrow card the unit names outgrow the digits; an even column floor keeps the colons evenly spaced instead of following each name's width. */
.countdown[data-variant="large"] .group { min-width: min(calc((100cqi - 1.6em) / 4), calc(var(--text-xs) * 4.2)); }
.countdown[data-variant="large"] .name { font-family: var(--font-body); letter-spacing: var(--tracking-body); }

/* Compact: one line, digits then a quiet unit letter. */
.countdown[data-variant="compact"] .units { gap: 6px; font-size: var(--text-sm); font-weight: 500; line-height: 1.3; }
.countdown[data-variant="compact"] .group { display: inline-flex; align-items: baseline; gap: 1px; }
.countdown[data-variant="compact"] .name { font-size: var(--text-sm); font-weight: 400; line-height: 1.3; }

/* Each digit is a clipped wheel whose window feathers out, so a turning digit fades at the edge instead of being cut. */
.wheel { --feather: .16em; position: relative; display: inline-block; overflow: hidden; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.5), #000 calc(100% - var(--feather) * 1.5), transparent); }
.sizer { visibility: hidden; }
.glyph { position: absolute; inset: var(--feather) 0; text-align: center; }

/* Finished: the digits give way to the label and a live mark. */
.done { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
.countdown[data-variant="large"] .done { font-family: var(--font-display); font-size: clamp(1.75rem, 11cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1.1; }
.countdown[data-variant="compact"] .done { color: var(--success); font-size: var(--text-sm); font-weight: 500; line-height: 1.3; }
.pulse { position: relative; width: .3em; height: .3em; min-width: 8px; min-height: 8px; flex: none; border-radius: 50%; background: var(--success); }
.pulse::after { position: absolute; inset: 0; border-radius: inherit; background: var(--success); content: ""; animation: pulse 2s var(--ease-standard) infinite; }
@keyframes pulse { from { opacity: .5; transform: scale(1); } 70%, to { opacity: 0; transform: scale(2.6); } }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
  .pulse::after { animation: none; }
  .number, .shell { transition: none; }
}
countdown.tsxtsx
"use client";

import { useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type Ref } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useTransform, type MotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./countdown.module.css";

export type CountdownUnit = "days" | "hours" | "minutes" | "seconds";

/** Use a countdown for a fixed moment people wait for: a launch, a live event, or a deadline. */
export interface CountdownProps {
  /**
   * The moment to count down to. A number is epoch milliseconds, a Date is used as is, and an ISO string with `Z` or an offset
   * such as `2026-10-06T10:00:00-07:00` is exact. A string without an offset is wall time in `timeZone` (Asia/Seoul by default),
   * so the result never depends on the visitor's own time zone.
   */
  target: Date | number | string;
  /** IANA zone for a target string without an offset, such as "Asia/Seoul", which is the default. Daylight saving is resolved for the target date. */
  timeZone?: string;
  /** Units to show, largest first. The largest unit absorbs the rest, so hours can pass 24 when days are left out. */
  units?: CountdownUnit[];
  /** Large digits with unit labels, or one compact line for banners and table cells. */
  variant?: "large" | "compact";
  /** Shown once the target passes. The digits morph into it. */
  completeLabel?: ReactNode;
  /** Fires once, the moment the countdown reaches zero while mounted. It fires on time even when the tab is hidden. */
  onComplete?: () => void;
  /** Accessible name, such as "발표 시작까지 남은 시간". */
  label?: string;
  /** Clock source in epoch milliseconds. Pass a server-synced clock when the visitor's device time cannot be trusted. */
  clock?: () => number;
  ref?: Ref<HTMLDivElement>;
  className?: string;
}

const { spring, stagger } = motionTokens;
const DEFAULT_UNITS: CountdownUnit[] = ["days", "hours", "minutes", "seconds"];
const SECONDS: Record<CountdownUnit, number> = { days: 86_400, hours: 3_600, minutes: 60, seconds: 1 };
/** How far each unit counts before carrying into the next, when a larger unit is shown. */
const LIMIT: Record<CountdownUnit, number> = { days: Infinity, hours: 24, minutes: 60, seconds: 60 };
const NAMES: Record<CountdownUnit, [string, string, string]> = { days: ["일", "일", "일"], hours: ["시간", "시간", "시간"], minutes: ["분", "분", "분"], seconds: ["초", "초", "초"] };
const MAX_TIMEOUT = 2_147_483_647;
const systemClock = () => Date.now();

/** Offset of a zone from UTC at an instant, in milliseconds. */
function zoneOffset(epoch: number, timeZone: string) {
  const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(new Date(epoch));
  const get = (type: string) => Number(parts.find(part => part.type === type)?.value ?? 0);
  return Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second")) - Math.floor(epoch / 1000) * 1000;
}

/** Resolves a countdown target to epoch milliseconds without ever reading the device's own time zone. */
export function toEpoch(target: Date | number | string, timeZone?: string) {
  if (typeof target === "number") return target;
  if (target instanceof Date) return target.getTime();
  const wall = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?$/.exec(target.trim());
  if (!wall) return Date.parse(target);
  const utc = Date.UTC(+wall[1]!, +wall[2]! - 1, +wall[3]!, +(wall[4] ?? 0), +(wall[5] ?? 0), +(wall[6] ?? 0));
  if (!timeZone) return utc;
  // Two passes settle the offset across a daylight saving change.
  const guess = utc - zoneOffset(utc, timeZone);
  return utc - zoneOffset(guess, timeZone);
}

/** Whole seconds left, split across the units on show. */
function partsFor(seconds: number, units: CountdownUnit[]) {
  return units.map((unit, index) => ({ unit, value: index === 0 ? Math.floor(seconds / SECONDS[unit]) : Math.floor(seconds / SECONDS[unit]) % LIMIT[unit] }));
}

/** Wheel sizes per column: a tens column that only ever shows 0 to 5 turns on a six-digit wheel, so 00 → 59 is a single step. */
function basesFor(unit: CountdownUnit, largest: boolean, length: number) {
  return Array.from({ length }, (_, index) => {
    const tens = index === length - 2 && length === 2 && !largest;
    return tens ? (unit === "hours" ? 3 : 6) : 10;
  });
}

const subscribeNothing = () => () => {};
const subscribeVisibility = (notify: () => void) => { document.addEventListener("visibilitychange", notify); return () => document.removeEventListener("visibilitychange", notify); };

/** One digit on a wheel. Its offset from the wheel position decides where it sits and how clearly it shows. */
function Glyph({ position, digit, base }: { position: MotionValue<number>; digit: number; base: number }) {
  const offset = useTransform(position, current => ((((digit - current) % base) + base * 1.5) % base) - base / 2);
  const y = useTransform(offset, current => `${current}em`);
  const opacity = useTransform(offset, current => Math.max(0, 1 - Math.abs(current)));
  const visibility = useTransform(offset, current => Math.abs(current) >= 1 ? "hidden" : "visible");
  const filter = useTransform(offset, current => Math.abs(current) < .02 || Math.abs(current) >= 1 ? "none" : `blur(${(Math.abs(current) * motionTokens.blur.subtle).toFixed(2)}px)`);
  return <motion.span className={styles.glyph} style={{ y, opacity, filter, visibility }}>{digit}</motion.span>;
}

/** A digit wheel that takes the short way round, so a countdown ticks downward and a wrap such as 00 → 59 is one step. */
function Wheel({ digit, base, delay, reduced }: { digit: number; base: number; delay: number; reduced: boolean }) {
  const position = useMotionValue(0);
  const wheel = useRef({ digit: 0, target: 0, revealed: false });
  useEffect(() => {
    const state = wheel.current;
    if (state.digit === digit) { state.revealed = true; return; }
    const up = (digit - state.digit + base) % base, down = base - up;
    state.target += down <= up ? -down : up;
    state.digit = digit;
    const first = !state.revealed;
    state.revealed = true;
    // A new animation on the same value takes over from the one in flight, keeping its velocity; no cleanup, so a re-run never strands a wheel mid-turn.
    if (reduced) position.jump(state.target);
    else animate(position, state.target, first ? { ...spring.smooth, delay } : spring.snappy);
  }, [base, delay, digit, position, reduced]);
  return <motion.span className={styles.wheel} initial={{ width: 0, opacity: 0 }} animate={{ width: "auto", opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : spring.morph}>
    <span className={styles.sizer}>0</span>
    {Array.from({ length: base }, (_, value) => <Glyph key={value} position={position} digit={value} base={base} />)}
  </motion.span>;
}

export function Countdown({ target, timeZone = "Asia/Seoul", units = DEFAULT_UNITS, variant = "large", completeLabel = "지금 시작했어요", onComplete, label = "남은 시간", clock = systemClock, ref, className }: CountdownProps) {
  const root = useRef<HTMLDivElement>(null);
  useImperativeHandle(ref, () => root.current as HTMLDivElement);
  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
  const reduced = !!useReducedMotion() && hydrated;
  const end = toEpoch(target, timeZone);
  const valid = Number.isFinite(end);
  const shownUnits = units.length ? [...units].sort((a, b) => SECONDS[b] - SECONDS[a]) : DEFAULT_UNITS;

  // The clock only ticks while the countdown is on screen and the tab is visible; coming back reads the clock again, so nothing drifts.
  const pageVisible = useSyncExternalStore(subscribeVisibility, () => document.visibilityState === "visible", () => true);
  const inView = useInView(root, { amount: 0 });
  const running = pageVisible && inView && valid;
  const [now, setNow] = useState<number | null>(null);
  const clockRef = useRef(clock);
  useLayoutEffect(() => { clockRef.current = clock; });
  useEffect(() => {
    if (!running) return;
    let timer: ReturnType<typeof setTimeout>;
    const tick = () => {
      const time = clockRef.current();
      setNow(time);
      const left = end - time;
      // Wake just after the next whole second, aligned to the target rather than to when the page loaded.
      if (left > 0) timer = setTimeout(tick, (left % 1000 || 1000) + 8);
    };
    timer = setTimeout(tick, 0);
    return () => clearTimeout(timer);
  }, [end, running]);

  // Completion is armed separately, so it lands on time even while ticking is paused.
  useEffect(() => {
    if (!valid) return;
    const left = end - clockRef.current();
    if (left <= 0) return;
    const timer = setTimeout(() => setNow(clockRef.current()), Math.min(MAX_TIMEOUT, left + 8));
    return () => clearTimeout(timer);
  }, [end, valid]);

  const left = now === null || !valid ? null : Math.max(0, end - now);
  const complete = left === 0;
  const onCompleteRef = useRef(onComplete);
  useLayoutEffect(() => { onCompleteRef.current = onComplete; });
  const wasRunning = useRef<number | null>(null);
  useEffect(() => {
    if (left === null) return;
    if (left > 0) { wasRunning.current = end; return; }
    if (wasRunning.current === end) { wasRunning.current = null; onCompleteRef.current?.(); }
  }, [end, left]);

  const seconds = left === null ? 0 : Math.ceil(left / 1000);
  const parts = partsFor(seconds, shownUnits);
  const pending = left === null;
  const compact = variant === "compact";

  // The shell springs to the size of whatever it holds, so the digits can morph into the finished label without a jump.
  const content = useRef<HTMLDivElement>(null);
  const width = useMotionValue<number | "auto">("auto"), height = useMotionValue<number | "auto">("auto");
  useLayoutEffect(() => {
    const node = content.current;
    if (!node || typeof ResizeObserver === "undefined") return;
    let first = true;
    const observer = new ResizeObserver(() => {
      const w = node.offsetWidth, h = node.offsetHeight;
      if (first || reduced) { width.jump(w); height.jump(h); first = false; return; }
      animate(width, w, spring.morph);
      animate(height, h, spring.smooth);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [height, reduced, width]);

  const spoken = complete ? (typeof completeLabel === "string" ? completeLabel : "끝났어요") : pending ? "" : parts.filter((part, index) => part.value > 0 || index === parts.length - 1).filter(part => seconds < 60 || part.unit !== "seconds").map(part => `${part.value}${NAMES[part.unit][2]}`).join(" ");
  const swap = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: motionTokens.duration.fast } }
    : { initial: { opacity: 0, scale: .92, filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, scale: .92, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.fast } }, transition: spring.morph };

  return <div ref={root} className={[styles.countdown, className].filter(Boolean).join(" ")} data-variant={variant} data-complete={complete || undefined} data-pending={pending || undefined} role="timer" aria-label={label}>
    <motion.div className={styles.shell} style={{ width, height }}>
      <div ref={content} className={styles.content} aria-hidden="true">
        <AnimatePresence mode="popLayout" initial={false}>
          {complete
            ? <motion.span key="done" className={styles.done} {...swap}><span className={styles.pulse} />{completeLabel}</motion.span>
            : <motion.span key="running" className={styles.units} {...swap}>
              {parts.map((part, group) => {
                const text = String(part.value).padStart(2, "0");
                const bases = basesFor(part.unit, group === 0, text.length);
                return <span key={part.unit} className={styles.unit}>
                  {!compact && group > 0 && <span className={styles.colon}>:</span>}
                  <span className={styles.group}>
                    <span className={styles.number}><AnimatePresence initial={false}>
                      {[...text].map((char, index) => <Wheel key={`${part.unit}${text.length - index}`} digit={Number(char)} base={bases[index]!} delay={Math.min((group * 2 + index) * stagger.item, .3)} reduced={reduced} />)}
                    </AnimatePresence></span>
                    <span className={styles.name}>{NAMES[part.unit][compact ? 1 : 0]}</span>
                  </span>
                </span>;
              })}
            </motion.span>}
        </AnimatePresence>
      </div>
    </motion.div>
    <span className={styles.srOnly}>{spoken}</span>
  </div>;
}

export default Countdown;