Notification list
A list of notifications with unread marks that never rely on colour alone.
npx shadcn@latest add https://ui.goodtabs.co/r/notification-list.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/notification-list
- Unread: Deploy finished2m
- Unread: New comment on Hero1h
- Invoice paidMon
Two unread rows and one read row.
Mark as read
3 unread
Selecting a row clears its mark and updates the unread count.
Usage
import { NotificationList } from "@/components/ui/notification-list"
<NotificationList items={items} onItemSelect={markRead} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; title: string; body?: string; time: string; read: boolean }[] | The notifications, newest first. | |
| onItemSelect | (id: string) => void | When set, each row is a button that calls this with its id. |
Source
notification-list/notification-list.tsx
import * as React from "react";
import { cn } from "@/lib/utils";
export type NotificationItem = {
id: string;
title: string;
body?: string;
/** Display time, for example "2m ago". */
time: string;
read: boolean;
};
type NotificationListProps = Omit<React.ComponentProps<"ul">, "children"> & {
items: NotificationItem[];
/** Called with the item id when a row is activated. Rows become buttons. */
onItemSelect?: (id: string) => void;
};
/**
* A list of notifications. Unread rows carry a brand dot, a heavier title and
* the words "Unread" for screen readers, so state never rides on colour alone.
*/
export function NotificationList({ items, onItemSelect, className, ...props }: NotificationListProps) {
return (
<ul
data-slot="notification-list"
className={cn("divide-y overflow-hidden rounded-lg border bg-background", className)}
{...props}
>
{items.map((item) => {
const content = (
<>
<span aria-hidden="true" className="mt-1.5 flex size-2 shrink-0 items-center justify-center">
{!item.read && <span className="size-2 rounded-full bg-brand" />}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-3">
<span
className={cn("truncate text-sm", item.read ? "text-foreground/70" : "font-medium text-foreground")}
>
{!item.read && <span className="sr-only">Unread: </span>}
{item.title}
</span>
<span className="shrink-0 font-mono text-xs text-foreground/70">{item.time}</span>
</span>
{item.body && <span className="mt-0.5 block truncate text-sm text-foreground/70">{item.body}</span>}
</span>
</>
);
const row = "flex w-full items-start gap-3 px-3 py-2.5 text-left";
return (
<li key={item.id} data-read={item.read || undefined}>
{onItemSelect ? (
<button
type="button"
onClick={() => onItemSelect(item.id)}
className={cn(
row,
"transition-colors duration-100 outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
>
{content}
</button>
) : (
<div className={row}>{content}</div>
)}
</li>
);
})}
</ul>
);
}
For AI agents
Hand your agent this page, the markdown at /components/notification-list/prompt.md, or the Copy AI prompt button above.
When to use
- An inbox or activity panel inside a popover, sheet or page
- Any list where some rows are new and the rest are not
When not to use
- Transient confirmations, which belong in a toast
- Long feeds that need grouping, filters or infinite loading
Agent notes
- Unread is three signals at once: a brand dot, a medium weight title and hidden text reading Unread
- The dot is the only use of brand; titles and times stay in foreground tones
- Passing onItemSelect from a server component fails; use it from a client component
- Times are plain strings, format them yourself