Search for pages, actions, and quick links.
Timelines, activity streams and notification lists. 8 blocks.
A vertical run of events with a connecting rail.
src/components/blocks/feeds/feed-timeline.tsx"use client";
import { CheckCircle2, GitCommit, MessageSquare, UserPlus } from "lucide-react";
const EVENTS = [
{ icon: GitCommit, title: "Deployed v2.4.1 to production", who: "Ada L.", when: "12 minutes ago" },
{ icon: CheckCircle2, title: "Closed 4 issues in the checkout milestone", who: "Grace H.", when: "1 hour ago" },
{ icon: MessageSquare, title: "Commented on “Payment provider migration”", who: "Alan T.", when: "3 hours ago" },
{ icon: UserPlus, title: "Invited two collaborators", who: "Katherine J.", when: "Yesterday" },
];
/**
* A vertical run of events with a connecting rail.
*
* The rail is a pseudo-element on each item rather than one absolutely positioned line, so it
* stops naturally at the last event — a single line has to be told where to end, and gets it
* wrong the moment the list length changes.
*
* `start-4` and `ms-*`, not `left-4`: the rail belongs on the reading-order side, so it moves to
* the right under RTL with no extra rules.
*/
export function FeedTimeline() {
return (
<ol className="space-y-1">
{EVENTS.map((event, index) => {
const Icon = event.icon;
const isLast = index === EVENTS.length - 1;
return (
<li key={event.title} className="relative flex gap-3 pb-4 last:pb-0">
{!isLast && (
<span
className="absolute start-4 top-8 bottom-0 w-px bg-border"
aria-hidden="true"
/>
)}
<div className="relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted ring-4 ring-background">
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
</div>
<div className="min-w-0 pt-1">
<p className="text-sm">{event.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{event.who} · {event.when}
</p>
</div>
</li>
);
})}
</ol>
);
}The same stream, with the date breaks a long feed needs.
src/components/blocks/feeds/feed-grouped.tsx"use client";
const GROUPS = [
{
day: "Today",
items: [
{ time: "14:02", text: "Invoice INV-2043 marked paid" },
{ time: "11:20", text: "New customer: Northwind Ltd" },
{ time: "09:48", text: "Nightly export completed" },
],
},
{
day: "Yesterday",
items: [
{ time: "17:35", text: "Plan changed to Business, annual" },
{ time: "10:04", text: "API key rotated by Ada L." },
],
},
];
/**
* The same stream, with the date breaks a long feed needs.
*
* A hundred events with no grouping is a wall. The day heading is `sticky` inside the scroll
* container so it stays put while its own group scrolls past — which is the behaviour that makes
* a long feed navigable rather than merely divided.
*/
export function FeedGrouped() {
return (
<div className="max-h-72 overflow-y-auto pe-1">
{GROUPS.map((group) => (
<section key={group.day}>
<h4 className="sticky top-0 z-10 bg-background/95 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur">
{group.day}
</h4>
<ul className="mb-2 space-y-0.5">
{group.items.map((item) => (
<li key={item.time} className="flex gap-3 py-1.5 text-sm">
<time className="w-12 shrink-0 tabular-nums text-xs text-muted-foreground">
{item.time}
</time>
<span className="min-w-0">{item.text}</span>
</li>
))}
</ul>
</section>
))}
</div>
);
}Unread state, type icon and a relative time.
src/components/blocks/feeds/feed-notifications.tsx"use client";
import { CreditCard, Settings, ShoppingCart, Users } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
const ITEMS = [
{ icon: ShoppingCart, title: "New order ORD-7891", detail: "Emma Wilson · $299.00", when: "2m", unread: true },
{ icon: CreditCard, title: "Payment received", detail: "INV-2043 · $4,200.00", when: "18m", unread: true },
{ icon: Users, title: "New customer signed up", detail: "Northwind Ltd", when: "1h", unread: false },
{ icon: Settings, title: "Nightly backup finished", detail: "No errors", when: "6h", unread: false },
];
/**
* Unread state, type icon and a relative time.
*
* Unread is carried by three things — a tinted row, a bolder title and a dot — because tint
* alone fails for anyone with reduced colour vision, and the dot alone is eight pixels. The
* count of unread items belongs in the *heading* rather than only in a badge, which is what
* makes it reachable when the list is announced.
*/
export function FeedNotifications() {
const unread = ITEMS.filter((item) => item.unread).length;
return (
<div>
<h4 className="mb-2 text-sm font-semibold">
Notifications
{unread > 0 && (
<span className="ms-2 rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
{unread} unread
</span>
)}
</h4>
<ul className="divide-y divide-border">
{ITEMS.map((item) => {
const Icon = item.icon;
return (
<li
key={item.title}
className={cn(
"flex items-start gap-3 px-2 py-2.5",
item.unread && "-mx-2 rounded-md bg-primary/5",
)}
>
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-muted">
<Icon className="size-3.5 text-muted-foreground" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className={cn("truncate text-sm", item.unread ? "font-semibold" : "font-medium")}>
{item.title}
</p>
<p className="truncate text-xs text-muted-foreground">{item.detail}</p>
</div>
<span className="shrink-0 text-[11px] text-muted-foreground">{item.when}</span>
{item.unread && (
<span className="mt-2 size-2 shrink-0 rounded-full bg-primary" aria-hidden="true" />
)}
</li>
);
})}
</ul>
</div>
);
}The old and new values are the reason the log exists, marked up as such.
src/components/blocks/feeds/audit-log.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* An audit trail showing what changed, from what, to what.
*
* A log line saying "Ana updated the order" is almost useless in an audit context. The before and
* after values are the entire reason the log exists, so they are the emphasised part here, with the
* old value struck through.
*
* `<del>` and `<ins>` rather than styled spans: they are the elements HTML has for removed and
* inserted content, so the change is conveyed semantically rather than by a line-through that a
* screen reader ignores. `line-through` on a `<span>` is decoration; `<del>` is meaning.
*
* Rows are grouped under a date heading, because an audit log is read by scanning to a day. A flat
* list of 200 timestamps is not.
*/
export function AuditLog() {
const days = [
{
date: "Today",
entries: [
{
who: "Ana Whitfield",
what: "Order status",
from: "Pending",
to: "Paid",
when: "09:14",
tone: "success" as const,
},
{
who: "Marcus Oyelaran",
what: "Shipping address",
from: "12 Elm St, Berlin",
to: "44 Oak Ave, Berlin",
when: "08:52",
tone: "neutral" as const,
},
],
},
{
date: "Yesterday",
entries: [
{
who: "System",
what: "Payment method",
from: "visa ···4242",
to: "mastercard ···8891",
when: "17:31",
tone: "neutral" as const,
},
{
who: "Priya Raman",
what: "Discount",
from: "0%",
to: "15%",
when: "14:06",
tone: "warning" as const,
},
],
},
];
return (
<div className="space-y-4">
{days.map((day) => (
<section key={day.date} className="space-y-2">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{day.date}
</h3>
<ul className="divide-y rounded-lg border">
{day.entries.map((entry) => (
<li key={`${entry.what}-${entry.when}`} className="flex gap-3 p-3">
<span
aria-hidden="true"
className={cn(
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
entry.tone === "success" && "bg-success",
entry.tone === "warning" && "bg-warning",
entry.tone === "neutral" && "bg-muted-foreground/40",
)}
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm">
<span className="font-medium">{entry.who}</span>
<span className="text-muted-foreground"> changed </span>
<span className="font-medium">{entry.what}</span>
</p>
<p className="text-xs">
{/* <del>/<ins> carry the change semantically, not just visually. */}
<del className="text-muted-foreground">{entry.from}</del>
<span aria-hidden="true" className="mx-1.5 text-muted-foreground">
→
</span>
<ins className="font-medium no-underline">{entry.to}</ins>
</p>
</div>
<Badge variant="outline" className="shrink-0 tabular-nums text-[10px]">
{entry.when}
</Badge>
</li>
))}
</ul>
</section>
))}
</div>
);
}Distinguishable by weight and a marker, so it survives dark mode.
src/components/blocks/feeds/notification-groups.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { AlertTriangle, CheckCircle2, MessageSquare, Package } from "lucide-react";
/**
* A notification list where read and unread are distinguishable without colour.
*
* Unread rows get a filled dot and a semibold title; read rows get neither. Tinting the row
* background is the usual approach and it fails in two ways at once — it is invisible to some
* colour-blind users, and it disappears entirely in dark mode where the tint has nowhere to go.
*
* "Mark all read" is a single action that mutates every row, so it lives in a `role="status"` region
* alongside the unread count: after pressing it, a screen-reader user hears the count reach zero and
* knows it worked. Otherwise the only feedback is visual.
*
* The dot is `aria-hidden` and the state is carried in an `sr-only` "Unread" instead, because a
* decorative dot announced as "bullet" conveys nothing.
*/
export function NotificationGroups() {
const [items, setItems] = useState([
{
id: 1,
Icon: Package,
title: "Order ORD-7891 shipped",
body: "Tracking number added by the fulfilment service.",
when: "9 minutes ago",
unread: true,
tone: "text-chart-1",
},
{
id: 2,
Icon: AlertTriangle,
title: "Refund rate above threshold",
body: "EU store crossed 4% for the second day running.",
when: "2 hours ago",
unread: true,
tone: "text-warning",
},
{
id: 3,
Icon: MessageSquare,
title: "Priya mentioned you",
body: "“…matches the support tickets exactly.”",
when: "Yesterday",
unread: false,
tone: "text-chart-3",
},
{
id: 4,
Icon: CheckCircle2,
title: "Monthly close completed",
body: "June ledger locked with no adjustments.",
when: "3 days ago",
unread: false,
tone: "text-success",
},
]);
const unread = items.filter((item) => item.unread).length;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p role="status" className="text-sm text-muted-foreground tabular-nums">
{unread} unread
</p>
<Button
variant="ghost"
size="sm"
disabled={unread === 0}
onClick={() => setItems((prev) => prev.map((item) => ({ ...item, unread: false })))}
>
Mark all read
</Button>
</div>
<ul className="divide-y rounded-lg border">
{items.map((item) => (
<li key={item.id} className="flex gap-3 p-3">
<span className={cn("mt-0.5 shrink-0", item.tone)}>
<item.Icon className="h-4 w-4" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex items-start gap-2">
<p
className={cn(
"min-w-0 flex-1 text-sm",
item.unread ? "font-semibold" : "font-normal",
)}
>
{item.title}
{item.unread && <span className="sr-only"> — unread</span>}
</p>
{item.unread && (
<span
aria-hidden="true"
className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
/>
)}
</div>
<p className="text-xs text-muted-foreground">{item.body}</p>
<p className="text-[11px] text-muted-foreground/70">{item.when}</p>
</div>
</li>
))}
</ul>
</div>
);
}Three states differing in shape as well as hue.
src/components/blocks/feeds/presence-list.tsx"use client";
import { Avatar, AvatarFallback } from "@dashboardpack/core/components/ui/avatar";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* Presence indicators that do not rely on colour alone.
*
* A green or amber dot is the standard pattern and it fails WCAG 1.4.1 — the state is conveyed only
* by hue. Each row carries the status as text too, and the dot is `aria-hidden`, so nothing depends
* on distinguishing green from amber.
*
* Offline uses a hollow ring rather than a grey fill, so the three states differ in *shape* as well
* as colour and remain distinguishable in a greyscale screenshot.
*/
export function PresenceList() {
const people = [
{ name: "Ana Whitfield", initials: "AW", state: "online" as const, detail: "In a call" },
{ name: "Marcus Oyelaran", initials: "MO", state: "online" as const, detail: "Reviewing PR #482" },
{ name: "Priya Raman", initials: "PR", state: "away" as const, detail: "Back in 20 minutes" },
{ name: "Tomas Berg", initials: "TB", state: "offline" as const, detail: "Last seen 4h ago" },
];
const states = {
online: { label: "Online", dot: "bg-success", tone: "text-success" },
away: { label: "Away", dot: "bg-warning", tone: "text-warning" },
offline: { label: "Offline", dot: "border-2 border-muted-foreground/50", tone: "text-muted-foreground" },
};
return (
<ul className="divide-y rounded-lg border">
{people.map((person) => {
const state = states[person.state];
return (
<li key={person.name} className="flex items-center gap-3 p-3">
<div className="relative shrink-0">
<Avatar className="h-9 w-9">
<AvatarFallback className="text-[11px]">{person.initials}</AvatarFallback>
</Avatar>
{/* Decorative: the state is in text below, so nothing is lost if this is unseen. */}
<span
aria-hidden="true"
className={cn(
"absolute bottom-0 h-3 w-3 rounded-full ring-2 ring-background end-0",
state.dot,
)}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{person.name}</p>
<p className="truncate text-xs text-muted-foreground">{person.detail}</p>
</div>
<span className={cn("shrink-0 text-xs font-medium", state.tone)}>{state.label}</span>
</li>
);
})}
</ul>
);
}Versions as real headings, so the outline is navigable.
src/components/blocks/feeds/release-notes-feed.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
/**
* A release feed where the version is the heading.
*
* Each entry is a `<section>` with an `<h3>`, so the page gets a real heading outline and a screen
* reader can jump between versions. A feed built from `<div>`s and bold text is one flat run of
* prose with no way to skip a release.
*
* Change types are badges rather than coloured text, because "Breaking" needs to survive being
* scanned quickly — and the badge variants already carry accessible contrast in both themes.
*
* Dates are `<time datetime>` with an ISO value, so the feed is machine-readable if it is ever
* turned into an actual RSS or JSON feed.
*/
export function ReleaseNotesFeed() {
const releases = [
{
version: "3.0.0",
date: "2026-07-31",
label: "31 July 2026",
changes: [
{ kind: "Added" as const, text: "Twelve dashboard variants and six layout presets" },
{ kind: "Added" as const, text: "Column pinning, inline editing and saved views" },
{ kind: "Breaking" as const, text: "`perPage: 100` replaced with `perPage: \"all\"`" },
],
},
{
version: "2.3.0",
date: "2026-07-24",
label: "24 July 2026",
changes: [
{ kind: "Fixed" as const, text: "Dev server crashed on a Server Component barrel import" },
{ kind: "Added" as const, text: "Continuous integration across two Node versions" },
],
},
];
const variants = {
Added: "success" as const,
Fixed: "secondary" as const,
Breaking: "destructive" as const,
};
return (
<div className="space-y-6">
{releases.map((release) => (
<section key={release.version} className="space-y-3">
<div className="flex flex-wrap items-baseline gap-2 border-b pb-2">
<h3 className="text-base font-semibold">v{release.version}</h3>
<time dateTime={release.date} className="text-xs text-muted-foreground">
{release.label}
</time>
</div>
<ul className="space-y-2">
{release.changes.map((change) => (
<li key={change.text} className="flex flex-wrap items-baseline gap-2">
<Badge variant={variants[change.kind]} className="text-[10px]">
{change.kind}
</Badge>
<span className="min-w-0 flex-1 text-sm text-muted-foreground">{change.text}</span>
</li>
))}
</ul>
</section>
))}
</div>
);
}
Threaded comments, one level deep
A deliberate nesting cap, indented so it mirrors under RTL.
src/components/blocks/feeds/comment-thread.tsxView source99 lines