Skip to content
oks-ui

App screens

Notifications feed

A real notifications panel: All/Unread/Inbox Tabs filter the list, each row's content varies by kind (a reaction, a quoted comment with Reply, a downloadable PDF, a pair of image uploads, or an Accept/Deny join request), and a small dot marks anything unread.

notifications-feed.tsx

How it's built

1

The Unread tab filters the same array by a real `unread` field — there's no separate unread dataset to keep in sync.

2

Each notification `kind` renders its own extra content (a quote box, a file card, an image grid, or actions) below the shared avatar/text/timestamp row.

3

The status badge on the avatar (online dot, comment icon, upload dot, verified check) is purely decorative — screen readers get the same information from the sentence text.

Source

One file · copy & paste
notifications-feed.tsx
import { useState } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { Tab, Tabs } from "oks-ui/tabs";
import { DownloadIcon, FileTextIcon, MessageCircleIcon, MoreIcon } from "../../showcase/icons";

// A checkmark inside a ring — "verified request".
function VerifiedIcon({ size = 11 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <circle cx="8" cy="8" r="7" fill="var(--oks-color-info-500)" stroke="none" />
      <path d="M5 8.2 7 10l4-4.5" />
    </svg>
  );
}

type Person = { name: string; photo?: string };

const ALBERT: Person = { name: "Albert Juan", photo: "/hero/person-man.jpg" };
const LAWRENCE: Person = { name: "Lawrence", photo: "/avatars/synthetic-1.jpg" };
const MARY: Person = { name: "Mary Cruz", photo: "/hero/person-woman.jpg" };
const ERIC: Person = { name: "Eric Young", photo: "/avatars/synthetic-2.jpg" };

type Notification =
  | { id: number; kind: "reaction"; person: Person; target: string; time: string; unread: boolean }
  | { id: number; kind: "comment"; person: Person; target: string; quote: string; time: string; unread: boolean }
  | { id: number; kind: "upload"; person: Person; target: string; fileName: string; fileSize: string; time: string; unread: boolean }
  | { id: number; kind: "images"; person: Person; target: string; covers: string[]; time: string; unread: boolean }
  | { id: number; kind: "join-request"; person: Person; channel: string; time: string; unread: boolean };

const TODAY: Notification[] = [
  { id: 1, kind: "reaction", person: ALBERT, target: "#web-app post", time: "2 min ago", unread: true },
  { id: 2, kind: "comment", person: LAWRENCE, target: "#UXDesign group", quote: "Great design! Improve mobile responsiveness…", time: "Today, 1:30 pm", unread: true },
  { id: 3, kind: "upload", person: LAWRENCE, target: "AppDev Page", fileName: "UX-report.pdf", fileSize: "5mb", time: "2 min ago", unread: true },
  { id: 4, kind: "images", person: MARY, target: "Rebranding page", covers: ["linear-gradient(140deg, #1a2a52 0%, #5a3fae 60%, #e0623a 100%)", "linear-gradient(140deg, #171923 0%, #7a2d2d 45%, #e0623a 100%)"], time: "2 min ago", unread: true },
];

const PREV_WEEK: Notification[] = [
  { id: 5, kind: "join-request", person: ERIC, channel: "#marketing", time: "2 min ago", unread: false },
];

function NotificationRow({ n }: { n: Notification }) {
  return (
    <div className="flex gap-3 py-3">
      <div className="relative shrink-0">
        <Avatar src={n.person.photo} name={n.person.name} size="md" radius="full" />
        {n.kind === "reaction" ? (
          <span aria-hidden="true" className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-paper" style={{ background: "var(--oks-color-success-500)" }} />
        ) : n.kind === "comment" ? (
          <span className="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-paper text-ink-muted ring-2 ring-paper">
            <MessageCircleIcon size={10} />
          </span>
        ) : n.kind === "upload" ? (
          <span aria-hidden="true" className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-paper" style={{ background: "var(--oks-color-warning-500)" }} />
        ) : n.kind === "join-request" ? (
          <span className="absolute -bottom-1 -right-1 rounded-full ring-2 ring-paper">
            <VerifiedIcon />
          </span>
        ) : null}
      </div>

      <div className="min-w-0 flex-1">
        <p className="text-[0.85rem] leading-snug text-ink-muted">
          <span className="font-semibold text-ink">{n.person.name}</span>{" "}
          {n.kind === "reaction" ? (
            <>
              Added reaction 🤔 <span className="font-medium text-ink">{n.target}</span>
            </>
          ) : n.kind === "comment" ? (
            <>
              added a new comment in <span className="font-medium text-ink">{n.target}</span>
            </>
          ) : n.kind === "upload" ? (
            <>
              Uploaded 2 attachments in <span className="font-medium text-ink">{n.target}</span>
            </>
          ) : n.kind === "images" ? (
            <>
              Uploaded {n.covers.length} images to <span className="font-medium text-ink">{n.target}</span>
            </>
          ) : (
            <>
              requested to join <span className="font-medium text-ink">{n.channel}</span>
            </>
          )}
        </p>
        <p className="text-[0.75rem] text-ink-faint">{n.time}</p>

        {n.kind === "comment" ? (
          <div className="mt-2 flex items-center justify-between gap-3 rounded-[var(--r-lg)] border border-line px-3 py-2">
            <p className="truncate text-[0.82rem] text-ink-muted">{n.quote}</p>
            <Button variant="link" size="sm" className="shrink-0 px-0">
              Reply
            </Button>
          </div>
        ) : null}

        {n.kind === "upload" ? (
          <div className="mt-2 flex items-center gap-2.5 rounded-[var(--r-lg)] border border-line px-3 py-2">
            <span className="flex h-8 w-7 shrink-0 items-center justify-center rounded-[var(--r-sm)] bg-[color:var(--oks-color-danger-500)] text-white">
              <FileTextIcon size={14} />
            </span>
            <span className="min-w-0 flex-1 truncate text-[0.82rem] text-ink">{n.fileName}</span>
            <span className="shrink-0 text-[0.78rem] text-ink-faint">({n.fileSize})</span>
            <Button isIconOnly aria-label={`Download ${n.fileName}`} variant="ghost" size="sm">
              <DownloadIcon size={15} />
            </Button>
          </div>
        ) : null}

        {n.kind === "images" ? (
          <div className="mt-2 grid grid-cols-2 gap-2">
            {n.covers.map((cover, i) => (
              <div key={i} className="relative aspect-[4/3] overflow-hidden rounded-[var(--r-lg)]" style={{ background: cover }}>
                <span className="absolute bottom-1.5 right-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm">
                  <DownloadIcon size={12} />
                </span>
              </div>
            ))}
          </div>
        ) : null}

        {n.kind === "join-request" ? (
          <div className="mt-2 flex items-center gap-2">
            <Button color="primary" size="sm" radius="full">
              Accept
            </Button>
            <Button variant="bordered" size="sm" radius="full">
              Deny
            </Button>
          </div>
        ) : null}
      </div>

      {n.unread ? (
        <span aria-label="Unread" className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-ink-faint" />
      ) : null}
    </div>
  );
}

export function NotificationsFeed() {
  const [tab, setTab] = useState("all");

  const today = tab === "unread" ? TODAY.filter((n) => n.unread) : TODAY;
  const prevWeek = tab === "unread" ? PREV_WEEK.filter((n) => n.unread) : PREV_WEEK;

  return (
    <div className="mx-auto flex w-full max-w-md flex-col rounded-[var(--r-lg)] bg-paper p-5 shadow-[var(--shadow-md)]">
      <div className="flex items-center justify-between">
        <h2 className="text-[1.2rem] font-bold text-ink">Notifications</h2>
        <Dropdown placement="bottom-end">
          <DropdownTrigger>
            <Button isIconOnly aria-label="Notification options" variant="ghost" radius="full" size="sm">
              <MoreIcon size={18} />
            </Button>
          </DropdownTrigger>
          <DropdownMenu aria-label="Notification options">
            <DropdownItem key="read">Mark all as read</DropdownItem>
            <DropdownItem key="settings">Notification settings</DropdownItem>
          </DropdownMenu>
        </Dropdown>
      </div>

      <Tabs aria-label="Filter notifications" variant="underlined" selectedKey={tab} onSelectionChange={(key) => setTab(String(key))} classNames={{ base: "mt-1" }}>
        <Tab key="all" title="All" />
        <Tab key="unread" title="Unread" />
        <Tab key="inbox" title="Inbox" />
      </Tabs>

      {today.length > 0 ? (
        <>
          <div className="mt-3 flex items-center justify-between">
            <h3 className="text-[0.85rem] font-semibold text-ink">Today</h3>
            <Button variant="link" size="sm" className="px-0">
              See more
            </Button>
          </div>
          <div className="flex flex-col divide-y divide-line">
            {today.map((n) => (
              <NotificationRow key={n.id} n={n} />
            ))}
          </div>
        </>
      ) : null}

      {prevWeek.length > 0 ? (
        <>
          <h3 className="mt-2 text-[0.85rem] font-semibold text-ink">Prev Week</h3>
          <div className="flex flex-col divide-y divide-line">
            {prevWeek.map((n) => (
              <NotificationRow key={n.id} n={n} />
            ))}
          </div>
        </>
      ) : null}
    </div>
  );
}

More patterns

App screens
Analytics dashboardA full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.
App screens
Settings panelA real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.
App screens
Data table viewA full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.