Dashboard activity feed
A dense feed for a narrow column, using ActivityItem rather than Timeline.
Live preview
Source
This exact file renders the preview above.
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { ActivityItem } from '@/components/ui/timeline'
/**
* Dashboard activity feed
*
* The sidebar companion to a metric row. Uses ActivityItem rather than
* Timeline: in a narrow column a connector rail wastes horizontal space that
* the sentence needs.
*/
const events = [
{
actor: 'Priya Raman',
action: 'promoted',
target: 'Tomas Lindqvist to Admin',
time: '14 Mar, 09:41',
iso: '2026-03-14',
tone: 'neutral' as const,
label: 'Access',
},
{
actor: 'Amara Osei',
action: 'rotated the key',
target: 'CI pipeline',
time: '13 Mar, 16:20',
iso: '2026-03-13',
tone: 'warning' as const,
label: 'Security',
},
{
actor: 'Jun Watanabe',
action: 'deployed',
target: 'v1.4.2 to Production',
time: '13 Mar, 09:02',
iso: '2026-03-13',
tone: 'success' as const,
label: 'Deploy',
},
{
actor: 'Elena Rossi',
action: 'commented on',
target: 'FND-284',
time: '12 Mar, 14:55',
iso: '2026-03-12',
tone: 'neutral' as const,
label: 'Comment',
},
{
actor: 'Marcus Bell',
action: 'invited',
target: 'sofia@acme.example',
time: '12 Mar, 11:03',
iso: '2026-03-12',
tone: 'neutral' as const,
label: 'Access',
},
]
export default function DashboardActivityFeed() {
return (
<section className="bg-canvas p-4 sm:p-6">
<Panel
title="Activity"
description="Everything that changed in the workspace"
headingLevel="h2"
flush
action={
<Button variant="ghost" size="sm">
View all
</Button>
}
>
<div className="divide-y divide-[var(--color-border-subtle)] px-4">
{events.map((event) => (
<ActivityItem
key={`${event.actor}-${event.time}`}
actor={<Avatar name={event.actor} size="sm" decorative />}
action={`${event.actor} ${event.action}`}
target={event.target}
timestamp={event.iso}
displayTime={event.time}
meta={
<Badge size="sm" tone={event.tone}>
{event.label}
</Badge>
}
/>
))}
</div>
</Panel>
</section>
)
}
components/blocks/sections/dashboard/activity-feed.tsx
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { ActivityItem } from '@/components/ui/timeline'
/**
* Dashboard activity feed
*
* The sidebar companion to a metric row. Uses ActivityItem rather than
* Timeline: in a narrow column a connector rail wastes horizontal space that
* the sentence needs.
*/
const events = [
{
actor: 'Priya Raman',
action: 'promoted',
target: 'Tomas Lindqvist to Admin',
time: '14 Mar, 09:41',
iso: '2026-03-14',
tone: 'neutral' as const,
label: 'Access',
},
{
actor: 'Amara Osei',
action: 'rotated the key',
target: 'CI pipeline',
time: '13 Mar, 16:20',
iso: '2026-03-13',
tone: 'warning' as const,
label: 'Security',
},
{
actor: 'Jun Watanabe',
action: 'deployed',
target: 'v1.4.2 to Production',
time: '13 Mar, 09:02',
iso: '2026-03-13',
tone: 'success' as const,
label: 'Deploy',
},
{
actor: 'Elena Rossi',
action: 'commented on',
target: 'FND-284',
time: '12 Mar, 14:55',
iso: '2026-03-12',
tone: 'neutral' as const,
label: 'Comment',
},
{
actor: 'Marcus Bell',
action: 'invited',
target: 'sofia@acme.example',
time: '12 Mar, 11:03',
iso: '2026-03-12',
tone: 'neutral' as const,
label: 'Access',
},
]
export default function DashboardActivityFeed() {
return (
<section className="bg-canvas p-4 sm:p-6">
<Panel
title="Activity"
description="Everything that changed in the workspace"
headingLevel="h2"
flush
action={
<Button variant="ghost" size="sm">
View all
</Button>
}
>
<div className="divide-y divide-[var(--color-border-subtle)] px-4">
{events.map((event) => (
<ActivityItem
key={`${event.actor}-${event.time}`}
actor={<Avatar name={event.actor} size="sm" decorative />}
action={`${event.actor} ${event.action}`}
target={event.target}
timestamp={event.iso}
displayTime={event.time}
meta={
<Badge size="sm" tone={event.tone}>
{event.label}
</Badge>
}
/>
))}
</div>
</Panel>
</section>
)
}
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
In a narrow column a connector rail wastes horizontal space that the sentence needs, so this uses the compact ActivityItem rather than Timeline.
- Write the action as a sentence so the row scans left to right.
- 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.
- Five events
- Badge metadata
- Divided rows
Accessibility
- Time elements
- Every entry carries a machine-readable datetime.
- Decorative avatars
- Actor avatars are hidden because the name is in the sentence.
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.
Related
All sectionsActivity item
A compact feed row: actor, action, target, time and optional metadata.
starter3 variantsDashboard metric row
Four KPI tiles, two of them with inverted trend semantics.
starterFeatured3 variantsAdmin starter
An internal console: overview, a filterable member table, orders, analytics and settings — all at compact density on the same components as the marketing starters.
advancedFeatured4 variants