Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
items{ id: string; title: string; body?: string; time: string; read: boolean }[]The notifications, newest first.
onItemSelect(id: string) => voidWhen 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