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.
Built with
How it’s built
- 01The Unread tab filters the same array by a real
unreadfield — there's no separate unread dataset to keep in sync. - 02Each notification
kindrenders its own extra content (a quote box, a file card, an image grid, or actions) below the shared avatar/text/timestamp row. - 03The 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
Two files · copy & pasteStyling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.
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, QuestionBubbleIcon } from "./icons";
// A checkmark inside a ring — "verified request".
const 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 },
];
const 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 a reaction <QuestionBubbleIcon /> on <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>
);
};
const 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>
);
};
export default NotificationsFeed;It imports ./icons, so save this next to it:
export const DownloadIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
</svg>
);
};
export const FileTextIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5z" />
<path d="M14 2v6h6" />
<path d="M9 13h6M9 17h6M9 9h2" />
</svg>
);
};
export const MessageCircleIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
</svg>
);
};
export const MoreIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
);
};
export const QuestionBubbleIcon = ({ size = 14 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-4.2-7.6L21 3v5h-5" />
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6V14M12 17h.01" />
</svg>
);
};More patterns

Analytics dashboardApp screens
A full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.

Settings panelApp screens
A real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.

Data table viewApp screens
A full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.