UIPackage
Menu

Countdown

countdown ui
Edit on GitHub

Countdown timer that shows time remaining to a target date. Supports DD:HH:MM:SS, HH:MM:SS, MM:SS, and SS formats plus custom token formats, render props for days/hours/minutes/seconds, a label, leading-zero padding, a custom separator, paused state, and on-finish/tick events.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/countdown.json
Named registry: npx shadcn@latest add @uipkge-react/countdown Installs to: components/ui/countdown/

Examples

Props

Name Type / Values Default Required
days number required
hours number required
minutes number required
seconds number required
display string required
finished boolean required

Files installed (2)

  • components/ui/countdown/Countdown.tsx 6.8 kB
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    
    type Format = 'DD:HH:MM:SS' | 'HH:MM:SS' | 'MM:SS' | 'SS'
    
    export interface CountdownRenderProps {
      days: number
      hours: number
      minutes: number
      seconds: number
      display: string
      finished: boolean
    }
    
    export interface CountdownProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
      /** Target date/time. Accepts a Date, ISO string, or epoch ms number. */
      target: Date | string | number
      /** Display format. Custom tokens: DD days, HH hours, MM minutes, SS seconds. */
      format?: Format | string
      /** Pause the countdown. */
      paused?: boolean
      /** Optional label rendered above the countdown. */
      label?: string
      /** Show leading zeros (e.g. 05 vs 5). */
      pad?: boolean
      /** Separator between units. */
      separator?: string
      /** Fired once when the countdown reaches zero. */
      onFinish?: () => void
      /** Fired every tick with the remaining ms. */
      onTick?: (remaining: number) => void
      /** Render-prop override for the whole display. Receives all parts + display string. */
      children?: ((props: CountdownRenderProps) => React.ReactNode) | React.ReactNode
      /** Render-prop override for the days unit. */
      renderDays?: (days: number) => React.ReactNode
      /** Render-prop override for the hours unit. */
      renderHours?: (hours: number) => React.ReactNode
      /** Render-prop override for the minutes unit. */
      renderMinutes?: (minutes: number) => React.ReactNode
      /** Render-prop override for the seconds unit. */
      renderSeconds?: (seconds: number) => React.ReactNode
    }
    
    const Countdown = React.forwardRef<HTMLDivElement, CountdownProps>(
      (
        {
          target,
          format = 'DD:HH:MM:SS',
          paused = false,
          label = '',
          pad = true,
          separator = ':',
          onFinish,
          onTick,
          children,
          renderDays,
          renderHours,
          renderMinutes,
          renderSeconds,
          className,
          ...props
        },
        ref,
      ) => {
        const [now, setNow] = React.useState(() => Date.now())
        const [finished, setFinished] = React.useState(false)
    
        const targetMs = React.useMemo(() => {
          if (target instanceof Date) return target.getTime()
          if (typeof target === 'number') return target
          return new Date(target).getTime()
        }, [target])
    
        const remainingMs = Math.max(0, targetMs - now)
    
        const parts = React.useMemo(() => {
          const total = remainingMs
          const days = Math.floor(total / 86_400_000)
          const hours = Math.floor((total % 86_400_000) / 3_600_000)
          const minutes = Math.floor((total % 3_600_000) / 60_000)
          const seconds = Math.floor((total % 60_000) / 1000)
          return { days, hours, minutes, seconds }
        }, [remainingMs])
    
        const pad2 = React.useCallback((n: number) => (pad ? String(n).padStart(2, '0') : String(n)), [pad])
    
        const display = React.useMemo(() => {
          const f = format
          const { days, hours, minutes, seconds } = parts
          const sep = separator
          if (f === 'DD:HH:MM:SS') return `${pad2(days)}${sep}${pad2(hours)}${sep}${pad2(minutes)}${sep}${pad2(seconds)}`
          if (f === 'HH:MM:SS') return `${pad2(days * 24 + hours)}${sep}${pad2(minutes)}${sep}${pad2(seconds)}`
          if (f === 'MM:SS') return `${pad2(days * 24 * 60 + hours * 60 + minutes)}${sep}${pad2(seconds)}`
          if (f === 'SS') return pad2(Math.floor(remainingMs / 1000))
          // Custom token format: replace DD, HH, MM, SS tokens.
          return f
            .replace('DD', pad2(days))
            .replace('HH', pad2(hours))
            .replace('MM', pad2(minutes))
            .replace('SS', pad2(seconds))
        }, [format, parts, separator, pad2, remainingMs])
    
        // Keep latest callbacks without retriggering the interval effect.
        const onFinishRef = React.useRef(onFinish)
        const onTickRef = React.useRef(onTick)
        onFinishRef.current = onFinish
        onTickRef.current = onTick
    
        React.useEffect(() => {
          if (paused) return
          const timer = setInterval(() => {
            const next = Date.now()
            const remaining = Math.max(0, targetMs - next)
            onTickRef.current?.(remaining)
            if (remaining <= 0) {
              setFinished(true)
              onFinishRef.current?.()
            }
            setNow(next)
          }, 1000)
          return () => clearInterval(timer)
        }, [paused, targetMs])
    
        // Reset finished state when the target changes.
        React.useEffect(() => {
          setFinished(false)
          setNow(Date.now())
        }, [targetMs])
    
        const renderProps: CountdownRenderProps = {
          ...parts,
          display,
          finished,
        }
    
        const renderDefault = () => {
          const f = format
          return (
            <>
              {f.includes('DD') &&
                (renderDays ? (
                  renderDays(parts.days)
                ) : (
                  <span data-slot="countdown-days" className="text-foreground text-2xl font-semibold">
                    {pad2(parts.days)}
                  </span>
                ))}
              {f.includes('DD') && f.includes('HH') && <span className="text-muted-foreground text-2xl">{separator}</span>}
              {f.includes('HH') &&
                (renderHours ? (
                  renderHours(parts.hours)
                ) : (
                  <span data-slot="countdown-hours" className="text-foreground text-2xl font-semibold">
                    {pad2(parts.hours)}
                  </span>
                ))}
              {f.includes('HH') && f.includes('MM') && <span className="text-muted-foreground text-2xl">{separator}</span>}
              {f.includes('MM') &&
                (renderMinutes ? (
                  renderMinutes(parts.minutes)
                ) : (
                  <span data-slot="countdown-minutes" className="text-foreground text-2xl font-semibold">
                    {pad2(parts.minutes)}
                  </span>
                ))}
              {f.includes('MM') && f.includes('SS') && <span className="text-muted-foreground text-2xl">{separator}</span>}
              {f.includes('SS') &&
                (renderSeconds ? (
                  renderSeconds(parts.seconds)
                ) : (
                  <span data-slot="countdown-seconds" className="text-foreground text-2xl font-semibold">
                    {pad2(parts.seconds)}
                  </span>
                ))}
            </>
          )
        }
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="countdown"
            data-finished={finished}
            data-paused={paused}
            className={cn('inline-flex flex-col gap-1', className)}
            {...props}
          >
            {label && (
              <span
                data-slot="countdown-label"
                className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
              >
                {label}
              </span>
            )}
            <div data-slot="countdown-display" className="flex items-baseline gap-1 font-mono tabular-nums">
              {typeof children === 'function' ? children(renderProps) : (children ?? renderDefault())}
            </div>
          </div>
        )
      },
    )
    Countdown.displayName = 'Countdown'
    
    export { Countdown }
  • components/ui/countdown/index.ts 0.1 kB
    export { Countdown, type CountdownProps, type CountdownRenderProps } from './Countdown'

Raw manifest: https://uipkge.dev/r/react/countdown.json