{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timer",
  "title": "Timer",
  "description": "Countdown and count-up timers with optional controls, accurate deadline tracking, customizable digit slots, and opt-out motion.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/timer.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useImperativeHandle,\n  useRef,\n  type ComponentProps,\n  type Ref,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nimport {\n  useCountdown,\n  useCountUp,\n  type TimerControls,\n  type TimerStatus,\n} from \"@/hooks/use-timer\";\nimport { cn } from \"@/lib/utils\";\n\nconst DIGIT_TRANSITION = {\n  duration: 0.2,\n  ease: [0.645, 0.045, 0.355, 1],\n} as const;\n\ntype TimerDirection = \"down\" | \"up\";\n\ntype TimerDisplayProps = Omit<ComponentProps<\"time\">, \"children\"> & {\n  seconds: number;\n  direction: TimerDirection;\n  status: TimerStatus;\n  animated?: boolean;\n  disabled?: boolean;\n  valueClassName?: string;\n  digitClassName?: string;\n  separatorClassName?: string;\n};\n\nexport type TimerHandle = TimerControls;\n\ntype SharedTimerProps = Omit<ComponentProps<\"time\">, \"children\"> & {\n  /**\n   * Imperative pause/resume/reset/restart controls. `ref` points at the\n   * rendered `<time>` element, matching every other component in the registry;\n   * the controls live here so both are reachable.\n   */\n  controlsRef?: Ref<TimerHandle>;\n  /** Start automatically on mount. */\n  autoStart?: boolean;\n  /** Declaratively pause or resume the timer. */\n  paused?: boolean;\n  /** Freeze the timer and prevent resume or restart. */\n  disabled?: boolean;\n  /** Animate digit changes. Reduced-motion preferences still take precedence. */\n  animated?: boolean;\n  /** Called once when the timer reaches its boundary. */\n  onComplete?: () => void;\n  /** Classes applied to each hours, minutes, and seconds segment. */\n  valueClassName?: string;\n  /** Classes applied to every animated digit slot. */\n  digitClassName?: string;\n  /** Classes applied to both separators. */\n  separatorClassName?: string;\n};\n\nexport type CountdownProps = SharedTimerProps & {\n  /** Countdown duration in seconds. */\n  duration: number;\n};\n\nexport type CountUpProps = SharedTimerProps & {\n  /** Initial elapsed time in seconds. */\n  startAt?: number;\n  /** Optional elapsed-time boundary in seconds. */\n  endAt?: number;\n};\n\nexport function Countdown({\n  duration,\n  autoStart = true,\n  paused,\n  disabled = false,\n  onComplete,\n  controlsRef,\n  ...props\n}: CountdownProps) {\n  const timer = useCountdown({\n    duration,\n    autoStart,\n    disabled,\n    onComplete,\n  });\n\n  useTimerHandle(controlsRef, timer);\n  usePausedProp(paused, timer);\n\n  return (\n    <TimerDisplay\n      seconds={timer.seconds}\n      direction=\"down\"\n      status={timer.status}\n      disabled={disabled}\n      {...props}\n    />\n  );\n}\n\nexport function CountUp({\n  startAt = 0,\n  endAt,\n  autoStart = true,\n  paused,\n  disabled = false,\n  onComplete,\n  controlsRef,\n  ...props\n}: CountUpProps) {\n  const timer = useCountUp({\n    startAt,\n    endAt,\n    autoStart,\n    disabled,\n    onComplete,\n  });\n\n  useTimerHandle(controlsRef, timer);\n  usePausedProp(paused, timer);\n\n  return (\n    <TimerDisplay\n      seconds={timer.seconds}\n      direction=\"up\"\n      status={timer.status}\n      disabled={disabled}\n      {...props}\n    />\n  );\n}\n\nfunction TimerDisplay({\n  seconds: totalSeconds,\n  direction,\n  status,\n  animated = true,\n  disabled = false,\n  className,\n  valueClassName,\n  digitClassName,\n  separatorClassName,\n  \"aria-label\": ariaLabel,\n  \"aria-live\": ariaLive = \"off\",\n  dateTime,\n  ...props\n}: TimerDisplayProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const shouldAnimate = animated && !shouldReduceMotion;\n  const { hours, minutes, seconds } = splitTime(totalSeconds);\n  const values = [hours, minutes, seconds];\n  const labelSuffix = direction === \"down\" ? \"remaining\" : \"elapsed\";\n\n  return (\n    <time\n      role=\"timer\"\n      aria-label={\n        ariaLabel ??\n        `${formatUnit(hours, \"hour\")}, ${formatUnit(minutes, \"minute\")}, ${formatUnit(seconds, \"second\")} ${labelSuffix}`\n      }\n      aria-live={ariaLive}\n      dateTime={dateTime ?? toDuration(totalSeconds)}\n      data-slot={direction === \"down\" ? \"countdown\" : \"count-up\"}\n      data-state={status}\n      data-value={totalSeconds}\n      data-animated={shouldAnimate}\n      data-disabled={disabled || undefined}\n      className={cn(\n        \"inline-flex items-center gap-2 font-mono text-2xl font-semibold tabular-nums\",\n        disabled && \"opacity-50\",\n        className,\n      )}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className=\"contents\">\n        {values.map((value, index) => (\n          <span key={index} className=\"contents\">\n            {index > 0 ? (\n              <span\n                data-slot=\"timer-separator\"\n                className={cn(\n                  \"leading-none text-muted-foreground\",\n                  separatorClassName,\n                )}\n              >\n                :\n              </span>\n            ) : null}\n            <TimerSegment\n              value={value}\n              direction={direction}\n              animated={shouldAnimate}\n              className={valueClassName}\n              digitClassName={digitClassName}\n            />\n          </span>\n        ))}\n      </span>\n    </time>\n  );\n}\n\nfunction TimerSegment({\n  value,\n  direction,\n  animated,\n  className,\n  digitClassName,\n}: {\n  value: number;\n  direction: TimerDirection;\n  animated: boolean;\n  className?: string;\n  digitClassName?: string;\n}) {\n  const digits = String(value).padStart(2, \"0\").split(\"\");\n\n  return (\n    <span\n      data-slot=\"timer-segment\"\n      data-value={value}\n      className={cn(\"inline-flex items-center\", className)}\n    >\n      {digits.map((digit, index) => (\n        <TimerDigit\n          key={`place-${digits.length - index - 1}`}\n          digit={digit}\n          direction={direction}\n          animated={animated}\n          className={digitClassName}\n        />\n      ))}\n    </span>\n  );\n}\n\nfunction TimerDigit({\n  digit,\n  direction,\n  animated,\n  className,\n}: {\n  digit: string;\n  direction: TimerDirection;\n  animated: boolean;\n  className?: string;\n}) {\n  const distance = direction === \"down\" ? \"-100%\" : \"100%\";\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-digit={digit}\n      data-slot=\"timer-digit\"\n      className={cn(\n        \"relative inline-block w-[1ch] overflow-hidden leading-none\",\n        className,\n      )}\n    >\n      <span className=\"invisible\">0</span>\n      {animated ? (\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={digit}\n            data-number={digit}\n            initial={{ y: distance, opacity: 0 }}\n            animate={{ y: 0, opacity: 1 }}\n            exit={{\n              y: direction === \"down\" ? \"100%\" : \"-100%\",\n              opacity: 0,\n            }}\n            transition={DIGIT_TRANSITION}\n            className=\"absolute inset-0 flex items-center justify-center\"\n          >\n            {digit}\n          </motion.span>\n        </AnimatePresence>\n      ) : (\n        <span\n          data-number={digit}\n          className=\"absolute inset-0 flex items-center justify-center\"\n        >\n          {digit}\n        </span>\n      )}\n    </span>\n  );\n}\n\nfunction useTimerHandle(\n  ref: Ref<TimerHandle> | undefined,\n  timer: TimerControls,\n) {\n  useImperativeHandle(\n    ref,\n    () => ({\n      pause: timer.pause,\n      resume: timer.resume,\n      reset: timer.reset,\n      restart: timer.restart,\n    }),\n    [timer.pause, timer.reset, timer.restart, timer.resume],\n  );\n}\n\nfunction usePausedProp(\n  paused: boolean | undefined,\n  timer: Pick<TimerControls, \"pause\" | \"resume\">,\n) {\n  const { pause, resume } = timer;\n  // Pause applies on mount (an initially-paused timer must not run), but\n  // resume only fires on a true -> false transition: resuming on every effect\n  // run would override `autoStart={false}` on mount and silently undo\n  // imperative pauses whenever the control identities change.\n  const previousPausedRef = useRef<boolean | undefined>(undefined);\n\n  useEffect(() => {\n    const previousPaused = previousPausedRef.current;\n    previousPausedRef.current = paused;\n\n    if (paused === true && previousPaused !== true) {\n      pause();\n    } else if (paused === false && previousPaused === true) {\n      resume();\n    }\n  }, [pause, paused, resume]);\n}\n\nfunction splitTime(totalSeconds: number) {\n  return {\n    hours: Math.floor(totalSeconds / 3600),\n    minutes: Math.floor((totalSeconds % 3600) / 60),\n    seconds: totalSeconds % 60,\n  };\n}\n\nfunction formatUnit(value: number, unit: string) {\n  return `${value} ${unit}${value === 1 ? \"\" : \"s\"}`;\n}\n\nfunction toDuration(totalSeconds: number) {\n  const { hours, minutes, seconds } = splitTime(totalSeconds);\n\n  return `PT${hours ? `${hours}H` : \"\"}${minutes ? `${minutes}M` : \"\"}${seconds || (!hours && !minutes) ? `${seconds}S` : \"\"}`;\n}\n",
      "type": "registry:ui",
      "target": "@ui/timer.tsx"
    },
    {
      "path": "registry/base/hooks/use-timer.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type TimerStatus = \"idle\" | \"running\" | \"paused\" | \"completed\";\n\nexport type TimerControls = {\n  pause: () => void;\n  resume: () => void;\n  reset: () => void;\n  restart: () => void;\n};\n\nexport type UseTimerOptions = {\n  direction: \"down\" | \"up\";\n  initialSeconds: number;\n  endSeconds?: number;\n  autoStart?: boolean;\n  disabled?: boolean;\n  onComplete?: () => void;\n};\n\nexport type UseTimerReturn = TimerControls & {\n  seconds: number;\n  status: TimerStatus;\n  isRunning: boolean;\n};\n\nexport type UseCountdownOptions = Omit<\n  UseTimerOptions,\n  \"direction\" | \"initialSeconds\" | \"endSeconds\"\n> & {\n  duration: number;\n};\n\nexport type UseCountUpOptions = Omit<\n  UseTimerOptions,\n  \"direction\" | \"initialSeconds\" | \"endSeconds\"\n> & {\n  startAt?: number;\n  endAt?: number;\n};\n\ntype TimerAnchor = {\n  startedAt: number;\n  valueMs: number;\n};\n\nexport function useTimer({\n  direction,\n  initialSeconds,\n  endSeconds,\n  autoStart = true,\n  disabled = false,\n  onComplete,\n}: UseTimerOptions): UseTimerReturn {\n  const initialMs = toMilliseconds(initialSeconds, \"initialSeconds\");\n  const endMs =\n    endSeconds === undefined\n      ? undefined\n      : toMilliseconds(endSeconds, \"endSeconds\");\n\n  validateBoundary(direction, initialMs, endMs);\n\n  const startsAtBoundary = hasReachedBoundary(direction, initialMs, endMs);\n  const initialStatus: TimerStatus = startsAtBoundary\n    ? \"completed\"\n    : autoStart && !disabled\n      ? \"running\"\n      : \"idle\";\n  const [valueMs, setValueMs] = useState(initialMs);\n  const [status, setStatus] = useState<TimerStatus>(initialStatus);\n  const valueRef = useRef(initialMs);\n  const statusRef = useRef<TimerStatus>(initialStatus);\n  const anchorRef = useRef<TimerAnchor>({\n    startedAt: 0,\n    valueMs: initialMs,\n  });\n  const definitionRef = useRef({ direction, initialMs, endMs });\n  const onCompleteRef = useRef(onComplete);\n  const completionNotifiedRef = useRef(startsAtBoundary);\n  const wasDisabledRef = useRef(disabled);\n  const resumeAfterDisabledRef = useRef(false);\n\n  useEffect(() => {\n    onCompleteRef.current = onComplete;\n  }, [onComplete]);\n\n  const commit = useCallback((nextValueMs: number, nextStatus: TimerStatus) => {\n    valueRef.current = nextValueMs;\n    statusRef.current = nextStatus;\n    setValueMs(nextValueMs);\n    setStatus(nextStatus);\n  }, []);\n\n  const readCurrentValue = useCallback(\n    (now: number) => {\n      const elapsed = Math.max(0, now - anchorRef.current.startedAt);\n      const nextValue =\n        direction === \"down\"\n          ? anchorRef.current.valueMs - elapsed\n          : anchorRef.current.valueMs + elapsed;\n\n      return clampToBoundary(direction, nextValue, endMs);\n    },\n    [direction, endMs],\n  );\n\n  const complete = useCallback(\n    (nextValueMs: number) => {\n      commit(nextValueMs, \"completed\");\n\n      if (!completionNotifiedRef.current) {\n        completionNotifiedRef.current = true;\n        onCompleteRef.current?.();\n      }\n    },\n    [commit],\n  );\n\n  const freeze = useCallback(() => {\n    if (statusRef.current !== \"running\") return;\n\n    const nextValue = readCurrentValue(Date.now());\n\n    if (hasReachedBoundary(direction, nextValue, endMs)) {\n      complete(nextValue);\n      return;\n    }\n\n    commit(nextValue, \"paused\");\n  }, [commit, complete, direction, endMs, readCurrentValue]);\n\n  const pause = useCallback(() => {\n    resumeAfterDisabledRef.current = false;\n    freeze();\n  }, [freeze]);\n\n  const resume = useCallback(() => {\n    if (\n      disabled ||\n      statusRef.current === \"running\" ||\n      hasReachedBoundary(direction, valueRef.current, endMs)\n    ) {\n      return;\n    }\n\n    resumeAfterDisabledRef.current = false;\n    anchorRef.current = {\n      startedAt: Date.now(),\n      valueMs: valueRef.current,\n    };\n    commit(valueRef.current, \"running\");\n  }, [commit, direction, disabled, endMs]);\n\n  const reset = useCallback(() => {\n    resumeAfterDisabledRef.current = false;\n    completionNotifiedRef.current = startsAtBoundary;\n    anchorRef.current = { startedAt: Date.now(), valueMs: initialMs };\n    commit(initialMs, startsAtBoundary ? \"completed\" : \"idle\");\n  }, [commit, initialMs, startsAtBoundary]);\n\n  const restart = useCallback(() => {\n    if (disabled) return;\n\n    completionNotifiedRef.current = startsAtBoundary;\n    anchorRef.current = { startedAt: Date.now(), valueMs: initialMs };\n    commit(initialMs, startsAtBoundary ? \"completed\" : \"running\");\n  }, [commit, disabled, initialMs, startsAtBoundary]);\n\n  useEffect(() => {\n    const previous = definitionRef.current;\n    const definitionChanged =\n      previous.direction !== direction ||\n      previous.initialMs !== initialMs ||\n      previous.endMs !== endMs;\n\n    if (!definitionChanged) return;\n\n    definitionRef.current = { direction, initialMs, endMs };\n    completionNotifiedRef.current = startsAtBoundary;\n    anchorRef.current = { startedAt: Date.now(), valueMs: initialMs };\n    commit(\n      initialMs,\n      startsAtBoundary\n        ? \"completed\"\n        : autoStart && !disabled\n          ? \"running\"\n          : \"idle\",\n    );\n  }, [\n    autoStart,\n    commit,\n    direction,\n    disabled,\n    endMs,\n    initialMs,\n    startsAtBoundary,\n  ]);\n\n  useEffect(() => {\n    const wasDisabled = wasDisabledRef.current;\n    wasDisabledRef.current = disabled;\n    let timeout = 0;\n\n    if (disabled) {\n      resumeAfterDisabledRef.current = statusRef.current === \"running\";\n      timeout = window.setTimeout(freeze, 0);\n    } else if (wasDisabled && resumeAfterDisabledRef.current) {\n      resumeAfterDisabledRef.current = false;\n      timeout = window.setTimeout(resume, 0);\n    }\n\n    return () => window.clearTimeout(timeout);\n  }, [disabled, freeze, resume]);\n\n  useEffect(() => {\n    if (status !== \"running\" || disabled) return;\n\n    let timeout = 0;\n\n    if (anchorRef.current.startedAt === 0) {\n      anchorRef.current = {\n        startedAt: Date.now(),\n        valueMs: valueRef.current,\n      };\n    }\n\n    function tick() {\n      const nextValue = readCurrentValue(Date.now());\n\n      if (hasReachedBoundary(direction, nextValue, endMs)) {\n        complete(nextValue);\n        return;\n      }\n\n      commit(nextValue, \"running\");\n      timeout = window.setTimeout(\n        tick,\n        millisecondsUntilNextSecond(nextValue, direction),\n      );\n    }\n\n    timeout = window.setTimeout(\n      tick,\n      millisecondsUntilNextSecond(valueRef.current, direction),\n    );\n\n    return () => window.clearTimeout(timeout);\n  }, [commit, complete, direction, disabled, endMs, readCurrentValue, status]);\n\n  const seconds =\n    direction === \"down\"\n      ? Math.ceil(valueMs / 1000)\n      : Math.floor(valueMs / 1000);\n\n  return {\n    seconds,\n    status,\n    isRunning: status === \"running\",\n    pause,\n    resume,\n    reset,\n    restart,\n  };\n}\n\nexport function useCountdown({\n  duration,\n  ...options\n}: UseCountdownOptions): UseTimerReturn {\n  return useTimer({\n    ...options,\n    direction: \"down\",\n    initialSeconds: duration,\n    endSeconds: 0,\n  });\n}\n\nexport function useCountUp({\n  startAt = 0,\n  endAt,\n  ...options\n}: UseCountUpOptions = {}): UseTimerReturn {\n  return useTimer({\n    ...options,\n    direction: \"up\",\n    initialSeconds: startAt,\n    endSeconds: endAt,\n  });\n}\n\nfunction toMilliseconds(seconds: number, name: string) {\n  if (!Number.isFinite(seconds) || seconds < 0) {\n    throw new RangeError(`${name} must be a finite, non-negative number.`);\n  }\n\n  // Round, because float seconds rarely scale to whole ms (16.1 * 1000 is\n  // 16100.000000000002) and a derived duration must not throw.\n  const milliseconds = Math.round(seconds * 1000);\n\n  if (!Number.isSafeInteger(milliseconds)) {\n    throw new RangeError(`${name} is outside the supported timer range.`);\n  }\n\n  return milliseconds;\n}\n\nfunction validateBoundary(\n  direction: \"down\" | \"up\",\n  initialMs: number,\n  endMs: number | undefined,\n) {\n  if (direction === \"down\" && endMs !== undefined && endMs > initialMs) {\n    throw new RangeError(\n      \"A countdown endSeconds value cannot be greater than initialSeconds.\",\n    );\n  }\n\n  if (direction === \"up\" && endMs !== undefined && endMs < initialMs) {\n    throw new RangeError(\n      \"A count-up endSeconds value cannot be less than initialSeconds.\",\n    );\n  }\n}\n\nfunction clampToBoundary(\n  direction: \"down\" | \"up\",\n  valueMs: number,\n  endMs: number | undefined,\n) {\n  if (endMs === undefined) return Math.max(0, valueMs);\n\n  return direction === \"down\"\n    ? Math.max(endMs, valueMs)\n    : Math.min(endMs, valueMs);\n}\n\nfunction hasReachedBoundary(\n  direction: \"down\" | \"up\",\n  valueMs: number,\n  endMs: number | undefined,\n) {\n  if (endMs === undefined) return false;\n\n  return direction === \"down\" ? valueMs <= endMs : valueMs >= endMs;\n}\n\nfunction millisecondsUntilNextSecond(\n  valueMs: number,\n  direction: \"down\" | \"up\",\n) {\n  const remainder = valueMs % 1000;\n  const delay =\n    direction === \"down\"\n      ? remainder === 0\n        ? 1000\n        : remainder\n      : remainder === 0\n        ? 1000\n        : 1000 - remainder;\n\n  return Math.max(16, delay);\n}\n",
      "type": "registry:hook",
      "target": "@hooks/use-timer.ts"
    }
  ],
  "meta": {
    "tags": [
      "countdown",
      "count-up",
      "timer",
      "stopwatch",
      "deadline",
      "clock"
    ],
    "effects": [
      "digit-roll",
      "number-transition",
      "optional-motion",
      "reduced-motion"
    ]
  },
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}