{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "type": "registry:ui",
  "title": "Timeline",
  "description": "Follow what happened, newest first, grouped by day.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/timeline/timeline.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type { CSSProperties, KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { ChevronDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface TimelineEvent {\n  id: string;\n  /** When it happened, as an ISO string or epoch milliseconds. */\n  at: string | number;\n  /** Who did it, shown first in the foreground color. */\n  actor?: string;\n  /** What happened, completing the actor: \"created preview database pr-142 from shop\". */\n  title: string;\n  /** Short context under the title, such as a pull request or a build. */\n  meta?: string;\n  /** Revealed in place when the row is expanded. Rows without detail are not interactive. */\n  detail?: ReactNode;\n  /** Portrait for an event by a person. */\n  avatar?: string;\n  /** Icon for a system event, used when there is no avatar. */\n  icon?: ReactNode;\n  /** Status of a system event. Always say the outcome in the title too, so it never rests on color. */\n  tone?: \"neutral\" | \"success\" | \"danger\";\n}\n\n/**\n * A vertical activity feed grouped by day, for project history, audit logs, and deploy streams. Use it when order and\n * recency matter; use a table when people need to sort or compare. Day labels stay pinned while their updates scroll,\n * the connecting line draws itself as rows come into view, rows expand in place, and new updates slide in at the top\n * while the rest glide down. Arrow keys move between rows, Enter or Space expands one.\n */\nexport interface TimelineProps {\n  /** Updates in any order; the newest shows first. */\n  events: TimelineEvent[];\n  /** Reference time for relative labels and day groups, in epoch milliseconds. Pass a ticking clock to keep labels fresh. */\n  now: number;\n  /** Accessible name for the feed. */\n  label: string;\n  /** Time zone for day groups and clock times. Fixed by default so server and client agree. */\n  timeZone?: string;\n  locale?: string;\n  /** Height of the scrolling area. Without it the feed grows with the page and reveals on page scroll. */\n  maxHeight?: number | string;\n  /** Scroll back to the top when a new update arrives while the feed is scrolled down. */\n  scrollToNew?: boolean;\n  defaultExpanded?: string[];\n  /** Heading level for the day labels. */\n  headingLevel?: 2 | 3 | 4 | 5 | 6;\n  className?: string;\n}\n\ntype Row = TimelineEvent & { time: number; day: string };\ntype Group = { day: string; label: string; rows: Row[] };\n\nconst HOUR = 3_600_000;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\n/** Seconds between rows revealed together, so the line reads as drawing downward. */\nconst STEP = 0.09;\nconst noopSubscribe = () => () => {};\n\n/** Reduced motion only after hydration, so the server and the first client render agree. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    noopSubscribe,\n    () => true,\n    () => false,\n  );\n  const reduced = useReducedMotion();\n  return hydrated && !!reduced;\n}\n\n/** Rows reveal against the viewport, which already clips by the scroll area, so they wait until they are visible in both. */\ntype Clock = { schedule: () => number; reduced: boolean; fresh: Set<string> };\nconst RevealClock = createContext<Clock | null>(null);\n\n/** Text that changes in place: the new value rises in from a soft blur while the old one lifts away a little faster. */\nfunction RiseText({ text, reduced, direction = 1 }: { text: string; reduced: boolean; direction?: number }) {\n  return (\n    <span className=\"relative inline-flex\" aria-hidden=\"true\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.span\n          key={text}\n          className=\"inline-block whitespace-nowrap\"\n          custom={direction}\n          variants={{\n            from: (dir: number) =>\n              reduced\n                ? { opacity: 0 }\n                : { opacity: 0, y: `${0.3 * dir}em`, filter: `blur(${motionTokens.blur.soft}px)` },\n            to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n            gone: (dir: number) =>\n              reduced\n                ? { opacity: 0, transition: { duration: 0 } }\n                : {\n                    opacity: 0,\n                    y: `${-0.3 * dir}em`,\n                    filter: `blur(${motionTokens.blur.subtle}px)`,\n                    transition: { duration: motionTokens.duration.fast, ease: standard },\n                  },\n          }}\n          initial=\"from\"\n          animate=\"to\"\n          exit=\"gone\"\n          transition={{\n            duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard,\n            ease: enter,\n          }}\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/** The day's update count rolls digit by digit in the direction it moved. */\nfunction RollingCount({ value, reduced }: { value: number; reduced: boolean }) {\n  const [state, setState] = useState({ value, direction: 1 });\n  if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });\n  const chars = String(value).split(\"\");\n  return (\n    <span className=\"inline-flex\" aria-hidden=\"true\">\n      {chars.map((char, index) => (\n        <span key={chars.length - index} className=\"relative inline-flex overflow-clip [overflow-clip-margin:.15em]\">\n          <RiseText text={char} reduced={reduced} direction={state.direction} />\n        </span>\n      ))}\n    </span>\n  );\n}\n\nfunction dayKey(time: number, timeZone: string) {\n  const parts = new Intl.DateTimeFormat(\"en-CA\", {\n    timeZone,\n    year: \"numeric\",\n    month: \"2-digit\",\n    day: \"2-digit\",\n  }).formatToParts(new Date(time));\n  const get = (type: string) => parts.find((part) => part.type === type)?.value ?? \"\";\n  return `${get(\"year\")}-${get(\"month\")}-${get(\"day\")}`;\n}\n\nfunction relative(time: number, now: number) {\n  const minutes = Math.max(0, Math.floor((now - time) / 60_000));\n  if (minutes < 1) return { short: \"Now\", long: \"just now\" };\n  if (minutes < 60) return { short: `${minutes}m`, long: `${minutes} ${minutes === 1 ? \"minute\" : \"minutes\"} ago` };\n  const hours = Math.floor(minutes / 60);\n  return { short: `${hours}h`, long: `${hours} ${hours === 1 ? \"hour\" : \"hours\"} ago` };\n}\n\n/** A day's section. A new day opens from nothing and pushes the older days down; it clips only while it grows. */\nfunction DaySection({\n  id,\n  fresh,\n  reduced,\n  children,\n}: {\n  id: string;\n  fresh: boolean;\n  reduced: boolean;\n  children: ReactNode;\n}) {\n  const [entering, setEntering] = useState(fresh);\n  return (\n    <motion.section\n      className={cn(\"relative\", entering && \"overflow-clip\")}\n      aria-labelledby={id}\n      data-entering={entering || undefined}\n      initial={fresh ? { height: 0, opacity: 0 } : false}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.exit, ease: standard },\n      }}\n      transition={\n        reduced\n          ? { duration: 0.15 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter } }\n      }\n      onAnimationComplete={() => setEntering(false)}\n    >\n      {children}\n    </motion.section>\n  );\n}\n\nfunction TimelineRow({\n  row,\n  last,\n  expanded,\n  onToggle,\n  timeLabel,\n  timeFull,\n}: {\n  row: Row;\n  last: boolean;\n  expanded: boolean;\n  onToggle: () => void;\n  timeLabel: string;\n  timeFull: string;\n}) {\n  const clock = useContext(RevealClock)!;\n  const { reduced } = clock;\n  const detailId = useId();\n  // The row reveals once, the first time it scrolls into view: its marker pops, then its line draws toward the next row.\n  const [revealDelay, setRevealDelay] = useState<number | null>(null);\n  const fresh = clock.fresh.has(row.id);\n  const [entering, setEntering] = useState(fresh);\n  const shown = revealDelay !== null;\n  const delay = (revealDelay ?? 0) + (fresh ? 0.12 : 0);\n  const pop: Transition = reduced\n    ? { duration: 0, opacity: { duration: 0.15 } }\n    : {\n        ...motionTokens.spring.morph,\n        visualDuration: 0.36,\n        bounce: 0,\n        delay,\n        opacity: { duration: motionTokens.duration.fast, ease: enter, delay },\n      };\n  const draw: Transition = reduced\n    ? { duration: 0 }\n    : { ...motionTokens.spring.smooth, visualDuration: 0.34, delay: delay + 0.1 };\n  const tone = row.avatar ? undefined : (row.tone ?? \"neutral\");\n  const Trigger = row.detail ? \"button\" : \"div\";\n\n  return (\n    <motion.li\n      className={cn(\"relative pl-[calc(var(--marker)+var(--space-2))]\", entering && \"overflow-clip\")}\n      data-entering={entering || undefined}\n      initial={fresh ? { height: 0 } : false}\n      animate={{ height: \"auto\" }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0.1 }\n          : { height: { ...motionTokens.spring.smooth, visualDuration: 0.3 }, opacity: { duration: 0.12 } },\n      }}\n      transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}\n      onAnimationComplete={() => setEntering(false)}\n      onViewportEnter={() => setRevealDelay((current) => current ?? clock.schedule())}\n      viewport={{ once: true, amount: 0.2 }}\n    >\n      {/* A person's portrait sits on a hairline ring; a system event is a quiet node with its icon. */}\n      <motion.span\n        className={cn(\n          \"absolute top-(--marker-top) left-0 z-1 grid size-(--marker) place-items-center rounded-full text-ink-2 [&_svg]:size-3.5 [&_svg]:stroke-2\",\n          \"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border after:border-[color-mix(in_oklab,var(--ink)_10%,transparent)] after:content-['']\",\n          tone === \"success\"\n            ? \"bg-[color-mix(in_oklab,var(--green)_12%,var(--surface))] text-green\"\n            : tone === \"danger\"\n              ? \"bg-[color-mix(in_oklab,var(--red)_12%,var(--surface))] text-red\"\n              : tone && \"bg-surface\",\n        )}\n        data-tone={tone}\n        aria-hidden=\"true\"\n        initial={{ scale: 0.4, opacity: 0 }}\n        animate={shown ? { scale: 1, opacity: 1 } : undefined}\n        transition={pop}\n      >\n        {row.avatar ? (\n          // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic\n          <img\n            className=\"block size-full rounded-[inherit] object-cover\"\n            src={row.avatar}\n            alt=\"\"\n            width={28}\n            height={28}\n            decoding=\"async\"\n          />\n        ) : (\n          row.icon\n        )}\n      </motion.span>\n      {/* The line runs from just under this marker to just above the next one and draws downward from its top. */}\n      {!last && (\n        <motion.span\n          className=\"absolute top-[calc(var(--marker-top)_+_var(--marker)_+_var(--line-gap))] bottom-[calc(var(--line-gap)_-_var(--marker-top))] left-[calc(var(--marker)/2_-_.5px)] w-px origin-[50%_0] bg-line-strong\"\n          aria-hidden=\"true\"\n          initial={{ scaleY: 0 }}\n          animate={shown ? { scaleY: 1 } : undefined}\n          transition={draw}\n        />\n      )}\n      <motion.div\n        className=\"min-w-0\"\n        initial={\n          fresh\n            ? reduced\n              ? { opacity: 0 }\n              : { opacity: 0, y: -10, filter: `blur(${motionTokens.blur.soft}px)` }\n            : false\n        }\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        transition={\n          reduced\n            ? { duration: 0.15 }\n            : {\n                y: motionTokens.spring.smooth,\n                opacity: { duration: motionTokens.duration.standard, ease: enter, delay: 0.05 },\n                filter: { duration: motionTokens.duration.standard, ease: enter, delay: 0.05 },\n              }\n        }\n      >\n        <Trigger\n          className={cn(\n            \"m-0 grid w-full items-start gap-x-3 rounded-control border-0 bg-transparent p-3 text-left [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard max-[420px]:gap-x-2 max-[420px]:px-2 motion-reduce:transition-none\",\n            row.detail\n              ? \"cursor-pointer grid-cols-[minmax(0,1fr)_auto_16px] hover:bg-hover active:bg-hover\"\n              : \"grid-cols-[minmax(0,1fr)_auto]\",\n          )}\n          {...(row.detail\n            ? {\n                type: \"button\" as const,\n                \"data-timeline-trigger\": \"\",\n                \"aria-expanded\": expanded,\n                \"aria-controls\": expanded ? detailId : undefined,\n                onClick: onToggle,\n              }\n            : {})}\n        >\n          <span className=\"grid min-w-0 gap-0.5\">\n            <span className=\"text-(length:--text-sm) leading-[20px] text-ink-2\">\n              {row.actor && <span className=\"font-medium text-ink\">{row.actor}</span>}\n              {row.actor ? \" \" : \"\"}\n              {row.title}\n            </span>\n            {row.meta && <span className=\"text-(length:--text-xs) leading-body text-ink-3\">{row.meta}</span>}\n          </span>\n          {/* Times keep a steady column, so \"9m\" becoming \"10m\" never rewraps the title beside it. */}\n          <time\n            className=\"flex min-w-[2.75rem] justify-end text-(length:--text-xs) leading-[20px] whitespace-nowrap text-ink-3 tabular-nums\"\n            dateTime={new Date(row.time).toISOString()}\n            title={timeFull}\n          >\n            <RiseText text={timeLabel} reduced={reduced} />\n            <span className=\"sr-only\">{timeFull}</span>\n          </time>\n          {row.detail && (\n            <motion.span\n              className=\"grid h-5 place-items-center text-ink-3\"\n              aria-hidden=\"true\"\n              initial={false}\n              animate={{ rotate: expanded ? 180 : 0 }}\n              transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}\n            >\n              <ChevronDown size={16} strokeWidth={1.75} />\n            </motion.span>\n          )}\n        </Trigger>\n        <AnimatePresence initial={false}>\n          {expanded && row.detail && (\n            <motion.div\n              key=\"detail\"\n              id={detailId}\n              className=\"overflow-clip [overflow-clip-margin:4px]\"\n              initial={{ height: 0 }}\n              animate={{ height: \"auto\" }}\n              exit={{\n                height: 0,\n                transition: reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: 0.28 },\n              }}\n              transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}\n            >\n              <motion.div\n                className=\"px-3 pb-4 text-(length:--text-sm) leading-body text-ink-2 max-[420px]:px-2\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.soft}px)` }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }}\n                transition={\n                  reduced ? { duration: 0.15 } : { duration: motionTokens.duration.standard, ease: enter, delay: 0.06 }\n                }\n              >\n                {row.detail}\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nexport function Timeline({\n  events,\n  now,\n  label,\n  timeZone = \"UTC\",\n  locale = \"en-US\",\n  maxHeight,\n  scrollToNew = true,\n  defaultExpanded = [],\n  headingLevel = 3,\n  className,\n}: TimelineProps) {\n  const reduced = useReducedMotionSafe();\n  const id = useId();\n  const Heading = `h${headingLevel}` as const;\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const [expanded, setExpanded] = useState(() => new Set(defaultExpanded));\n\n  // Rows added after the first render are fresh: they slide in and the rest glide down. Their titles are announced.\n  const ids = events.map((event) => event.id).join(\"|\");\n  const [known, setKnown] = useState(() => ({\n    ids,\n    set: new Set(events.map((event) => event.id)),\n    fresh: new Set<string>(),\n    announcement: \"\",\n  }));\n  if (known.ids !== ids) {\n    const added = events.filter((event) => !known.set.has(event.id));\n    setKnown({\n      ids,\n      set: new Set(events.map((event) => event.id)),\n      fresh: new Set([...known.fresh, ...added.map((event) => event.id)]),\n      announcement: added.length\n        ? `New update: ${added.map((event) => [event.actor, event.title].filter(Boolean).join(\" \")).join(\". \")}`\n        : known.announcement,\n    });\n  }\n  // The bottom edge feathers while more updates wait below, and clears once the end is in view.\n  const trackRef = useRef<HTMLDivElement>(null);\n  const scrolls = maxHeight !== undefined;\n  useEffect(() => {\n    const scroller = scrollerRef.current,\n      track = trackRef.current;\n    if (!scrolls || !scroller || !track || typeof ResizeObserver === \"undefined\") return;\n    const update = () => {\n      if (scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 2) scroller.dataset.more = \"\";\n      else delete scroller.dataset.more;\n    };\n    update();\n    scroller.addEventListener(\"scroll\", update, { passive: true });\n    const observer = new ResizeObserver(update);\n    observer.observe(scroller);\n    observer.observe(track);\n    return () => {\n      scroller.removeEventListener(\"scroll\", update);\n      observer.disconnect();\n    };\n  }, [scrolls]);\n\n  const newestId = events.reduce<TimelineEvent | null>(\n    (latest, event) => (!latest || new Date(event.at).getTime() > new Date(latest.at).getTime() ? event : latest),\n    null,\n  )?.id;\n  useEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scrollToNew || !scroller || scroller.scrollTop < 1) return;\n    scroller.scrollTo({ top: 0, behavior: reduced ? \"auto\" : \"smooth\" });\n  }, [newestId, scrollToNew, reduced]);\n\n  const [initialDays] = useState(() => new Set(events.map((event) => dayKey(new Date(event.at).getTime(), timeZone))));\n  const groups = useMemo<Group[]>(() => {\n    const today = dayKey(now, timeZone),\n      yesterday = dayKey(now - 24 * HOUR, timeZone);\n    const heading = new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", timeZone });\n    const byDay = new Map<string, Row[]>();\n    [...events]\n      .map((event) => ({ ...event, time: new Date(event.at).getTime() }))\n      .sort((a, b) => b.time - a.time)\n      .forEach((event) => {\n        const day = dayKey(event.time, timeZone);\n        byDay.set(day, [...(byDay.get(day) ?? []), { ...event, day }]);\n      });\n    return [...byDay].map(([day, rows]) => ({\n      day,\n      rows,\n      label: day === today ? \"Today\" : day === yesterday ? \"Yesterday\" : heading.format(new Date(rows[0]!.time)),\n    }));\n  }, [events, now, timeZone, locale]);\n  const formats = useMemo(\n    () => ({\n      clock: new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", timeZone }),\n      full: new Intl.DateTimeFormat(locale, {\n        weekday: \"long\",\n        month: \"long\",\n        day: \"numeric\",\n        hour: \"numeric\",\n        minute: \"2-digit\",\n        timeZone,\n      }),\n    }),\n    [locale, timeZone],\n  );\n\n  // Rows that come into view together are spaced a beat apart, so the line reads as drawing down the feed.\n  const nextReveal = useRef(0);\n  const schedule = useCallback(() => {\n    if (reduced) return 0;\n    const current = performance.now() / 1000;\n    const start = Math.min(Math.max(current, nextReveal.current), current + 0.45);\n    nextReveal.current = start + STEP;\n    return start - current;\n  }, [reduced]);\n  const clock = useMemo<Clock>(() => ({ schedule, reduced, fresh: known.fresh }), [schedule, reduced, known.fresh]);\n\n  function toggle(id: string) {\n    setExpanded((current) => {\n      const next = new Set(current);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n  }\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n    const triggers = [...(scrollerRef.current?.querySelectorAll<HTMLElement>(\"[data-timeline-trigger]\") ?? [])];\n    const index = triggers.indexOf(document.activeElement as HTMLElement);\n    if (index < 0) return;\n    event.preventDefault();\n    const next =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? triggers.length - 1\n          : Math.min(Math.max(index + (event.key === \"ArrowDown\" ? 1 : -1), 0), triggers.length - 1);\n    triggers[next]?.focus();\n  }\n\n  return (\n    <RevealClock.Provider value={clock}>\n      {/* Geometry: a 28px marker column, the line centred under it, and rows whose first text line centres on the marker. */}\n      <section\n        className={cn(\"relative grid min-w-0 text-ink [--line-gap:4px] [--marker-top:8px] [--marker:28px]\", className)}\n        aria-label={label}\n      >\n        {/* Inserting at the top pushes the rows down instead of the browser holding the scroll position still. While more\n          updates wait below (data-more, set from the scroll handler), the last visible row fades into the edge instead of being cut. */}\n        {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- delegated arrow-key movement between the row buttons inside; the scroller itself is not a control */}\n        <div\n          ref={scrollerRef}\n          className={cn(\n            \"min-w-0 [overflow-anchor:none]\",\n            scrolls &&\n              \"max-h-(--timeline-height) overflow-y-auto overscroll-contain [scrollbar-color:var(--line-strong)_transparent] [scrollbar-width:thin] [mask-image:linear-gradient(to_bottom,#000_calc(100%_-_var(--timeline-feather)),transparent)] transition-[--timeline-feather] duration-standard ease-standard data-more:[--timeline-feather:36px] motion-reduce:duration-instant\",\n          )}\n          data-scrolls={scrolls || undefined}\n          style={\n            scrolls\n              ? ({ \"--timeline-height\": typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight } as CSSProperties)\n              : undefined\n          }\n          onKeyDown={onKeyDown}\n        >\n          {/* Presence starts enabled so markers keep their hidden first frame; rows and days present at mount opt out of entering themselves. */}\n          <div ref={trackRef}>\n            <AnimatePresence>\n              {groups.map((group) => (\n                <DaySection\n                  key={group.day}\n                  id={`${id}-${group.day}`}\n                  fresh={!initialDays.has(group.day)}\n                  reduced={reduced}\n                >\n                  {/* Day labels stay pinned while their updates scroll under them. Set --timeline-surface when the feed sits on another surface. */}\n                  <Heading\n                    className=\"sticky top-0 z-2 m-0 flex items-baseline justify-between gap-3 bg-[var(--timeline-surface,var(--surface))] py-2 pr-3 pl-0 text-(length:--text-sm) leading-body font-medium text-ink\"\n                    id={`${id}-${group.day}`}\n                  >\n                    <span>{group.label}</span>\n                    <span className=\"text-(length:--text-xs) font-normal text-ink-3 tabular-nums\" aria-hidden=\"true\">\n                      <RollingCount value={group.rows.length} reduced={reduced} />{\" \"}\n                      {group.rows.length === 1 ? \"update\" : \"updates\"}\n                    </span>\n                    <span className=\"sr-only\">{`, ${group.rows.length} ${group.rows.length === 1 ? \"update\" : \"updates\"}`}</span>\n                  </Heading>\n                  <ol className=\"mb-3 list-none p-0\">\n                    <AnimatePresence>\n                      {group.rows.map((row, index) => {\n                        const today = group.label === \"Today\" && now - row.time < 12 * HOUR;\n                        const time = today\n                          ? relative(row.time, now)\n                          : { short: formats.clock.format(new Date(row.time)), long: \"\" };\n                        return (\n                          <TimelineRow\n                            key={row.id}\n                            row={row}\n                            last={index === group.rows.length - 1}\n                            expanded={expanded.has(row.id)}\n                            onToggle={() => toggle(row.id)}\n                            timeLabel={time.short}\n                            timeFull={\n                              today\n                                ? `${time.long}, ${formats.full.format(new Date(row.time))}`\n                                : formats.full.format(new Date(row.time))\n                            }\n                          />\n                        );\n                      })}\n                    </AnimatePresence>\n                  </ol>\n                </DaySection>\n              ))}\n            </AnimatePresence>\n          </div>\n        </div>\n        <span className=\"sr-only\" role=\"status\">\n          {known.announcement}\n        </span>\n      </section>\n    </RevealClock.Provider>\n  );\n}\n\nexport default Timeline;\n",
      "type": "registry:component",
      "target": "@components/iris/timeline/timeline.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/timeline",
  "meta": {
    "kind": "component",
    "group": "Data display",
    "export": "Timeline",
    "tags": [
      "data",
      "feed",
      "activity"
    ],
    "docs": "https://irisui.dev/components/timeline"
  }
}
