{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "type": "registry:ui",
  "title": "Timeline",
  "description": "Activity grouped by day on a rail, where new events grow in at the top.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/timeline.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport { Collapsible } from \"@base-ui/react/collapsible\";\nimport { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from \"motion/react\";\nimport { useMemo, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { ChevronRight, Loader } from \"@/lib/icons\";\n\nexport type TimelineTone = \"neutral\" | \"success\" | \"warning\" | \"danger\" | \"info\";\n\nexport type TimelineEvent = {\n  /** Stable id. New ids that arrive after mount animate in. */\n  id: string;\n  date: Date | string | number;\n  /** The event, usually with the actor in it: “Maya merged #1290”. */\n  title: React.ReactNode;\n  /** One line under the title. */\n  description?: React.ReactNode;\n  /** Hidden behind a Details toggle: a diff, a log excerpt, the full comment. */\n  details?: React.ReactNode;\n  /** 14px glyph in the rail node. */\n  icon?: React.ReactNode;\n  /** Draws the node as this person's avatar instead of an icon. */\n  actor?: { name: string; avatarSrc?: string };\n  /** Colors the node's icon. Meaning only: a failed deploy, a resolved incident. */\n  tone?: TimelineTone;\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Time\n * -----------------------------------------------------------------------------------------------*/\n\n// A clock shared by every timeline on the page, ticking every 30s. On the server and during\n// hydration it reads null, so relative labels only ever render on the client.\nconst ticker = (() => {\n  const listeners = new Set<() => void>();\n  let now = 0;\n  let timer: number | undefined;\n  const read = () => Math.floor(Date.now() / 30000) * 30000;\n  return {\n    subscribe(fn: () => void) {\n      listeners.add(fn);\n      if (listeners.size === 1) {\n        now = read();\n        timer = window.setInterval(() => {\n          const next = read();\n          if (next !== now) {\n            now = next;\n            listeners.forEach((l) => l());\n          }\n        }, 5000);\n      }\n      return () => {\n        listeners.delete(fn);\n        if (!listeners.size) window.clearInterval(timer);\n      };\n    },\n    get: () => (now ||= read()),\n  };\n})();\n\n/** The current time to the nearest 30 seconds, or null before hydration. */\nexport function useNow() {\n  return useSyncExternalStore(ticker.subscribe, ticker.get, () => null);\n}\n\nconst toDate = (d: TimelineEvent[\"date\"]) => (d instanceof Date ? d : new Date(d));\nconst dayKey = (d: Date, utc: boolean) => (utc ? d.toISOString().slice(0, 10) : `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`);\nconst startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n\nfunction dayLabel(d: Date, now: number | null, locale: string) {\n  if (now === null) return new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: \"numeric\", timeZone: \"UTC\" }).format(d);\n  const days = Math.round((startOfDay(new Date(now)) - startOfDay(d)) / 86400000);\n  if (days === 0 || days === 1) {\n    const s = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(-days, \"day\");\n    return s.charAt(0).toLocaleUpperCase(locale) + s.slice(1);\n  }\n  if (days > 1 && days < 7) return new Intl.DateTimeFormat(locale, { weekday: \"long\" }).format(d);\n  const sameYear = new Date(now).getFullYear() === d.getFullYear();\n  return new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: sameYear ? undefined : \"numeric\" }).format(d);\n}\n\nfunction timeLabel(d: Date, now: number | null, locale: string) {\n  const clock = new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", timeZone: now === null ? \"UTC\" : undefined }).format(d);\n  if (now === null) return clock;\n  const s = (now - d.getTime()) / 1000;\n  // Recent events read as an age; anything older than a few hours reads as a time of day,\n  // because the day header already says which day.\n  if (s < 45) return \"Just now\";\n  const rtf = new Intl.RelativeTimeFormat(locale, { style: \"narrow\", numeric: \"always\" });\n  if (s < 3600) return rtf.format(-Math.max(1, Math.round(s / 60)), \"minute\");\n  if (s < 4 * 3600) return rtf.format(-Math.floor(s / 3600), \"hour\");\n  return clock;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Timeline\n * -----------------------------------------------------------------------------------------------*/\n\nexport type TimelineProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Newest first. Grouped by the viewer's local day. */\n  events: TimelineEvent[];\n  locale?: string;\n  /** Day headers stick to the top of the nearest scroll container. Set `--timeline-surface` to the background behind the timeline so they cover what scrolls under them. */\n  stickyHeaders?: boolean;\n  /** Skeleton rows while the first page loads. */\n  loading?: boolean;\n  /** Shown when there are no events and nothing is loading. */\n  empty?: React.ReactNode;\n  /** There is older history: the rail continues, dashed, into a Show older button. */\n  hasMore?: boolean;\n  onLoadMore?: () => void;\n  loadingMore?: boolean;\n};\n\nexport function Timeline({\n  events,\n  locale = \"en-US\",\n  stickyHeaders = true,\n  loading = false,\n  empty = \"No activity yet\",\n  hasMore = false,\n  onLoadMore,\n  loadingMore = false,\n  className,\n  ...rest\n}: TimelineProps) {\n  const now = useNow();\n  const reduce = useReducedMotion();\n  // The first events shown are history. Later ids newer than all of them are news and get the\n  // entrance; older pages loaded at the bottom don't. The snapshot waits for loading to finish.\n  const [history, setHistory] = useState<{ ids: Set<string>; newest: number } | null>(null);\n  if (!loading && history === null) {\n    setHistory({ ids: new Set(events.map((e) => e.id)), newest: Math.max(-Infinity, ...events.map((e) => toDate(e.date).getTime())) });\n  }\n  const isFresh = (e: TimelineEvent & { at: Date }) => !!history && !history.ids.has(e.id) && e.at.getTime() > history.newest;\n\n  const groups = useMemo(() => {\n    const out: { key: string; label: string; items: (TimelineEvent & { at: Date })[] }[] = [];\n    for (const e of events) {\n      const at = toDate(e.date);\n      const key = dayKey(at, now === null);\n      const last = out[out.length - 1];\n      if (last?.key === key) last.items.push({ ...e, at });\n      else out.push({ key, label: dayLabel(at, now, locale), items: [{ ...e, at }] });\n    }\n    return out;\n  }, [events, now, locale]);\n\n  if (loading) return <TimelineSkeleton className={className} {...rest} />;\n  if (!events.length)\n    return (\n      <div className={cn(\"flex items-center gap-3 py-2 text-[13px] text-fg-3\", className)} {...rest}>\n        <span aria-hidden className=\"size-2 shrink-0 rounded-full border border-dashed border-fg-4 ml-2 mr-1\" />\n        {empty}\n      </div>\n    );\n\n  // Arrivals grow from zero height, pushing history down rather than jumping it. Clipping is\n  // only on while growing, so sticky headers, focus rings and the arrival ring aren't cut.\n  const enter: Enter = reduce\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 } }\n    : {\n        initial: { height: 0, opacity: 0, overflow: \"hidden\" },\n        animate: { height: \"auto\", opacity: 1, transitionEnd: { overflow: \"visible\" } },\n        transition: { height: { duration: 0.34, ease: ease.out }, opacity: { duration: 0.24, delay: 0.06, ease: ease.out } },\n      };\n\n  return (\n    <div data-slot=\"timeline\" className={cn(\"relative text-[13px]\", className)} {...rest}>\n      {/* Before hydration days are grouped in UTC; the local regrouping swaps in without animating. */}\n      <AnimatePresence key={now === null ? \"server\" : \"client\"} initial={false}>\n        {groups.map((g, gi) => (\n          <motion.section\n            key={g.key}\n            aria-label={g.label}\n            {...enter}\n            exit={{ opacity: 0, height: 0, overflow: \"hidden\", transition: { duration: 0.18 } }}\n          >\n            <h3\n              className={cn(\n                \"relative z-[1] flex h-8 items-center pl-9 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase\",\n                stickyHeaders && \"sticky top-0 bg-(--timeline-surface,var(--frame))\",\n                // The rail runs through every header but the first.\n                gi > 0 && \"before:absolute before:top-0 before:bottom-0 before:left-[11.5px] before:w-px before:bg-line-2\",\n              )}\n            >\n              <span suppressHydrationWarning>{g.label}</span>\n            </h3>\n            <ol>\n              <AnimatePresence initial={false}>\n                {g.items.map((e, i) => (\n                  <Item\n                    key={e.id}\n                    event={e}\n                    at={e.at}\n                    now={now}\n                    locale={locale}\n                    fresh={isFresh(e)}\n                    first={gi === 0 && i === 0}\n                    last={gi === groups.length - 1 && i === g.items.length - 1 && !hasMore}\n                    enter={enter}\n                    reduce={!!reduce}\n                  />\n                ))}\n              </AnimatePresence>\n            </ol>\n          </motion.section>\n        ))}\n      </AnimatePresence>\n\n      {hasMore && (\n        <div className=\"relative flex items-center pt-1 pl-9\">\n          {/* The rail keeps going, dashed: there is more above the fold of history. */}\n          <span\n            aria-hidden\n            className=\"absolute top-0 left-[11.5px] h-full w-px bg-[linear-gradient(var(--line-2)_50%,transparent_50%)] bg-[length:1px_4px]\"\n          />\n          <button\n            type=\"button\"\n            onClick={onLoadMore}\n            aria-busy={loadingMore || undefined}\n            className={cn(\n              \"relative -ml-2 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-2 select-none\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n              loadingMore && \"pointer-events-none\",\n            )}\n          >\n            <span className={cn(\"transition-opacity duration-150\", loadingMore && \"opacity-0\")}>Show older activity</span>\n            {loadingMore && (\n              <span className=\"absolute inset-0 grid place-items-center\">\n                <Loader size={14} className=\"animate-spin-slow\" />\n                <span className=\"sr-only\">Loading older activity</span>\n              </span>\n            )}\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n\nconst toneClass: Record<TimelineTone, string> = {\n  neutral: \"text-fg-2\",\n  success: \"text-success\",\n  warning: \"text-warning\",\n  danger: \"text-danger\",\n  info: \"text-info\",\n};\n\ntype Enter = Pick<HTMLMotionProps<\"li\">, \"initial\" | \"animate\" | \"transition\">;\n\nfunction Item({\n  event,\n  at,\n  now,\n  locale,\n  fresh,\n  first,\n  last,\n  enter,\n  reduce,\n}: {\n  event: TimelineEvent;\n  at: Date;\n  now: number | null;\n  locale: string;\n  fresh: boolean;\n  first: boolean;\n  last: boolean;\n  enter: Enter;\n  reduce: boolean;\n}) {\n  const { title, description, details, icon, actor, tone = \"neutral\" } = event;\n  const absolute = new Intl.DateTimeFormat(locale, { dateStyle: \"medium\", timeStyle: \"short\", timeZone: now === null ? \"UTC\" : undefined }).format(\n    at,\n  );\n\n  return (\n    <motion.li {...enter} exit={{ opacity: 0, height: 0, overflow: \"hidden\", transition: { duration: 0.18 } }} className=\"relative\">\n      {/* The rail: from this node's center down, and up to the previous one unless this is the first. */}\n      <span\n        aria-hidden\n        className={cn(\"absolute left-[11.5px] w-px bg-line-2\", first ? \"top-3\" : \"top-0\", last ? \"h-3\" : \"bottom-0\", first && last && \"hidden\")}\n      />\n      <motion.div\n        initial={fresh && !reduce ? { y: -6, filter: \"blur(2px)\" } : false}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: 0.34, ease: ease.out }}\n        className=\"relative grid grid-cols-[24px_minmax(0,1fr)] gap-x-3 pb-4\"\n      >\n        <Node icon={icon} actor={actor} tone={tone} fresh={fresh} reduce={reduce} />\n        <div className=\"min-w-0 pt-0.5\">\n          <div className=\"flex items-baseline gap-3\">\n            <p className=\"min-w-0 flex-1 leading-5 text-pretty text-fg [&_b]:font-medium [&_strong]:font-medium\">{title}</p>\n            <time\n              dateTime={at.toISOString()}\n              title={absolute}\n              suppressHydrationWarning\n              className=\"shrink-0 text-[12px] leading-5 whitespace-nowrap text-fg-3 tabular\"\n            >\n              {timeLabel(at, now, locale)}\n            </time>\n          </div>\n          {description && <p className=\"mt-0.5 text-[12.5px] leading-[18px] text-pretty text-fg-2\">{description}</p>}\n          {details && <Details>{details}</Details>}\n        </div>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nfunction Node({\n  icon,\n  actor,\n  tone,\n  fresh,\n  reduce,\n}: {\n  icon?: React.ReactNode;\n  actor?: TimelineEvent[\"actor\"];\n  tone: TimelineTone;\n  fresh: boolean;\n  reduce: boolean;\n}) {\n  return (\n    <span className=\"relative grid size-6 place-items-center\">\n      {fresh && !reduce && (\n        // One ring, once, on arrival: it marks what's new without looping for attention.\n        <motion.span\n          aria-hidden\n          className=\"absolute inset-0 rounded-full border border-fg-3\"\n          initial={{ scale: 1, opacity: 0.6 }}\n          animate={{ scale: 1.9, opacity: 0 }}\n          transition={{ duration: 0.8, ease: ease.out, delay: 0.2 }}\n        />\n      )}\n      <motion.span\n        initial={fresh && !reduce ? { scale: 0.6 } : false}\n        animate={{ scale: 1 }}\n        transition={{ type: \"spring\", stiffness: 520, damping: 30, mass: 0.7, delay: 0.08 }}\n        className={cn(\n          \"relative grid size-6 place-items-center overflow-hidden rounded-full border border-line-2 bg-raised\",\n          !actor && toneClass[tone],\n          tone !== \"neutral\" && !actor && \"border-current/30\",\n        )}\n      >\n        {actor ? (\n          <Avatar.Root className=\"grid size-full place-items-center bg-fg/[0.1] text-[9.5px] font-medium text-fg-2 select-none\">\n            <Avatar.Image src={actor.avatarSrc} alt=\"\" width={24} height={24} className=\"col-start-1 row-start-1 size-full object-cover\" />\n            <Avatar.Fallback className=\"col-start-1 row-start-1\">{initials(actor.name)}</Avatar.Fallback>\n          </Avatar.Root>\n        ) : (\n          <span className=\"grid place-items-center [&>svg]:size-3.5\">{icon ?? <span className=\"size-1.5 rounded-full bg-current\" />}</span>\n        )}\n      </motion.span>\n    </span>\n  );\n}\n\nfunction Details({ children }: { children: React.ReactNode }) {\n  return (\n    <Collapsible.Root className=\"mt-0.5 -mb-1\">\n      <Collapsible.Trigger\n        className={cn(\n          \"group/details relative -ml-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[12px] text-fg-3 select-none\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg-2 active:scale-[0.97] active:duration-75 data-panel-open:text-fg-2\",\n          \"before:absolute before:-inset-y-2.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n        )}\n      >\n        <ChevronRight\n          size={14}\n          className=\"-ml-0.5 transition-transform duration-200 ease-out-quart group-data-panel-open/details:rotate-90 motion-reduce:transition-none\"\n        />\n        Details\n      </Collapsible.Trigger>\n      <Collapsible.Panel\n        className={cn(\n          \"h-(--collapsible-panel-height) overflow-hidden\",\n          \"transition-[height,opacity] duration-220 ease-out-quart data-ending-style:duration-150\",\n          \"data-starting-style:h-0 data-starting-style:opacity-0 data-ending-style:h-0 data-ending-style:opacity-0\",\n          \"motion-reduce:transition-[opacity]\",\n        )}\n      >\n        <div className=\"pt-1.5 pb-0.5\">\n          <div className=\"rounded-lg border border-line bg-fg/[0.025] px-3 py-2.5 text-[12.5px] leading-[18px] text-fg-2\">{children}</div>\n        </div>\n      </Collapsible.Panel>\n    </Collapsible.Root>\n  );\n}\n\nfunction initials(name: string) {\n  const words = name\n    .trim()\n    .split(/[\\s._]+/)\n    .filter(Boolean);\n  const first = (w: string) => Array.from(w)[0] ?? \"\";\n  return (words.length > 1 ? first(words[0]) + first(words[words.length - 1]) : first(words[0] ?? \"\")).toUpperCase();\n}\n\n// Three rows in the item's own geometry: node, title line with a time, a description line.\nfunction TimelineSkeleton({ className, ...rest }: React.ComponentProps<\"div\">) {\n  const fill = \"bg-fg/[0.07] animate-pulse-soft motion-reduce:animate-none\";\n  return (\n    <div aria-busy className={cn(\"relative\", className)} {...rest}>\n      <span className=\"sr-only\">Loading activity</span>\n      <div className=\"flex h-8 items-center pl-9\">\n        <span className={cn(fill, \"h-2.5 w-14 rounded-[4px]\")} />\n      </div>\n      {[0.72, 0.56, 0.64].map((w, i) => (\n        <div key={i} aria-hidden className=\"relative grid grid-cols-[24px_minmax(0,1fr)] gap-x-3 pb-4\">\n          {i < 2 && <span className={cn(\"absolute left-[11.5px] w-px bg-line-2\", i === 0 ? \"top-3\" : \"top-0\", \"bottom-0\")} />}\n          {i === 2 && <span className=\"absolute top-0 left-[11.5px] h-3 w-px bg-line-2\" />}\n          <span className={cn(fill, \"relative size-6 rounded-full\")} />\n          <div className=\"pt-0.5\">\n            <div className=\"flex h-5 items-center gap-3\">\n              <span className={cn(fill, \"h-3 rounded-[4px]\")} style={{ width: `${w * 100}%` }} />\n              <span className={cn(fill, \"ml-auto h-2.5 w-10 rounded-[4px]\")} />\n            </div>\n            <div className=\"mt-0.5 flex h-[18px] items-center\">\n              <span className={cn(fill, \"h-2.5 rounded-[4px]\")} style={{ width: `${w * 60}%` }} />\n            </div>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/timeline"
}
