Skip to content

Activity item

A compact feed row: actor, action, target, time and optional metadata.

Datastarteractivityfeedauditlog

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Panel } from '@/components/ui/card'
import { ActivityItem } from '@/components/ui/timeline'
import { DemoColumn, DemoStage } from './_kit'

export default function ActivityItemDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Panel title="Recent activity" description="The denser feed variant of Timeline." flush>
          <div className="divide-y divide-[var(--color-border-subtle)] px-4">
            <ActivityItem
              actor={<Avatar name="Priya Raman" size="sm" decorative />}
              action="Priya Raman promoted"
              target="Tomas Lindqvist to Admin"
              timestamp="2026-03-14"
              displayTime="14 Mar, 09:41"
              meta={<Badge size="sm">Access</Badge>}
            />
            <ActivityItem
              actor={<Avatar name="Amara Osei" size="sm" decorative />}
              action="Amara Osei rotated the key"
              target="CI pipeline"
              timestamp="2026-03-13"
              displayTime="13 Mar, 16:20"
              meta={
                <Badge size="sm" tone="warning">
                  Security
                </Badge>
              }
            />
            <ActivityItem
              actor={<Avatar name="Jun Watanabe" size="sm" decorative />}
              action="Jun Watanabe deployed"
              target="v1.4.2 to Production"
              timestamp="2026-03-13"
              displayTime="13 Mar, 09:02"
              meta={
                <Badge size="sm" tone="success">
                  Deploy
                </Badge>
              }
            />
            <ActivityItem
              actor={<Avatar name="System" size="sm" decorative />}
              action="Weekly usage report generated"
              timestamp="2026-03-09"
              displayTime="09 Mar, 00:00"
            />
          </div>
        </Panel>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/activity-item.tsx

import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Panel } from '@/components/ui/card'
import { ActivityItem } from '@/components/ui/timeline'
import { DemoColumn, DemoStage } from './_kit'

export default function ActivityItemDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Panel title="Recent activity" description="The denser feed variant of Timeline." flush>
          <div className="divide-y divide-[var(--color-border-subtle)] px-4">
            <ActivityItem
              actor={<Avatar name="Priya Raman" size="sm" decorative />}
              action="Priya Raman promoted"
              target="Tomas Lindqvist to Admin"
              timestamp="2026-03-14"
              displayTime="14 Mar, 09:41"
              meta={<Badge size="sm">Access</Badge>}
            />
            <ActivityItem
              actor={<Avatar name="Amara Osei" size="sm" decorative />}
              action="Amara Osei rotated the key"
              target="CI pipeline"
              timestamp="2026-03-13"
              displayTime="13 Mar, 16:20"
              meta={
                <Badge size="sm" tone="warning">
                  Security
                </Badge>
              }
            />
            <ActivityItem
              actor={<Avatar name="Jun Watanabe" size="sm" decorative />}
              action="Jun Watanabe deployed"
              target="v1.4.2 to Production"
              timestamp="2026-03-13"
              displayTime="13 Mar, 09:02"
              meta={
                <Badge size="sm" tone="success">
                  Deploy
                </Badge>
              }
            />
            <ActivityItem
              actor={<Avatar name="System" size="sm" decorative />}
              action="Weekly usage report generated"
              timestamp="2026-03-09"
              displayTime="09 Mar, 00:00"
            />
          </div>
        </Panel>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/timeline.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Timeline / ActivityItem
 *
 * Timeline renders a chronology as an ordered list — the order is the meaning,
 * so it must not be a `<div>` stack. ActivityItem is the denser feed variant
 * used inside dashboards and record sidebars.
 */
export interface TimelineEntry {
  id: string
  title: string
  description?: ReactNode
  /** ISO date string, formatted by the caller for display. */
  timestamp: string
  displayTime: string
  icon?: ReactNode
  tone?: 'default' | 'accent' | 'success' | 'warning' | 'danger'
}

export interface TimelineProps {
  entries: TimelineEntry[]
  className?: string
  /** `alternating` staggers entries left/right on wide screens. */
  layout?: 'stacked' | 'alternating'
}

const toneRing = {
  default: 'border-line bg-surface text-ink-subtle',
  accent: 'border-accent bg-accent-soft text-accent-soft-ink',
  success: 'border-success-line bg-success-soft text-success',
  warning: 'border-warning-line bg-warning-soft text-warning',
  danger: 'border-danger-line bg-danger-soft text-danger',
} as const

export function Timeline({ entries, className, layout = 'stacked' }: TimelineProps) {
  return (
    <ol className={cn('relative flex flex-col', className)}>
      {entries.map((entry, index) => {
        const isLast = index === entries.length - 1
        const alternate = layout === 'alternating' && index % 2 === 1

        return (
          <li key={entry.id} className={cn('relative flex gap-4 pb-6', isLast && 'pb-0')}>
            <div className="flex flex-col items-center">
              <span
                className={cn(
                  'flex size-8 shrink-0 items-center justify-center rounded-full border',
                  toneRing[entry.tone ?? 'default'],
                )}
                aria-hidden="true"
              >
                {entry.icon ?? <span className="size-1.5 rounded-full bg-current" />}
              </span>
              {!isLast ? <span className="mt-1 w-px flex-1 bg-line" aria-hidden="true" /> : null}
            </div>

            <div className={cn('min-w-0 flex-1 pt-0.5', alternate && 'lg:pl-8')}>
              <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
                <p className="text-sm font-semibold text-ink-strong">{entry.title}</p>
                <time dateTime={entry.timestamp} className="font-mono text-2xs text-ink-subtle">
                  {entry.displayTime}
                </time>
              </div>
              {entry.description ? (
                <div className="mt-1 text-sm text-ink-muted">{entry.description}</div>
              ) : null}
            </div>
          </li>
        )
      })}
    </ol>
  )
}

export interface ActivityItemProps {
  actor: ReactNode
  action: string
  target?: string
  timestamp: string
  displayTime: string
  meta?: ReactNode
  className?: string
}

export function ActivityItem({
  actor,
  action,
  target,
  timestamp,
  displayTime,
  meta,
  className,
}: ActivityItemProps) {
  return (
    <div className={cn('flex items-start gap-3 py-2.5', className)}>
      <div className="shrink-0">{actor}</div>
      <div className="min-w-0 flex-1">
        <p className="text-sm leading-snug text-ink">
          {action}
          {target ? <span className="font-medium text-ink-strong"> {target}</span> : null}
        </p>
        <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
          <time dateTime={timestamp} className="font-mono text-2xs text-ink-subtle">
            {displayTime}
          </time>
          {meta}
        </div>
      </div>
    </div>
  )
}

components/ui/avatar.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'

/**
 * Avatar / AvatarGroup
 *
 * Foundry ships no photography, so avatars render deterministic initials on a
 * tinted surface. The tint is derived from the name's character codes, which
 * keeps the same person the same colour on every page without a colour field
 * in the data.
 *
 * A decorative avatar next to a visible name is `aria-hidden`; a standalone
 * one exposes the name as its label.
 */
export interface AvatarProps {
  name: string
  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  /** Suppresses the accessible name when the name is already on screen. */
  decorative?: boolean
  /** Small badge anchored bottom-right, e.g. a presence dot. */
  indicator?: ReactNode
  shape?: 'circle' | 'square'
  className?: string
}

const sizes = {
  xs: 'size-5 text-2xs',
  sm: 'size-7 text-2xs',
  md: 'size-9 text-xs',
  lg: 'size-12 text-sm',
  xl: 'size-16 text-lg',
} as const

const tints = [
  'bg-accent-soft text-accent-soft-ink',
  'bg-success-soft text-success',
  'bg-warning-soft text-warning',
  'bg-info-soft text-info',
  'bg-danger-soft text-danger',
  'bg-surface-sunken text-ink-muted',
] as const

function tintFor(name: string): string {
  let hash = 0
  for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
  return tints[hash % tints.length] ?? tints[0]
}

export function Avatar({
  name,
  size = 'md',
  decorative = false,
  indicator,
  shape = 'circle',
  className,
}: AvatarProps) {
  return (
    <span className={cn('relative inline-flex shrink-0', className)}>
      <span
        role={decorative ? undefined : 'img'}
        aria-label={decorative ? undefined : name}
        aria-hidden={decorative || undefined}
        className={cn(
          'inline-flex items-center justify-center border border-line font-semibold select-none',
          shape === 'circle' ? 'rounded-full' : 'rounded-md',
          sizes[size],
          tintFor(name),
        )}
      >
        {toInitials(name)}
      </span>
      {indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
    </span>
  )
}

export interface AvatarGroupProps {
  names: string[]
  size?: AvatarProps['size']
  /** Names beyond this count collapse into a "+n" chip. */
  max?: number
  className?: string
  label?: string
}

export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
  const visible = names.slice(0, max)
  const overflow = names.length - visible.length

  return (
    <span
      className={cn('flex items-center', className)}
      role="group"
      aria-label={label ?? `${names.length} people`}
    >
      {visible.map((name) => (
        <span
          key={name}
          className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
        >
          <Avatar name={name} size={size} decorative />
        </span>
      ))}
      {overflow > 0 ? (
        <span
          className={cn(
            '-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
            sizes[size],
          )}
        >
          +{overflow}
        </span>
      ) : null}
      <span className="sr-only">{names.join(', ')}</span>
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

The dense sibling of Timeline, for dashboard sidebars and audit logs where a connector line would waste vertical space.

  • Write the action as a sentence with the target emphasised, so the row scans left to right.
  • The actor avatar is decorative — the name is already in the sentence.
  • Group by day in the parent rather than repeating a full date on every row.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • With avatar actor
  • With badge metadata
  • System entries

Accessibility

Reading order
Actor, action and time follow the visual order, so there is no mismatch for screen-reader users.
Time
Timestamps use `<time datetime>`.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.