{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/timeline/timeline.tsx",
      "type": "registry:component",
      "target": "@components/xzero/timeline/timeline.tsx",
      "content": "\"use client\";\n\nimport { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } 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 styles from \"./timeline.module.css\";\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: \"merged Checkout redesign into main\". */\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 = .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(noopSubscribe, () => true, () => false);\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 <span className={styles.rise} aria-hidden=\"true\">\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n      <motion.span key={text} className={styles.riseLine} custom={direction}\n        variants={{\n          from: (dir: number) => reduced ? { opacity: 0 } : { opacity: 0, y: `${.3 * dir}em`, filter: `blur(${motionTokens.blur.soft}px)` },\n          to: { opacity: 1, y: \"0em\", filter: \"blur(0px)\" },\n          gone: (dir: number) => reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: `${-.3 * dir}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } },\n        }}\n        initial=\"from\" animate=\"to\" exit=\"gone\" transition={{ duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard, ease: enter }}>{text}</motion.span>\n    </AnimatePresence>\n  </span>;\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)];\n  return <span className={styles.rolling} aria-hidden=\"true\">\n    {chars.map((char, index) => <span key={chars.length - index} className={styles.place}><RiseText text={char} reduced={reduced} direction={state.direction} /></span>)}\n  </span>;\n}\n\nfunction dayKey(time: number, timeZone: string) {\n  const parts = new Intl.DateTimeFormat(\"en-CA\", { timeZone, year: \"numeric\", month: \"2-digit\", day: \"2-digit\" }).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({ id, fresh, reduced, children }: { id: string; fresh: boolean; reduced: boolean; children: ReactNode }) {\n  const [entering, setEntering] = useState(fresh);\n  return <motion.section className={styles.group} aria-labelledby={id} data-entering={entering || undefined}\n    initial={fresh ? { height: 0, opacity: 0 } : false} animate={{ height: \"auto\", opacity: 1 }} exit={{ height: 0, opacity: 0, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.exit, ease: standard } }}\n    transition={reduced ? { duration: .15 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter } }}\n    onAnimationComplete={() => setEntering(false)}>\n    {children}\n  </motion.section>;\n}\n\nfunction TimelineRow({ row, last, expanded, onToggle, timeLabel, timeFull }: { row: Row; last: boolean; expanded: boolean; onToggle: () => void; timeLabel: string; timeFull: string }) {\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 ? .12 : 0);\n  const pop: Transition = reduced ? { duration: 0, opacity: { duration: .15 } } : { ...motionTokens.spring.morph, visualDuration: .36, bounce: 0, delay, opacity: { duration: motionTokens.duration.fast, ease: enter, delay } };\n  const draw: Transition = reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .34, delay: delay + .1 };\n  const tone = row.avatar ? undefined : row.tone ?? \"neutral\";\n  const Trigger = row.detail ? \"button\" : \"div\";\n\n  return <motion.li className={styles.item} data-entering={entering || undefined}\n    initial={fresh ? { height: 0 } : false} animate={{ height: \"auto\" }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: .1 } : { height: { ...motionTokens.spring.smooth, visualDuration: .3 }, opacity: { duration: .12 } } }}\n    transition={reduced ? { duration: 0 } : motionTokens.spring.smooth} onAnimationComplete={() => setEntering(false)}\n    onViewportEnter={() => setRevealDelay(current => current ?? clock.schedule())} viewport={{ once: true, amount: .2 }}>\n    <motion.span className={styles.marker} data-tone={tone} aria-hidden=\"true\" initial={{ scale: .4, opacity: 0 }} animate={shown ? { scale: 1, opacity: 1 } : undefined} transition={pop}>\n      {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic */}\n      {row.avatar ? <img src={row.avatar} alt=\"\" width={28} height={28} decoding=\"async\" /> : row.icon}\n    </motion.span>\n    {!last && <motion.span className={styles.segment} aria-hidden=\"true\" initial={{ scaleY: 0 }} animate={shown ? { scaleY: 1 } : undefined} transition={draw} />}\n    <motion.div className={styles.content} initial={fresh ? (reduced ? { opacity: 0 } : { opacity: 0, y: -10, filter: `blur(${motionTokens.blur.soft}px)` }) : false} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n      transition={reduced ? { duration: .15 } : { y: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .05 }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: .05 } }}>\n      <Trigger className={styles.trigger} {...(row.detail ? { type: \"button\" as const, \"data-timeline-trigger\": \"\", \"aria-expanded\": expanded, \"aria-controls\": expanded ? detailId : undefined, onClick: onToggle } : {})}>\n        <span className={styles.text}>\n          <span className={styles.title}>{row.actor && <span className={styles.actor}>{row.actor}</span>}{row.actor ? \" \" : \"\"}{row.title}</span>\n          {row.meta && <span className={styles.meta}>{row.meta}</span>}\n        </span>\n        <time className={styles.time} dateTime={new Date(row.time).toISOString()} title={timeFull}><RiseText text={timeLabel} reduced={reduced} /><span className={styles.srOnly}>{timeFull}</span></time>\n        {row.detail && <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: expanded ? 180 : 0 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}><ChevronDown size={16} strokeWidth={1.75} /></motion.span>}\n      </Trigger>\n      <AnimatePresence initial={false}>\n        {expanded && row.detail && <motion.div key=\"detail\" id={detailId} className={styles.detail}\n          initial={{ height: 0 }} animate={{ height: \"auto\" }} exit={{ height: 0, transition: reduced ? { duration: 0 } : { ...motionTokens.spring.smooth, visualDuration: .28 } }}\n          transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}>\n          <motion.div className={styles.detailInner} initial={reduced ? { opacity: 0 } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }} transition={reduced ? { duration: .15 } : { duration: motionTokens.duration.standard, ease: enter, delay: .06 }}>{row.detail}</motion.div>\n        </motion.div>}\n      </AnimatePresence>\n    </motion.div>\n  </motion.li>;\n}\n\nexport function Timeline({ events, now, label, timeZone = \"UTC\", locale = \"en-US\", maxHeight, scrollToNew = true, defaultExpanded = [], headingLevel = 3, className }: TimelineProps) {\n  const reduced = useReducedMotionSafe();\n  const id = useId();\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(() => ({ ids, set: new Set(events.map(event => event.id)), fresh: new Set<string>(), announcement: \"\" }));\n  if (known.ids !== ids) {\n    const added = events.filter(event => !known.set.has(event.id));\n    setKnown({ ids, set: new Set(events.map(event => event.id)), fresh: new Set([...known.fresh, ...added.map(event => event.id)]), announcement: added.length ? `New update: ${added.map(event => [event.actor, event.title].filter(Boolean).join(\" \")).join(\". \")}` : known.announcement });\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, track = trackRef.current;\n    if (!scrolls || !scroller || !track || typeof ResizeObserver === \"undefined\") return;\n    const update = () => { if (scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop > 2) scroller.dataset.more = \"\"; else delete scroller.dataset.more; };\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 () => { scroller.removeEventListener(\"scroll\", update); observer.disconnect(); };\n  }, [scrolls]);\n\n  const newestId = events.reduce<TimelineEvent | null>((latest, event) => !latest || new Date(event.at).getTime() > new Date(latest.at).getTime() ? event : latest, null)?.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), 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].map(event => ({ ...event, time: new Date(event.at).getTime() })).sort((a, b) => b.time - a.time).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]) => ({ day, rows, label: day === today ? \"Today\" : day === yesterday ? \"Yesterday\" : heading.format(new Date(rows[0]!.time)) }));\n  }, [events, now, timeZone, locale]);\n  const formats = useMemo(() => ({\n    clock: new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", timeZone }),\n    full: new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", hour: \"numeric\", minute: \"2-digit\", timeZone }),\n  }), [locale, timeZone]);\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 + .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 => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; });\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 = event.key === \"Home\" ? 0 : event.key === \"End\" ? triggers.length - 1 : Math.min(Math.max(index + (event.key === \"ArrowDown\" ? 1 : -1), 0), triggers.length - 1);\n    triggers[next]?.focus();\n  }\n\n  return <RevealClock.Provider value={clock}>\n    <div className={[styles.root, className].filter(Boolean).join(\" \")} role=\"region\" aria-label={label}>\n      <div ref={scrollerRef} className={styles.scroller} data-scrolls={scrolls || undefined} style={scrolls ? { \"--timeline-height\": typeof maxHeight === \"number\" ? `${maxHeight}px` : maxHeight } as CSSProperties : undefined} onKeyDown={onKeyDown}>\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}><AnimatePresence>\n          {groups.map(group => <DaySection key={group.day} id={`${id}-${group.day}`} fresh={!initialDays.has(group.day)} reduced={reduced}>\n            <div className={styles.day} role=\"heading\" aria-level={headingLevel} id={`${id}-${group.day}`}>\n              <span>{group.label}</span>\n              <span className={styles.count} aria-hidden=\"true\"><RollingCount value={group.rows.length} reduced={reduced} /> {group.rows.length === 1 ? \"update\" : \"updates\"}</span>\n              <span className={styles.srOnly}>{`, ${group.rows.length} ${group.rows.length === 1 ? \"update\" : \"updates\"}`}</span>\n            </div>\n            <ol className={styles.list}>\n              <AnimatePresence>\n                {group.rows.map((row, index) => {\n                  const today = group.label === \"Today\" && now - row.time < 12 * HOUR;\n                  const time = today ? relative(row.time, now) : { short: formats.clock.format(new Date(row.time)), long: \"\" };\n                  return <TimelineRow key={row.id} row={row} last={index === group.rows.length - 1} expanded={expanded.has(row.id)} onToggle={() => toggle(row.id)}\n                    timeLabel={time.short} timeFull={today ? `${time.long}, ${formats.full.format(new Date(row.time))}` : formats.full.format(new Date(row.time))} />;\n                })}\n              </AnimatePresence>\n            </ol>\n          </DaySection>)}\n        </AnimatePresence></div>\n      </div>\n      <span className={styles.srOnly} role=\"status\">{known.announcement}</span>\n    </div>\n  </RevealClock.Provider>;\n}\n\nexport default Timeline;\n"
    },
    {
      "path": "registry/components/timeline/timeline.module.css",
      "type": "registry:component",
      "target": "@components/xzero/timeline/timeline.module.css",
      "content": "/* Geometry: a 28px marker column, the line centred under it, and rows whose first text line centres on the marker. */\n.root { --marker: 28px; --marker-top: 8px; --line-gap: 4px; position: relative; display: grid; min-width: 0; color: var(--foreground); }\n/* Inserting at the top pushes the rows down instead of the browser holding the scroll position still. */\n.scroller { min-width: 0; overflow-anchor: none; }\n@property --timeline-feather { syntax: \"<length>\"; inherits: false; initial-value: 0px; }\n.scroller[data-scrolls] { max-height: var(--timeline-height); overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; mask-image: linear-gradient(to bottom, #000 calc(100% - var(--timeline-feather)), transparent); transition: --timeline-feather var(--duration-standard) var(--ease-standard); }\n/* While more updates wait below, the last visible row fades into the edge instead of being cut. */\n.scroller[data-more] { --timeline-feather: 36px; }\n.group { position: relative; }\n.group[data-entering] { overflow: clip; }\n/* Day labels stay pinned while their updates scroll under them. Set --timeline-surface when the feed sits on another surface. */\n.day { position: sticky; z-index: 2; top: 0; display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); padding: var(--space-2) var(--space-3) var(--space-2) 0; background: var(--timeline-surface, var(--surface)); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n.count { color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; font-variant-numeric: tabular-nums; }\n.rolling { display: inline-flex; }\n.place { position: relative; display: inline-flex; overflow: clip; overflow-clip-margin: .15em; }\n.rise { position: relative; display: inline-flex; }\n.riseLine { display: inline-block; white-space: nowrap; }\n.list { margin: 0 0 var(--space-3); padding: 0; list-style: none; }\n\n.item { position: relative; padding-left: calc(var(--marker) + var(--space-2)); }\n.item[data-entering] { overflow: clip; }\n.marker { position: absolute; z-index: 1; top: var(--marker-top); left: 0; display: grid; width: var(--marker); height: var(--marker); place-items: center; border-radius: var(--radius-pill); color: var(--text-secondary); }\n.marker img { display: block; width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }\n/* A person's portrait sits on a hairline ring; a system event is a quiet node with its icon. */\n.marker::after { content: \"\"; position: absolute; inset: 0; border: 1px solid color-mix(in oklab, var(--foreground) 10%, transparent); border-radius: inherit; pointer-events: none; }\n.marker[data-tone] { background: var(--surface); }\n.marker[data-tone=\"success\"] { background: color-mix(in oklab, var(--success) 12%, var(--surface)); color: var(--success); }\n.marker[data-tone=\"danger\"] { background: color-mix(in oklab, var(--danger) 12%, var(--surface)); color: var(--danger); }\n.marker svg { width: 14px; height: 14px; stroke-width: 2; }\n/* The line runs from just under this marker to just above the next one and draws downward from its top. */\n.segment { position: 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); width: 1px; background: var(--border-strong); transform-origin: 50% 0; }\n\n.content { min-width: 0; }\n.trigger { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: var(--space-3); margin: 0; padding: 12px var(--space-3); border: 0; border-radius: var(--radius-control); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\nbutton.trigger { grid-template-columns: minmax(0, 1fr) auto 16px; cursor: pointer; }\n@media (hover: hover) and (pointer: fine) { button.trigger:hover { background: var(--surface-muted); } }\nbutton.trigger:active { background: var(--surface-muted); }\n.text { display: grid; min-width: 0; gap: 2px; }\n.title { color: var(--text-secondary); font-size: var(--text-sm); line-height: 20px; }\n.actor { color: var(--foreground); font-weight: 500; }\n.meta { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n/* Times keep a steady column, so \"9m\" becoming \"10m\" never rewraps the title beside it. */\n.time { display: flex; min-width: 2.75rem; justify-content: flex-end; color: var(--text-muted); font-size: var(--text-xs); line-height: 20px; font-variant-numeric: tabular-nums; white-space: nowrap; }\n.chevron { display: grid; height: 20px; place-items: center; color: var(--text-muted); }\n.detail { overflow: clip; overflow-clip-margin: 4px; }\n.detailInner { padding: 0 var(--space-3) var(--space-4); color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 420px) { .trigger { column-gap: var(--space-2); padding-inline: var(--space-2); } .detailInner { padding-inline: var(--space-2); } }\n@media (prefers-reduced-motion: reduce) { .trigger { transition: none; } .scroller[data-scrolls] { transition: --timeline-feather var(--duration-instant) var(--ease-standard); } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/timeline",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "feed",
      "activity"
    ],
    "docs": "https://ui.xzero.co.uk/components/timeline",
    "markdown": "https://ui.xzero.co.uk/components/timeline/markdown"
  }
}