A vertical activity timeline whose events slide in one after another, each with a springy color-coded inline-SVG icon (commit, comment, deploy, merge, alert, star) and a connector line that draws down to the next event. Relative times are derived deterministically from minutes-ago values.
Preview
Recent activity
Maya Okafor deployed web@v2.3.0 to production
Liam Chen merged pull request #482 Faster search index
Sofia Martinez commented on Checkout redesign
Noah Patel pushed 3 commits to feat/billing-v2
Monitoring flagged elevated latency on api-eu-west
Ava Thompson starred motionfoundry/ui
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add activity-feedWrites components/ui/activity-feed.tsx and components/styles/activity-feed.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ActivityFeed } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ActivityFeed />6 );7}Props
Accessibility
A <section> labelled by its heading (visible or visually hidden) containing an ordered list, so screen readers announce the number of events. Icons and connector lines are aria-hidden decoration; each event is plain text and its relative time is a <time> element with an ISO 8601 duration in dateTime. Under prefers-reduced-motion all events appear immediately with no slide, spring or line draw.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/activity-feed.module.css";6 7export type ActivityKind = "commit" | "comment" | "deploy" | "merge" | "alert" | "star";8 9export interface ActivityItem {10 /** Unique id */11 id: string;12 /** Who did it */13 actor: string;14 /** What happened (verb phrase) */15 action: string;16 /** What it happened to */17 target?: string;18 /** Icon / color category */19 kind: ActivityKind;20 /** How long ago, in minutes (deterministic — no clocks are read) */21 minutesAgo: number;22}23 24export interface ActivityFeedProps {25 /** Events, newest first */26 items?: ActivityItem[];27 /** Heading shown above the feed (also its accessible name) */28 title?: string;29 /** Show the heading visibly */30 showTitle?: boolean;31 /** Maximum events rendered */32 maxItems?: number;33 /** Delay between items animating in, in milliseconds */34 stagger?: number;35 /** Draw a vertical connector line between icons */36 showConnector?: boolean;37 /** Compact spacing */38 dense?: boolean;39 /** Accent color (commit icons, connector glow and focus tint) */40 accentColor?: string;41 /** Optional className merged onto the root */42 className?: string;43 /** Optional inline styles merged onto the root */44 style?: React.CSSProperties;45}46 47const DEFAULT_ITEMS: ActivityItem[] = [48 { id: "a1", actor: "Maya Okafor", action: "deployed", target: "web@v2.3.0 to production", kind: "deploy", minutesAgo: 2 },49 { id: "a2", actor: "Liam Chen", action: "merged pull request", target: "#482 Faster search index", kind: "merge", minutesAgo: 18 },50 { id: "a3", actor: "Sofia Martinez", action: "commented on", target: "Checkout redesign", kind: "comment", minutesAgo: 47 },51 { id: "a4", actor: "Noah Patel", action: "pushed 3 commits to", target: "feat/billing-v2", kind: "commit", minutesAgo: 95 },52 { id: "a5", actor: "Monitoring", action: "flagged elevated latency on", target: "api-eu-west", kind: "alert", minutesAgo: 260 },53 { id: "a6", actor: "Ava Thompson", action: "starred", target: "motionfoundry/ui", kind: "star", minutesAgo: 1680 },54];55 56const ICONS: Record<ActivityKind, React.ReactNode> = {57 commit: (58 <>59 <circle cx="12" cy="12" r="3.5" />60 <path d="M3 12h5.5M15.5 12H21" />61 </>62 ),63 comment: <path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z" />,64 deploy: <path d="M12 3c3 2 5 5.5 5 9l-2.5 3h-5L7 12c0-3.5 2-7 5-9ZM9.5 15 8 20l4-2 4 2-1.5-5M12 9.5v.01" />,65 merge: (66 <>67 <circle cx="6" cy="5" r="2" />68 <circle cx="6" cy="19" r="2" />69 <circle cx="18" cy="12" r="2" />70 <path d="M6 7v10M8 5h2a6 6 0 0 1 6 6" />71 </>72 ),73 alert: <path d="M12 4 2.5 20h19L12 4ZM12 10v4.5M12 17.5v.01" />,74 star: <path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9L12 3.5Z" />,75};76 77function relative(min: number) {78 const m = Math.max(0, Math.round(min));79 if (m < 1) return { text: "just now", iso: "PT0M" };80 if (m < 60) return { text: `${m}m ago`, iso: `PT${m}M` };81 const h = Math.floor(m / 60);82 if (h < 24) return { text: `${h}h ago`, iso: `PT${h}H` };83 const d = Math.floor(h / 24);84 return { text: d === 1 ? "yesterday" : `${d}d ago`, iso: `P${d}D` };85}86 87export function ActivityFeed({88 items = DEFAULT_ITEMS,89 title = "Recent activity",90 showTitle = true,91 maxItems = 6,92 stagger = 90,93 showConnector = true,94 dense = false,95 accentColor = "#6366f1",96 className = "",97 style,98}: ActivityFeedProps) {99 const reduce = useReducedMotion();100 const id = React.useId();101 const list = items.slice(0, Math.max(1, Math.floor(maxItems)));102 const step = Math.max(0, stagger) / 1000;103 104 return (105 <section106 aria-labelledby={`${id}-title`}107 className={`${styles.root} ${className}`.trim()}108 data-dense={dense ? "" : undefined}109 style={{ "--mf-af-accent": accentColor, ...style } as React.CSSProperties}110 >111 <h3 id={`${id}-title`} className={showTitle ? styles.title : styles.srOnly}>112 {title}113 </h3>114 <ol className={styles.list}>115 {list.map((item, i) => {116 const t = relative(item.minutesAgo);117 const last = i === list.length - 1;118 return (119 <motion.li120 key={item.id}121 className={styles.item}122 initial={reduce ? false : { opacity: 0, x: -10 }}123 animate={{ opacity: 1, x: 0 }}124 transition={reduce ? { duration: 0 } : { duration: 0.42, delay: i * step, ease: [0.16, 1, 0.3, 1] }}125 >126 <div className={styles.rail} aria-hidden="true">127 <motion.span128 className={styles.icon}129 data-kind={item.kind}130 initial={reduce ? false : { scale: 0.4 }}131 animate={{ scale: 1 }}132 transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 22, delay: i * step }}133 >134 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">135 {ICONS[item.kind]}136 </svg>137 </motion.span>138 {showConnector && !last && (139 <motion.span140 className={styles.line}141 initial={reduce ? false : { scaleY: 0 }}142 animate={{ scaleY: 1 }}143 transition={reduce ? { duration: 0 } : { duration: 0.4, delay: i * step + 0.15, ease: "easeOut" }}144 />145 )}146 </div>147 <div className={styles.body}>148 <p className={styles.text}>149 <span className={styles.actor}>{item.actor}</span> {item.action}150 {item.target && (151 <>152 {" "}153 <span className={styles.target}>{item.target}</span>154 </>155 )}156 </p>157 <time className={styles.time} dateTime={t.iso}>158 {t.text}159 </time>160 </div>161 </motion.li>162 );163 })}164 </ol>165 </section>166 );167}