UIData Displaymotion/react

ActivityFeed

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

  1. Maya Okafor deployed web@v2.3.0 to production

  2. Liam Chen merged pull request #482 Faster search index

  3. Sofia Martinez commented on Checkout redesign

  4. Noah Patel pushed 3 commits to feat/billing-v2

  5. Monitoring flagged elevated latency on api-eu-west

  6. Ava Thompson starred motionfoundry/ui

Customize

7 props
6
90

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add activity-feed

Writes 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.

example.tsx
1import { ActivityFeed } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ActivityFeed />6  );7}

Props

PropTypeDefault
items

Events, newest first: { id, actor, action, target?, kind: 'commit' | 'comment' | 'deploy' | 'merge' | 'alert' | 'star', minutesAgo }.

ActivityItem[]6 sample events
title

Heading above the feed; also its accessible name.

string'Recent activity'
showTitle

Show the heading visibly (otherwise visually hidden).

booleantrue
maxItems

Maximum events rendered.

number6
stagger

Delay between events animating in, in milliseconds.

number90
showConnector

Draw a vertical connector line between event icons.

booleantrue
dense

Compact spacing with smaller icons and inline times.

booleanfalse
accentColor

Commit icon color and connector tint.

string'#6366f1'
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/activity-feed.tsx
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}