{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/timeline/timeline.tsx","type":"registry:component","target":"@components/arc/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 };\nconst RevealClock = createContext(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 \n 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: .14, ease: standard } },\n }}\n initial=\"from\" animate=\"to\" exit=\"gone\" transition={{ duration: reduced ? .15 : .22, ease: enter }}>{text}\n \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)];\n return \n {chars.map((char, index) => )}\n ;\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 setEntering(false)}>\n {children}\n ;\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(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: .32, 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 setEntering(false)}\n onViewportEnter={() => setRevealDelay(current => current ?? clock.schedule())} viewport={{ once: true, amount: .2 }}>\n \n {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic */}\n {row.avatar ? \"\" : row.icon}\n \n {!last && }\n \n \n \n {row.actor && {row.actor}}{row.actor ? \" \" : \"\"}{row.title}\n {row.meta && {row.meta}}\n \n \n {row.detail && }\n \n \n {expanded && row.detail && \n {row.detail}\n }\n \n \n ;\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(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(), 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(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((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(() => {\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();\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(() => ({ 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) {\n if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(event.key)) return;\n const triggers = [...(scrollerRef.current?.querySelectorAll(\"[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 \n
\n
\n {/* Presence starts enabled so markers keep their hidden first frame; rows and days present at mount opt out of entering themselves. */}\n
\n {groups.map(group => \n
\n {group.label}\n {group.rows.length === 1 ? \"update\" : \"updates\"}\n {`, ${group.rows.length} ${group.rows.length === 1 ? \"update\" : \"updates\"}`}\n
\n
    \n \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 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 \n
\n
)}\n
\n
\n {known.announcement}\n
\n
;\n}\n\nexport default Timeline;\n"},{"path":"registry/components/timeline/timeline.module.css","type":"registry:component","target":"@components/arc/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: \"\"; 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 oklch, 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 oklch, var(--success) 12%, var(--surface)); color: var(--success); }\n.marker[data-tone=\"danger\"] { background: color-mix(in oklch, 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); }\nbutton.trigger:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }\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 150ms linear; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/timeline","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/timeline","markdown":"https://uiarc.dev/components/timeline/markdown","tags":["data","feed","activity"]}}