{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"status-bars","type":"registry:component","title":"Status bars","description":"A status page that reads at a glance: ninety days a service drawn up bar by bar, one card that glides between them, and incidents whose updates open in as they're posted.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency"],"files":[{"path":"registry/status-bars.tsx","type":"registry:component","content":"// Hairline UI · status-bars · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { RelativeTime, type DateLike } from \"./relative-time\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * STATUS BARS: a status page that reads at a glance\n *\n *   overall   one line says how things are; it morphs when a\n *             service changes (\"All systems operational\" →\n *             \"Some systems degraded\") and its dot changes\n *             colour with it\n *   days      ninety days a service, a bar a day, drawn up\n *             from the baseline left to right; thirty on a\n *             narrow card\n *   read      point at a day (or arrow along with the keys) and\n *             one card glides from bar to bar above them, saying\n *             what happened that day\n *   incidents an open incident shows its updates newest first;\n *             a new update opens in on top and the status pill\n *             morphs (Investigating → Identified → Monitoring →\n *             Resolved). Past incidents fold behind a toggle\n *\n * Everything is props: feed it from your own checks or from\n * Better Stack, Instatus or Atlassian Statuspage.\n * ───────────────────────────────────────────────────────── */\n\nexport type { DateLike };\nexport type ServiceStatus = \"operational\" | \"degraded\" | \"major\" | \"maintenance\";\nexport type DayStatus = \"operational\" | \"degraded\" | \"major\" | \"maintenance\" | \"none\";\n\nexport interface StatusDay {\n  /** \"2026-10-14\". */\n  date: string;\n  status: DayStatus;\n  /** Minutes the service was degraded or down that day. */\n  minutes?: number;\n  /** Incident titles that touched this day. */\n  incidents?: string[];\n}\n\nexport interface StatusService {\n  id: string;\n  name: string;\n  status: ServiceStatus;\n  /** Uptime over the days shown, as a percentage. */\n  uptime?: number;\n  /** Oldest first; the last is today. */\n  days: StatusDay[];\n}\n\nexport type IncidentStage = \"investigating\" | \"identified\" | \"monitoring\" | \"resolved\";\n\nexport interface StatusIncident {\n  id: string;\n  title: string;\n  impact: \"minor\" | \"major\" | \"maintenance\";\n  stage: IncidentStage;\n  /** Newest first. */\n  updates: { id: string; at: DateLike; stage: IncidentStage; message: string }[];\n  /** Names of affected services. */\n  services?: string[];\n}\n\nexport interface StatusBarsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  services: StatusService[];\n  incidents?: StatusIncident[];\n  updatedAt?: DateLike;\n  title?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst BAR: Record<DayStatus, string> = {\n  operational: \"bg-emerald-500/80\",\n  degraded: \"bg-amber-500\",\n  major: \"bg-red-500\",\n  maintenance: \"bg-foreground/35\",\n  none: \"bg-muted shadow-[inset_0_0_0_1px_var(--border)]\",\n};\nconst WORD: Record<ServiceStatus, string> = { operational: \"Operational\", degraded: \"Degraded\", major: \"Outage\", maintenance: \"Maintenance\" };\nconst WORD_TONE: Record<ServiceStatus, string> = {\n  operational: \"text-emerald-600 dark:text-emerald-400\",\n  degraded: \"text-amber-700 dark:text-amber-400\",\n  major: \"text-red-600 dark:text-red-400\",\n  maintenance: \"text-muted-foreground\",\n};\nconst STAGES: Record<IncidentStage, string> = { investigating: \"Investigating\", identified: \"Identified\", monitoring: \"Monitoring\", resolved: \"Resolved\" };\nconst NONE: StatusIncident[] = [];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst DAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst dayLabel = (iso: string) => {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  const t = new Date(y, m - 1, d);\n  return `${DAYS[t.getDay()]} ${d} ${MONTHS[m - 1]}`;\n};\nconst span = (min: number) => (min < 60 ? `${min} min` : `${Math.floor(min / 60)} h${min % 60 ? ` ${min % 60} min` : \"\"}`);\nconst said = (d: StatusDay) =>\n  d.status === \"none\"\n    ? \"No data\"\n    : d.status === \"operational\"\n      ? \"No incidents\"\n      : d.status === \"maintenance\"\n        ? `Maintenance${d.minutes ? ` for ${span(d.minutes)}` : \"\"}`\n        : `${d.status === \"major\" ? \"Down\" : \"Degraded\"}${d.minutes ? ` for ${span(d.minutes)}` : \"\"}`;\n\nfunction overall(services: StatusService[]) {\n  if (services.some((s) => s.status === \"major\")) return { text: \"Major outage\", tone: \"major\" as const };\n  if (services.some((s) => s.status === \"degraded\")) return { text: \"Some systems degraded\", tone: \"degraded\" as const };\n  if (services.some((s) => s.status === \"maintenance\")) return { text: \"Maintenance in progress\", tone: \"maintenance\" as const };\n  return { text: \"All systems operational\", tone: \"operational\" as const };\n}\n\n/** A sentence that changes by lifting the old one out and the new one in. */\nfunction Line({ text, className = \"\", reduced }: { text: string; className?: string; reduced: boolean }) {\n  const [shown, setShown] = React.useState(text);\n  const [old, setOld] = React.useState<{ k: number; t: string }[]>([]);\n  const key = React.useRef(0);\n  const ref = React.useRef<HTMLSpanElement>(null);\n  if (text !== shown) {\n    if (!reduced) setOld((o) => [...o, { k: ++key.current, t: shown }]);\n    setShown(text);\n  }\n  React.useLayoutEffect(() => {\n    if (!reduced && old.length) ref.current?.animate([{ opacity: 0, transform: \"translateY(0.3em)\", filter: \"blur(4px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 420, delay: 80, easing: EASE, fill: \"backwards\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [shown]);\n  return (\n    <span className={`relative inline-block ${className}`}>\n      <span ref={ref} className=\"inline-block\">\n        {shown}\n      </span>\n      {old.map((o) => (\n        <Gone key={o.k} text={o.t} onDone={() => setOld((all) => all.filter((x) => x.k !== o.k))} />\n      ))}\n    </span>\n  );\n}\n\nfunction Gone({ text, onDone }: { text: string; onDone: () => void }) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const a = ref.current?.animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, transform: \"translateY(-0.3em)\", filter: \"blur(4px)\" }], { duration: 220, easing: EASE, fill: \"forwards\" });\n    if (a) a.onfinish = onDone;\n    return () => a?.cancel();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  return (\n    <span ref={ref} aria-hidden=\"true\" className=\"pointer-events-none absolute top-0 left-0 whitespace-nowrap\">\n      {text}\n    </span>\n  );\n}\n\nfunction Service({ s, reduced }: { s: StatusService; reduced: boolean }) {\n  const id = React.useId();\n  const barsRef = React.useRef<HTMLDivElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const [active, setActive] = React.useState<number | null>(null);\n  const [place, setPlace] = React.useState({ x: 0, ready: false });\n  const [keyed, setKeyed] = React.useState(false);\n  const [spoken, setSpoken] = React.useState(\"\");\n  const last = s.days.length - 1;\n\n  // The bars draw up from the baseline, left to right, once.\n  React.useLayoutEffect(() => {\n    if (reduced) return;\n    barsRef.current?.querySelectorAll<HTMLElement>(\"[data-bar]\").forEach((b, i) =>\n      b.animate([{ transform: \"scaleY(0)\" }, { transform: \"none\" }], { duration: 520, delay: 60 + i * 5, easing: EASE, fill: \"backwards\" }),\n    );\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // The card glides to sit over the day, kept inside the row.\n  React.useLayoutEffect(() => {\n    if (active === null) return;\n    const bars = barsRef.current;\n    const card = cardRef.current;\n    const bar = bars?.querySelector<HTMLElement>(`[data-bar=\"${active}\"]`);\n    if (!bars || !card || !bar) return;\n    const center = bar.offsetLeft + bar.offsetWidth / 2;\n    const x = Math.max(0, Math.min(bars.offsetWidth - card.offsetWidth, center - card.offsetWidth / 2));\n    setPlace((p) => (p.x === x && p.ready ? p : { x, ready: true }));\n  }, [active]);\n\n  const day = active === null ? null : s.days[active];\n  const move = (to: number) => {\n    const visible = [...(barsRef.current?.querySelectorAll<HTMLElement>(\"[data-bar]\") ?? [])].filter((b) => b.offsetParent !== null).map((b) => Number(b.dataset.bar));\n    const first = visible[0] ?? 0;\n    const i = Math.max(first, Math.min(last, to));\n    setActive(i);\n    setKeyed(true);\n    const d = s.days[i];\n    setSpoken(`${dayLabel(d.date)}: ${said(d)}${d.incidents?.length ? `. ${d.incidents.join(\". \")}` : \"\"}`);\n  };\n\n  return (\n    <div className=\"py-4 first:pt-0 last:pb-0\">\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <p className=\"flex min-w-0 items-baseline gap-2\">\n          <span id={`${id}-name`} className=\"truncate text-[13.5px] font-medium tracking-[-0.01em]\">\n            {s.name}\n          </span>\n          <span className={`shrink-0 text-[11.5px] transition-colors duration-500 ${WORD_TONE[s.status]}`}>\n            <TextMorph>{WORD[s.status]}</TextMorph>\n          </span>\n        </p>\n        {s.uptime !== undefined && (\n          <p className=\"shrink-0 text-[11.5px] text-muted-foreground tabular-nums\">\n            <NumberRoll value={s.uptime / 100} format={{ style: \"percent\", minimumFractionDigits: 2, maximumFractionDigits: 2 }} /> uptime\n          </p>\n        )}\n      </div>\n\n      <div className=\"relative mt-2\">\n        {/* One card for every day: it glides between bars instead of a tooltip per bar. */}\n        <div\n          ref={cardRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-full left-0 z-10 mb-2 w-[13.5rem] rounded-[12px] bg-popover px-3 py-2 text-popover-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n          style={{\n            transform: `translateX(${place.x}px) translateY(${day ? 0 : 4}px)`,\n            opacity: day ? 1 : 0,\n            transition: reduced ? \"none\" : `transform ${place.ready && day ? 320 : 0}ms ${EASE}, opacity 180ms ${EASE}`,\n          }}\n        >\n          {day && (\n            <>\n              <p className=\"text-[11.5px] text-muted-foreground\">{dayLabel(day.date)}</p>\n              <p className={`mt-0.5 text-[12.5px] font-medium ${day.status === \"major\" ? \"text-red-600 dark:text-red-400\" : day.status === \"degraded\" ? \"text-amber-700 dark:text-amber-400\" : \"\"}`}>{said(day)}</p>\n              {day.incidents?.map((t) => (\n                <p key={t} className=\"mt-1 text-[11.5px] leading-[1.4] text-muted-foreground\">\n                  {t}\n                </p>\n              ))}\n            </>\n          )}\n        </div>\n\n        <div\n          ref={barsRef}\n          role=\"group\"\n          tabIndex={0}\n          aria-labelledby={`${id}-name`}\n          aria-describedby={`${id}-help`}\n          onPointerMove={(e) => {\n            const bar = (e.target as HTMLElement).closest<HTMLElement>(\"[data-bar]\");\n            if (bar) {\n              setKeyed(false);\n              setActive(Number(bar.dataset.bar));\n            }\n          }}\n          onPointerLeave={() => !keyed && setActive(null)}\n          onBlur={() => setActive(null)}\n          onKeyDown={(e) => {\n            const at = active ?? last;\n            if (e.key === \"ArrowLeft\") move(at - 1);\n            else if (e.key === \"ArrowRight\") move(at + 1);\n            else if (e.key === \"Home\") move(0);\n            else if (e.key === \"End\") move(last);\n            else if (e.key === \"Escape\") return setActive(null);\n            else return;\n            e.preventDefault();\n          }}\n          onFocus={() => active === null && move(last)}\n          className={`flex h-8 items-stretch gap-[2px] rounded-[4px] ${FOCUS} focus-visible:ring-offset-2 focus-visible:ring-offset-background`}\n        >\n          {s.days.map((d, i) => (\n            <span\n              key={d.date}\n              data-bar={i}\n              className={`min-w-[2px] flex-1 origin-bottom rounded-[2px] transition-[background-color,opacity] duration-500 ${BAR[d.status]} ${i < s.days.length - 30 ? \"hidden @lg:block\" : \"\"}`}\n              style={{ opacity: active === null || active === i ? 1 : 0.55 }}\n            />\n          ))}\n        </div>\n        <span id={`${id}-help`} className=\"sr-only\">\n          {`${s.name} is ${WORD[s.status].toLowerCase()}. Arrow keys read each day.`}\n        </span>\n        <span className=\"sr-only\" role=\"status\">\n          {keyed ? spoken : \"\"}\n        </span>\n      </div>\n\n      <div className=\"mt-1.5 flex justify-between text-[11px] text-muted-foreground\">\n        <span>\n          <span className=\"@lg:hidden\">{Math.min(30, s.days.length)} days ago</span>\n          <span className=\"hidden @lg:inline\">{s.days.length} days ago</span>\n        </span>\n        <span>Today</span>\n      </div>\n    </div>\n  );\n}\n\nfunction Incident({ inc, reduced, open, onToggle }: { inc: StatusIncident; reduced: boolean; open: boolean; onToggle?: () => void }) {\n  const seen = React.useRef(new Set(inc.updates.map((u) => u.id)));\n  const listRef = React.useRef<HTMLOListElement>(null);\n  // A new update opens in on top.\n  React.useLayoutEffect(() => {\n    const fresh = inc.updates.filter((u) => !seen.current.has(u.id));\n    fresh.forEach((u) => seen.current.add(u.id));\n    if (reduced || !fresh.length) return;\n    for (const u of fresh) {\n      const el = listRef.current?.querySelector<HTMLElement>(`[data-update=\"${CSS.escape(u.id)}\"]`);\n      el?.animate([{ opacity: 0, transform: \"translateY(-6px)\", filter: \"blur(4px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 460, easing: EASE });\n    }\n  }, [inc.updates, reduced]);\n  const tick = inc.stage === \"resolved\" ? \"bg-emerald-500\" : inc.impact === \"major\" ? \"bg-red-500\" : inc.impact === \"minor\" ? \"bg-amber-500\" : \"bg-foreground/35\";\n  const updates = open ? inc.updates : inc.updates.slice(0, 1);\n  return (\n    <article className=\"relative rounded-[14px] py-3 pr-3 pl-4 shadow-[inset_0_0_0_1px_var(--border)]\">\n      <span aria-hidden=\"true\" className={`absolute top-3 bottom-3 left-0 w-0.5 rounded-full transition-colors duration-500 ${tick}`} />\n      <div className=\"flex items-start justify-between gap-3\">\n        <h3 className=\"min-w-0 text-[13.5px] font-medium tracking-[-0.01em]\">{inc.title}</h3>\n        <span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-500 ${inc.stage === \"resolved\" ? \"text-emerald-600 dark:text-emerald-400\" : \"text-foreground\"}`}>\n          <TextMorph>{STAGES[inc.stage]}</TextMorph>\n        </span>\n      </div>\n      {inc.services?.length ? <p className=\"mt-0.5 text-[11.5px] text-muted-foreground\">{inc.services.join(\", \")}</p> : null}\n      <ol ref={listRef} className=\"mt-2.5 space-y-2.5\">\n        {updates.map((u) => (\n          <li key={u.id} data-update={u.id} className=\"grid grid-cols-[5.5rem_minmax(0,1fr)] gap-x-3 text-[12.5px]\">\n            <span className=\"text-[11.5px] text-muted-foreground\">\n              <span className=\"block font-medium text-foreground\">{STAGES[u.stage]}</span>\n              <RelativeTime date={u.at} />\n            </span>\n            <p className=\"leading-[1.5] text-foreground/90\">{u.message}</p>\n          </li>\n        ))}\n      </ol>\n      {onToggle && inc.updates.length > 1 && (\n        <button type=\"button\" aria-expanded={open} onClick={onToggle} className={`mt-2 -ml-2 h-7 cursor-pointer rounded-full px-2 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}>\n          <TextMorph>{open ? \"Fewer updates\" : `All ${inc.updates.length} updates`}</TextMorph>\n        </button>\n      )}\n    </article>\n  );\n}\n\nexport function StatusBars({ services, incidents = NONE, updatedAt, title = \"Status\", className = \"\", ...props }: StatusBarsProps) {\n  const reduced = useReducedMotion();\n  const [showPast, setShowPast] = React.useState(false);\n  const [openPast, setOpenPast] = React.useState<string | null>(null);\n  const now = overall(services);\n  const active = incidents.filter((i) => i.stage !== \"resolved\");\n  const past = incidents.filter((i) => i.stage === \"resolved\");\n  const dot = { operational: \"bg-emerald-500\", degraded: \"bg-amber-500\", major: \"bg-red-500\", maintenance: \"bg-foreground/40\" }[now.tone];\n\n  return (\n    <div className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-border px-4 py-4 @md:px-5\">\n        <h2 className=\"flex items-center gap-2.5 text-[17px] font-medium tracking-[-0.02em] @md:text-[19px]\">\n          <span className=\"relative grid size-2.5 place-items-center\" aria-hidden=\"true\">\n            <span className={`size-2.5 rounded-full transition-colors duration-500 ${dot}`} />\n            {now.tone !== \"operational\" && !reduced && <span className={`absolute inset-0 rounded-full opacity-40 animate-[ui-ping_1.8s_ease-out_infinite] ${dot}`} />}\n          </span>\n          <span className=\"sr-only\">{title}: </span>\n          <Line text={now.text} reduced={reduced} />\n        </h2>\n        {updatedAt !== undefined && (\n          <p className=\"text-[11.5px] text-muted-foreground\">\n            <RelativeTime date={updatedAt} prefix=\"Updated\" />\n          </p>\n        )}\n      </div>\n\n      {active.length > 0 && (\n        <section aria-label=\"Open incidents\" className=\"space-y-2.5 border-b border-border px-4 py-4 @md:px-5\">\n          {active.map((inc) => (\n            <Incident key={inc.id} inc={inc} reduced={reduced} open />\n          ))}\n        </section>\n      )}\n\n      <section aria-label=\"Services\" className=\"divide-y divide-border px-4 py-4 @md:px-5\">\n        {services.map((s) => (\n          <Service key={s.id} s={s} reduced={reduced} />\n        ))}\n      </section>\n\n      {past.length > 0 && (\n        <section aria-label=\"Past incidents\" className=\"border-t border-border px-4 py-3 @md:px-5\">\n          <button\n            type=\"button\"\n            aria-expanded={showPast}\n            onClick={() => setShowPast((v) => !v)}\n            className={`-ml-2 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full px-2 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n          >\n            <TextMorph>{showPast ? \"Hide past incidents\" : \"Past incidents\"}</TextMorph>\n            <span className=\"tabular-nums\">\n              <NumberRoll value={past.length} />\n            </span>\n          </button>\n          <div className=\"grid\" inert={!showPast} style={{ gridTemplateRows: showPast ? \"1fr\" : \"0fr\", opacity: showPast ? 1 : 0, transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE}, opacity 300ms ${EASE}` }}>\n            <div className=\"min-h-0 min-w-0 overflow-clip\">\n              <div className=\"space-y-2.5 pt-2.5 pb-1\">\n                {past.map((inc) => (\n                  <Incident key={inc.id} inc={inc} reduced={reduced} open={openPast === inc.id} onToggle={() => setOpenPast((o) => (o === inc.id ? null : inc.id))} />\n                ))}\n              </div>\n            </div>\n          </div>\n        </section>\n      )}\n    </div>\n  );\n}\n"},{"path":"registry/relative-time.tsx","type":"registry:component","content":"// Hairline UI · status-bars · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * RELATIVE TIME: \"2 min ago\" that keeps itself true\n *\n *   tick      it re-renders only when the words would change:\n *             on the next minute under an hour, the next hour\n *             under a day; nothing runs between\n *   roll      the number rolls up (\"2 min\" → \"3 min\") and the\n *             unit morphs when it changes (\"59 min\" → \"1 h\")\n *   date      past a week it settles on the date (\"12 Sep\")\n *\n * Until it's mounted it renders the date, so the server and the\n * first paint agree; the hover title is always the full time.\n * ───────────────────────────────────────────────────────── */\n\nexport type DateLike = Date | string | number;\n\nexport interface RelativeTimeProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  date: DateLike;\n  /** Said before the time, e.g. \"Last used\". */\n  prefix?: string;\n  /** Under a minute. */\n  justNow?: string;\n  locales?: string | string[];\n}\n\nconst MINUTE = 60_000;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\nexport const toTime = (d: DateLike) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : new Date(d).getTime());\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\n/** \"12 Sep\", with the year when it isn't this one; pass locales to use the browser's own format. */\nexport function formatDate(d: DateLike, locales?: string | string[]) {\n  const t = new Date(toTime(d));\n  const sameYear = t.getFullYear() === new Date().getFullYear();\n  if (locales) return t.toLocaleDateString(locales, sameYear ? { day: \"numeric\", month: \"short\" } : { day: \"numeric\", month: \"short\", year: \"numeric\" });\n  return `${t.getDate()} ${MONTHS[t.getMonth()]}${sameYear ? \"\" : ` ${t.getFullYear()}`}`;\n}\n\ntype Parts = { n: number | null; unit: string; next: number };\n\nfunction parts(at: number, now: number, justNow: string, locales?: string | string[]): Parts {\n  const ago = Math.max(0, now - at);\n  if (ago < MINUTE) return { n: null, unit: justNow, next: MINUTE - ago };\n  if (ago < HOUR) return { n: Math.floor(ago / MINUTE), unit: \"min ago\", next: MINUTE - (ago % MINUTE) };\n  if (ago < DAY) {\n    const h = Math.floor(ago / HOUR);\n    return { n: h, unit: h === 1 ? \"hour ago\" : \"hours ago\", next: HOUR - (ago % HOUR) };\n  }\n  if (ago < 7 * DAY) {\n    const d = Math.floor(ago / DAY);\n    return { n: d, unit: d === 1 ? \"day ago\" : \"days ago\", next: DAY - (ago % DAY) };\n  }\n  return { n: null, unit: formatDate(at, locales), next: 0 };\n}\n\nconst subscribeMounted = () => () => {};\n\nexport function RelativeTime({ date, prefix, justNow = \"just now\", locales, className = \"\", ...props }: RelativeTimeProps) {\n  const at = toTime(date);\n  const mounted = React.useSyncExternalStore(subscribeMounted, () => true, () => false);\n  const [now, setNow] = React.useState(() => Date.now());\n  const p = parts(at, mounted ? now : at + 8 * DAY, justNow, locales);\n\n  // One timer, set for the moment the words change.\n  React.useEffect(() => {\n    if (!mounted || !p.next) return;\n    const timer = window.setTimeout(() => setNow(Date.now()), p.next + 50);\n    return () => window.clearTimeout(timer);\n  }, [mounted, p.next, now]);\n\n  React.useEffect(() => setNow(Date.now()), [at]);\n\n  const iso = new Date(at).toISOString();\n  return (\n    <time\n      dateTime={iso}\n      title={new Date(at).toLocaleString(locales ?? \"en-GB\", { dateStyle: \"medium\", timeStyle: \"short\" })}\n      className={`inline-flex items-baseline whitespace-nowrap tabular-nums ${className}`}\n      {...props}\n    >\n      {prefix && <span>{prefix}&nbsp;</span>}\n      {/* Remounted once on the client, so the first real words appear without a morph. */}\n      <Words key={mounted ? \"client\" : \"server\"} n={p.n} unit={p.unit} />\n    </time>\n  );\n}\n\nfunction Words({ n, unit }: { n: number | null; unit: string }) {\n  return (\n    <>\n      <span\n        aria-hidden={n === null || undefined}\n        className=\"inline-grid transition-[grid-template-columns] duration-[380ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none\"\n        style={{ gridTemplateColumns: n === null ? \"0fr\" : \"1fr\" }}\n      >\n        <span className=\"min-w-0 [clip-path:inset(-4px_0)]\">\n          <span className=\"inline-flex items-baseline pr-[0.25em]\">\n            <NumberRoll value={n ?? 1} direction=\"up\" />\n          </span>\n        </span>\n      </span>\n      <TextMorph>{unit}</TextMorph>\n    </>\n  );\n}\n"}],"css":{"@keyframes ui-ping":{"from":{"transform":"scale(1)","opacity":"0.55"},"to":{"transform":"scale(2.6)","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/status-bars. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}