{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "relative-time",
  "type": "registry:ui",
  "title": "Relative Time",
  "description": "“3 minutes ago” that wakes only when its words change, digits rolling.",
  "categories": [
    "dates"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/relative-time.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useMemo, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype Unit = \"second\" | \"minute\" | \"hour\" | \"day\" | \"week\" | \"month\" | \"year\";\ntype DateInput = Date | string | number;\n\nconst SEC = 1000;\nconst MIN = 60 * SEC;\nconst HOUR = 60 * MIN;\nconst DAY = 24 * HOUR;\nconst WEEK = 7 * DAY;\nconst MONTH = 30.4375 * DAY;\nconst YEAR = 365.25 * DAY;\n/** Each band: below `until` of elapsed time, count in `unit`, changing every `every` ms. */\nconst BANDS: { until: number; unit: Unit; size: number; every: number }[] = [\n  { until: MIN, unit: \"second\", size: SEC, every: 10 * SEC },\n  { until: HOUR, unit: \"minute\", size: MIN, every: MIN },\n  { until: DAY, unit: \"hour\", size: HOUR, every: HOUR },\n  { until: WEEK, unit: \"day\", size: DAY, every: DAY },\n  { until: 5 * WEEK, unit: \"week\", size: WEEK, every: WEEK },\n  { until: YEAR, unit: \"month\", size: MONTH, every: MONTH },\n  { until: Infinity, unit: \"year\", size: YEAR, every: YEAR },\n];\n// A timestamp a few seconds in the future is almost always clock skew, not a plan.\nconst SKEW = 30 * SEC;\n\nconst toTime = (d: DateInput) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : Date.parse(d));\n\nexport type RelativeTimeFormat = \"long\" | \"short\" | \"narrow\";\n\ntype Resolved =\n  | {\n      kind: \"relative\";\n      unit: Unit;\n      value: number;\n      future: boolean;\n      next: number | null;\n    }\n  | { kind: \"absolute\"; next: number | null };\n\n/** Which words to show at `now`, and how many ms until they change. */\nfunction resolve(at: number, now: number, absoluteAfter: number): Resolved {\n  const diff = at - now;\n  const future = diff > SKEW;\n  const abs = future ? diff : Math.max(0, -diff);\n  if (abs >= absoluteAfter) return { kind: \"absolute\", next: future ? abs - absoluteAfter + 1 : null };\n  const band = BANDS.find((b) => abs < b.until)!;\n  // Seconds count in tens: \"now\", \"10 seconds ago\", \"20 seconds ago\".\n  const bucket = band.unit === \"second\" ? band.every : band.size;\n  const value = band.unit === \"second\" ? Math.floor(abs / bucket) * 10 : Math.floor(abs / bucket);\n  let next = future ? abs % bucket || bucket : bucket - (abs % bucket);\n  if (!future) next = Math.min(next, absoluteAfter - abs);\n  return {\n    kind: \"relative\",\n    unit: band.unit,\n    value,\n    future,\n    next: Math.min(next + 5, HOUR),\n  };\n}\n\n/* ------------------------------------------------------------------------ */\n/* One clock per instance. It only wakes when the words would change, sleeps */\n/* while the tab is hidden, and catches up the moment the tab comes back.    */\n\nconst noop = () => () => {};\nconst stamp = () => ({ now: Date.now() });\n\nfunction useNow(at: number, live: boolean, absoluteAfter: number) {\n  const [clock] = useState(stamp);\n  const subscribe = useCallback(\n    (notify: () => void) => {\n      let timer = 0;\n      const tick = () => {\n        window.clearTimeout(timer);\n        clock.now = Date.now();\n        notify();\n        if (!live || document.hidden || Number.isNaN(at)) return;\n        const next = resolve(at, clock.now, absoluteAfter).next;\n        if (next != null) timer = window.setTimeout(tick, next);\n      };\n      const onVisibility = () => (document.hidden ? window.clearTimeout(timer) : tick());\n      tick();\n      document.addEventListener(\"visibilitychange\", onVisibility);\n      return () => {\n        window.clearTimeout(timer);\n        document.removeEventListener(\"visibilitychange\", onVisibility);\n      };\n    },\n    [clock, at, live, absoluteAfter],\n  );\n  // The server can't know the reader's clock: it renders a fixed date, and the\n  // client swaps in the relative words right after hydration without a mismatch.\n  return useSyncExternalStore(\n    subscribe,\n    () => clock.now,\n    () => null,\n  );\n}\n\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(\n    noop,\n    () => Intl.DateTimeFormat().resolvedOptions().locale,\n    () => \"en-US\",\n  );\n  return locale ?? detected;\n}\n\nexport type UseRelativeTimeOptions = {\n  format?: RelativeTimeFormat;\n  numeric?: \"auto\" | \"always\";\n  absoluteAfter?: number;\n  live?: boolean;\n  locale?: string;\n};\n\n/** The words on their own: `{ text, parts, absolute, full, iso }`, updating on the same schedule. */\nexport function useRelativeTime(\n  date: DateInput,\n  { format = \"long\", numeric = \"auto\", absoluteAfter = WEEK, live = true, locale: localeProp }: UseRelativeTimeOptions = {},\n) {\n  const at = toTime(date);\n  const locale = useLocale(localeProp);\n  const now = useNow(at, live, absoluteAfter);\n\n  return useMemo(() => {\n    const valid = !Number.isNaN(at);\n    const d = new Date(valid ? at : 0);\n    const iso = valid ? d.toISOString() : undefined;\n    if (!valid)\n      return {\n        text: \"Unknown date\",\n        parts: null,\n        absolute: \"Unknown date\",\n        full: null,\n        iso,\n        unit: null,\n      };\n    if (now == null) {\n      const text = new Intl.DateTimeFormat(\"en-US\", {\n        dateStyle: \"medium\",\n        timeZone: \"UTC\",\n      }).format(d);\n      return { text, parts: null, absolute: text, full: null, iso, unit: null };\n    }\n    const sameYear = new Date(now).getFullYear() === d.getFullYear();\n    const absolute = new Intl.DateTimeFormat(locale, sameYear ? { month: \"short\", day: \"numeric\" } : { dateStyle: \"medium\" }).format(d);\n    const zone = new Intl.DateTimeFormat(locale, { timeZoneName: \"long\" }).formatToParts(d).find((p) => p.type === \"timeZoneName\")?.value;\n    const offset = new Intl.DateTimeFormat(locale, {\n      timeZoneName: \"shortOffset\",\n    })\n      .formatToParts(d)\n      .find((p) => p.type === \"timeZoneName\")?.value;\n    const full = {\n      date: new Intl.DateTimeFormat(locale, {\n        dateStyle: \"full\",\n        timeStyle: \"short\",\n      }).format(d),\n      zone: [zone, offset].filter(Boolean).join(\" · \"),\n    };\n    const r = resolve(at, now, absoluteAfter);\n    if (r.kind === \"absolute\") return { text: absolute, parts: null, absolute, full, iso, unit: null };\n\n    const rtf = new Intl.RelativeTimeFormat(locale, { style: format, numeric });\n    const signed = r.future ? r.value : -r.value;\n    const raw = rtf.formatToParts(signed === 0 ? 0 : signed, r.unit);\n    // Split around the number so it can roll while the words stay put: [\"\", 3, \" minutes ago\"].\n    const first = raw.findIndex((p) => p.type !== \"literal\");\n    let last = -1;\n    raw.forEach((p, i) => {\n      if (p.type !== \"literal\") last = i;\n    });\n    const parts =\n      first < 0\n        ? null\n        : {\n            before: raw\n              .slice(0, first)\n              .map((p) => p.value)\n              .join(\"\"),\n            value: r.value,\n            after: raw\n              .slice(last + 1)\n              .map((p) => p.value)\n              .join(\"\"),\n          };\n    return {\n      text: raw.map((p) => p.value).join(\"\"),\n      parts,\n      absolute,\n      full,\n      iso,\n      unit: r.unit,\n    };\n  }, [at, now, locale, format, numeric, absoluteAfter]);\n}\n\n/* ------------------------------------------------------------------------ */\n\nexport type RelativeTimeProps = Omit<React.ComponentProps<\"time\">, \"children\" | \"dateTime\"> & {\n  /** A Date, a timestamp in ms, or anything Date.parse reads (ISO strings). */\n  date: DateInput;\n  /** How much to spell out: \"3 minutes ago\", \"3 min. ago\", \"3m ago\". */\n  format?: RelativeTimeFormat;\n  /** \"auto\" says \"yesterday\" and \"now\"; \"always\" says \"1 day ago\" and \"0 seconds ago\". */\n  numeric?: \"auto\" | \"always\";\n  /** Milliseconds after which it shows the date instead (\"12 Sep\"). Pass Infinity to stay relative. */\n  absoluteAfter?: number;\n  /** Keep the words current. Off, it renders once. */\n  live?: boolean;\n  /** Show the full date, time and zone on hover and focus. */\n  tooltip?: boolean;\n  locale?: string;\n};\n\nexport function RelativeTime({\n  date,\n  format = \"long\",\n  numeric = \"auto\",\n  absoluteAfter = WEEK,\n  live = true,\n  tooltip = true,\n  locale,\n  className,\n  ...rest\n}: RelativeTimeProps) {\n  const reduce = useReducedMotion();\n  const t = useRelativeTime(date, {\n    format,\n    numeric,\n    absoluteAfter,\n    live,\n    locale,\n  });\n\n  const body = (\n    <time\n      dateTime={t.iso}\n      data-state={t.unit ? \"relative\" : \"absolute\"}\n      data-unit={t.unit ?? undefined}\n      className={cn(\"tabular whitespace-nowrap\", className)}\n      {...rest}\n    >\n      {t.parts ? (\n        <>\n          {t.parts.before}\n          {/* Keyed by unit: 50 seconds becoming 1 minute swaps, it doesn't roll backwards. */}\n          {/* NumberFlow's mask makes it taller than the line; a line-high box keeps rows from growing. */}\n          <span className=\"inline-flex h-[1lh] items-center align-top\">\n            <NumberFlow\n              key={t.unit}\n              aria-hidden\n              value={t.parts.value}\n              locales={locale}\n              animated={!reduce}\n              transformTiming={{\n                duration: 420,\n                easing: `cubic-bezier(${ease.out.join(\",\")})`,\n              }}\n              spinTiming={{\n                duration: 420,\n                easing: `cubic-bezier(${ease.out.join(\",\")})`,\n              }}\n              opacityTiming={{ duration: 180, easing: \"ease-out\" }}\n            />\n          </span>\n          <span className=\"sr-only\">{t.parts.value}</span>\n          {t.parts.after}\n        </>\n      ) : (\n        t.text\n      )}\n    </time>\n  );\n\n  if (!tooltip || !t.full) return body;\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger delay={500} render={body} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"origin-(--transform-origin) rounded-lg border border-line-2 bg-raised px-2.5 py-1.5 text-left shadow-pop\",\n              \"transition-[opacity,scale,filter] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none\",\n            )}\n          >\n            <p className=\"whitespace-nowrap text-[12px] leading-4 text-fg\">{t.full.date}</p>\n            {t.full.zone && <p className=\"mt-0.5 whitespace-nowrap text-[11px] leading-4 text-fg-3\">{t.full.zone}</p>}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/relative-time"
}
