{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"countdown","type":"registry:ui","title":"Countdown","description":"Time left until a moment, ticking each second, with unit names in the reader's language.","dependencies":[],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/countdown.tsx","type":"registry:file","target":"components/dashral/countdown.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\ntype CountdownUnit = \"days\" | \"hours\" | \"minutes\" | \"seconds\"\n\ntype CountdownProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** When it ends: a Date, an ISO string (\"2026-12-01T09:00:00Z\") or milliseconds since 1970. */\n  to: Date | string | number\n  /** Which units to show, largest first. Without days, the hours carry on past 24. */\n  units?: CountdownUnit[]\n  /** boxes (default): each number in a box with its unit under it. inline: one line of text, \"2d 04:15:09\". */\n  variant?: \"boxes\" | \"inline\"\n  size?: \"sm\" | \"default\" | \"lg\"\n  /** Called once, when it reaches zero (right away if `to` has already passed). */\n  onComplete?: () => void\n  /** Shown in place of the numbers once it's over. Without it, zeros stay. */\n  completed?: React.ReactNode\n  classNames?: { unit?: string; value?: string; label?: string }\n}\n\nconst unitMs: Record<CountdownUnit, number> = { days: 86_400_000, hours: 3_600_000, minutes: 60_000, seconds: 1000 }\nconst intlUnit: Record<CountdownUnit, \"day\" | \"hour\" | \"minute\" | \"second\"> = { days: \"day\", hours: \"hour\", minutes: \"minute\", seconds: \"second\" }\nconst allUnits: CountdownUnit[] = [\"days\", \"hours\", \"minutes\", \"seconds\"]\n\n/** What's left, split into the units shown: the largest takes what's above it (no days → 50 hours). */\nfunction split(ms: number, units: CountdownUnit[]) {\n  let rest = Math.max(0, ms)\n  const parts = {} as Record<CountdownUnit, number>\n  for (const unit of allUnits.filter((u) => units.includes(u))) {\n    parts[unit] = Math.floor(rest / unitMs[unit])\n    rest -= parts[unit] * unitMs[unit]\n  }\n  return parts\n}\n\n/** The unit's name in the locale and in the right plural for the number (\"days\", \"hari\", \"أيام\"), without the number. */\nfunction unitName(locale: string, unit: CountdownUnit, value: number, display: \"long\" | \"narrow\") {\n  const parts = new Intl.NumberFormat(locale, { style: \"unit\", unit: intlUnit[unit], unitDisplay: display }).formatToParts(value)\n  return parts.filter((part) => part.type === \"unit\").map((part) => part.value).join(\"\") || unit\n}\n\n/**\n * One character that rolls when it changes: the old one slides down and fades out while the new one slides in from\n * above, in the same cell, so a tick never blinks. Plain CSS animations (tw-animate), no library; with reduced motion\n * it just changes.\n */\nfunction RollingChar({ char }: { char: string }) {\n  // The character shown and the one leaving, with a count so each change gets fresh elements (and fresh animations).\n  const [state, setState] = React.useState({ current: char, previous: null as string | null, turn: 0 })\n  if (state.current !== char) setState({ current: char, previous: state.current, turn: state.turn + 1 })\n  const roll = \"duration-(--duration-considered) ease-standard fill-mode-forwards motion-reduce:animate-none\"\n  return (\n    <span className=\"inline-grid overflow-hidden [mask-image:linear-gradient(transparent,#000_22%,#000_78%,transparent)]\">\n      {state.previous !== null && (\n        <span\n          key={`out-${state.turn}`}\n          className={cn(\"col-start-1 row-start-1 animate-out fade-out-0 blur-out-[2px] slide-out-to-bottom-[70%] motion-reduce:hidden\", roll)}\n        >\n          {state.previous}\n        </span>\n      )}\n      <span\n        key={`in-${state.turn}`}\n        className={cn(\"col-start-1 row-start-1\", state.turn > 0 && [\"animate-in fade-in-0 blur-in-[2px] slide-in-from-top-[70%]\", roll])}\n      >\n        {state.current}\n      </span>\n    </span>\n  )\n}\n\n/** A number (or any text) whose characters roll one by one: in \"40\" → \"39\" both turn, in \"39\" → \"38\" only the last. */\nfunction RollingText({ text }: { text: string }) {\n  // Keyed from the end, so a number that loses a digit (100 → 99) keeps its units in place.\n  const chars = [...text]\n  return (\n    <span className=\"inline-flex\">\n      {chars.map((char, i) => (\n        <RollingChar key={chars.length - i} char={char} />\n      ))}\n    </span>\n  )\n}\n\n/**\n * Time left until a moment, ticking each second: a launch, a sale's end, a webinar. Unit names come from the locale,\n * so they're in the reader's language without translations. The numbers fill in once the page is in the browser\n * (the server can't know the reader's clock), so the page never shows a wrong time first.\n */\nfunction Countdown({\n  to,\n  units = allUnits,\n  variant = \"boxes\",\n  size = \"default\",\n  onComplete,\n  completed,\n  className,\n  classNames,\n  ...props\n}: CountdownProps) {\n  const { locale } = useLocale()\n  const target = new Date(to).getTime()\n  const [now, setNow] = React.useState<number | null>(null)\n  const onCompleteRef = React.useRef(onComplete)\n  React.useEffect(() => {\n    onCompleteRef.current = onComplete\n  })\n\n  React.useEffect(() => {\n    let timer: ReturnType<typeof setTimeout>\n    let done = false\n    const tick = () => {\n      const current = Date.now()\n      setNow(current)\n      if (current >= target) {\n        if (!done) onCompleteRef.current?.()\n        done = true\n        return\n      }\n      // Wakes on the next whole second of what's left, so every unit turns over together and never skips.\n      timer = setTimeout(tick, ((target - current) % 1000) + 5)\n    }\n    tick()\n    return () => clearTimeout(timer)\n  }, [target])\n\n  const left = now === null ? null : target - now\n  const over = left !== null && left <= 0\n  if (over && completed !== undefined) {\n    return (\n      <div data-slot=\"countdown\" data-complete=\"\" className={className} {...props}>\n        {completed}\n      </div>\n    )\n  }\n\n  const parts = left === null ? null : split(left, units)\n  const shown = allUnits.filter((unit) => units.includes(unit))\n  const number = new Intl.NumberFormat(locale, { minimumIntegerDigits: 2, useGrouping: false })\n  const plain = new Intl.NumberFormat(locale)\n  // Every second for the eyes; the whole sentence for screen readers, which read it when they reach it (a timer\n  // isn't announced on every tick).\n  const spoken = parts ? shown.map((unit) => `${plain.format(parts[unit])} ${unitName(locale, unit, parts[unit], \"long\")}`).join(\", \") : undefined\n\n  if (variant === \"inline\") {\n    // \"2d 04:15:09\": days with their short unit, then the rest joined by colons.\n    const clock = shown.filter((unit) => unit !== \"days\").map((unit) => (parts ? number.format(parts[unit]) : \"––\"))\n    const days = shown.includes(\"days\") ? (parts ? `${plain.format(parts.days)}${unitName(locale, \"days\", parts.days, \"narrow\")}` : \"–\") : null\n    const text = [days, clock.join(\":\")].filter(Boolean).join(\" \")\n    // A span, so it can sit inside a sentence (a <p>).\n    return (\n      <span\n        role=\"timer\"\n        data-slot=\"countdown\"\n        data-complete={over || undefined}\n        aria-label={spoken}\n        className={cn(\n          \"inline-flex font-medium tabular-nums\",\n          size === \"sm\" ? \"text-sm\" : size === \"lg\" ? \"text-2xl tracking-tight\" : \"text-base\",\n          className\n        )}\n        {...props}\n      >\n        <span aria-hidden className={cn(\"inline-flex\", !parts && \"text-muted-foreground\", classNames?.value)}>\n          {parts ? <RollingText text={text} /> : text}\n        </span>\n      </span>\n    )\n  }\n\n  return (\n    <div\n      role=\"timer\"\n      data-slot=\"countdown\"\n      data-complete={over || undefined}\n      aria-label={spoken}\n      className={cn(\"inline-flex items-start\", size === \"sm\" ? \"gap-1.5\" : size === \"lg\" ? \"gap-3\" : \"gap-2\", className)}\n      {...props}\n    >\n      {shown.map((unit) => (\n        <div\n          key={unit}\n          aria-hidden\n          className={cn(\n            \"grid justify-items-center rounded-lg border bg-card text-card-foreground shadow-xs\",\n            size === \"sm\" ? \"min-w-11 px-1.5 py-1\" : size === \"lg\" ? \"min-w-20 px-3 py-3\" : \"min-w-15 px-2 py-2\",\n            classNames?.unit\n          )}\n        >\n          <span\n            className={cn(\n              \"font-semibold tabular-nums\",\n              size === \"sm\" ? \"text-base\" : size === \"lg\" ? \"text-4xl tracking-tight\" : \"text-2xl tracking-tight\",\n              !parts && \"text-muted-foreground\",\n              classNames?.value\n            )}\n          >\n            {parts ? <RollingText text={number.format(parts[unit])} /> : \"––\"}\n          </span>\n          <span className={cn(\"text-muted-foreground\", size === \"sm\" ? \"text-[10px]\" : \"text-xs\", classNames?.label)}>\n            {unitName(locale, unit, parts ? parts[unit] : 2, \"long\")}\n          </span>\n        </div>\n      ))}\n    </div>\n  )\n}\n\nexport { Countdown, type CountdownProps, type CountdownUnit }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}