Web InnoventixFreeCode

Notifications Menu

Original · free

notifications dropdown list

byWeb InnoventixReact + Tailwind
menunotificationsmenus
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/menu-notifications.json
menu-notifications.tsx
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/* ---------------------------------- types --------------------------------- */

type NotifType =
  | "mention"
  | "comment"
  | "security"
  | "billing"
  | "follow"
  | "system";

interface Notification {
  id: string;
  type: NotifType;
  actor: string;
  isPerson: boolean;
  title: string;
  body: string;
  time: string;
  unread: boolean;
}

type FilterId = "all" | "unread" | "mentions";

interface FilterTab {
  id: FilterId;
  label: string;
}

/* ---------------------------------- icons --------------------------------- */

function BellIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M6 8a6 6 0 0 1 12 0c0 5 1.5 7 2 8H4c.5-1 2-3 2-8Z" />
      <path d="M10.3 20a2 2 0 0 0 3.4 0" />
    </svg>
  );
}

function CheckCheckIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="m2 12 4.5 4.5L12 11" />
      <path d="m10 16 1.5 1.5L22 7" />
    </svg>
  );
}

function XIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.9}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M18 6 6 18M6 6l12 12" />
    </svg>
  );
}

function AtIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="4" />
      <path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8" />
    </svg>
  );
}

function ChatIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2Z" />
    </svg>
  );
}

function ShieldIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6Z" />
      <path d="m9 12 2 2 4-4" />
    </svg>
  );
}

function CardIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <rect x="2.5" y="5" width="19" height="14" rx="2.5" />
      <path d="M2.5 10h19M6 15h4" />
    </svg>
  );
}

function UserPlusIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <circle cx="9" cy="8" r="3.5" />
      <path d="M3 20a6 6 0 0 1 12 0" />
      <path d="M18 8v6M15 11h6" />
    </svg>
  );
}

function SparkIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18" />
    </svg>
  );
}

function InboxIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.6}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <path d="M4 13h4l1.5 2.5h5L16 13h4" />
      <path d="M4 13 6 5h12l2 8v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Z" />
    </svg>
  );
}

function GearIcon({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      <circle cx="12" cy="12" r="3" />
      <path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1" />
    </svg>
  );
}

function BoltIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
      <path d="M13 2 4 14h6l-1 8 9-12h-6Z" />
    </svg>
  );
}

/* --------------------------------- content -------------------------------- */

const INITIAL_NOTIFICATIONS: Notification[] = [
  {
    id: "n1",
    type: "mention",
    actor: "Amara Osei",
    isPerson: true,
    title: "mentioned you in Q3 launch checklist",
    body: "@you can you confirm the analytics events are wired before we ship Thursday?",
    time: "4m",
    unread: true,
  },
  {
    id: "n2",
    type: "comment",
    actor: "Devin Park",
    isPerson: true,
    title: "replied to your comment on PR #482",
    body: "Good catch. I refactored the retry logic to use exponential backoff with jitter.",
    time: "22m",
    unread: true,
  },
  {
    id: "n3",
    type: "security",
    actor: "Security",
    isPerson: false,
    title: "New sign-in from Chrome on macOS",
    body: "Palo Alto, US · 10:12 AM. If this wasn't you, secure your account now.",
    time: "1h",
    unread: true,
  },
  {
    id: "n4",
    type: "billing",
    actor: "Billing",
    isPerson: false,
    title: "Invoice INV-2043 for June is ready",
    body: "$248.00 charged to Visa ending 4471. Receipt available in your workspace.",
    time: "3h",
    unread: false,
  },
  {
    id: "n5",
    type: "follow",
    actor: "Lena Fischer",
    isPerson: true,
    title: "started following your project Northwind",
    body: "You now share 3 boards and the release calendar.",
    time: "5h",
    unread: false,
  },
  {
    id: "n6",
    type: "mention",
    actor: "Kwame Mensah",
    isPerson: true,
    title: "mentioned you in #design-review",
    body: "The empty state you designed for the inbox is genuinely great work.",
    time: "8h",
    unread: false,
  },
  {
    id: "n7",
    type: "system",
    actor: "Northwind CI",
    isPerson: false,
    title: "Deployment to production succeeded",
    body: "Build 1.9.4 is live · 312 checks passing, 0 failed. Rollback available for 24h.",
    time: "Yesterday",
    unread: false,
  },
];

const FILTERS: FilterTab[] = [
  { id: "all", label: "All" },
  { id: "unread", label: "Unread" },
  { id: "mentions", label: "Mentions" },
];

const AVATAR_PALETTE = [
  "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300",
  "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
];

const TYPE_TILE: Record<NotifType, string> = {
  mention: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  comment: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  security: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  billing: "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300",
  follow: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  system: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
};

const TYPE_BADGE: Record<NotifType, string> = {
  mention: "bg-indigo-500 text-white",
  comment: "bg-sky-500 text-white",
  security: "bg-rose-500 text-white",
  billing: "bg-amber-500 text-white",
  follow: "bg-emerald-500 text-white",
  system: "bg-violet-500 text-white",
};

function typeIcon(type: NotifType, className: string) {
  switch (type) {
    case "mention":
      return <AtIcon className={className} />;
    case "comment":
      return <ChatIcon className={className} />;
    case "security":
      return <ShieldIcon className={className} />;
    case "billing":
      return <CardIcon className={className} />;
    case "follow":
      return <UserPlusIcon className={className} />;
    case "system":
      return <SparkIcon className={className} />;
  }
}

function initials(name: string): string {
  const parts = name.trim().split(/\s+/);
  const a = parts[0]?.[0] ?? "";
  const b = parts.length > 1 ? parts[parts.length - 1]?.[0] ?? "" : "";
  return (a + b).toUpperCase();
}

function paletteFor(name: string): string {
  let hash = 0;
  for (let i = 0; i < name.length; i += 1) {
    hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
  }
  return AVATAR_PALETTE[hash % AVATAR_PALETTE.length];
}

const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];

/* -------------------------------- component ------------------------------- */

export default function MenuNotifications() {
  const reduce = useReducedMotion();
  const uid = useId();
  const panelId = `${uid}-panel`;
  const headingId = `${uid}-heading`;
  const listId = `${uid}-list`;

  const [open, setOpen] = useState(false);
  const [filter, setFilter] = useState<FilterId>("all");
  const [notifications, setNotifications] =
    useState<Notification[]>(INITIAL_NOTIFICATIONS);
  const [focusedRow, setFocusedRow] = useState(0);

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const panelRef = useRef<HTMLDivElement | null>(null);
  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const pendingFocus = useRef<number | null>(null);

  const unreadCount = useMemo(
    () => notifications.filter((n) => n.unread).length,
    [notifications],
  );
  const mentionsCount = useMemo(
    () => notifications.filter((n) => n.type === "mention").length,
    [notifications],
  );

  const visible = useMemo(() => {
    if (filter === "unread") return notifications.filter((n) => n.unread);
    if (filter === "mentions")
      return notifications.filter((n) => n.type === "mention");
    return notifications;
  }, [notifications, filter]);

  const selectedTabIndex = FILTERS.findIndex((f) => f.id === filter);

  /* focus first control when opening */
  useEffect(() => {
    if (!open) return;
    setFocusedRow(0);
    const raf = requestAnimationFrame(() => {
      tabRefs.current[selectedTabIndex]?.focus();
    });
    return () => cancelAnimationFrame(raf);
  }, [open, selectedTabIndex]);

  /* reset roving row focus when the filter changes */
  useEffect(() => {
    setFocusedRow(0);
  }, [filter]);

  /* restore focus to a sensible row after a dismissal */
  useEffect(() => {
    if (pendingFocus.current === null) return;
    const target = Math.max(0, Math.min(pendingFocus.current, visible.length - 1));
    pendingFocus.current = null;
    if (visible.length > 0) {
      setFocusedRow(target);
      rowRefs.current[target]?.focus();
    } else {
      panelRef.current?.focus();
    }
  }, [visible.length]);

  /* close on outside pointer */
  useEffect(() => {
    if (!open) return;
    function onPointerDown(event: PointerEvent) {
      const target = event.target as Node;
      if (panelRef.current?.contains(target)) return;
      if (triggerRef.current?.contains(target)) return;
      setOpen(false);
    }
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  function closeAndRestore() {
    setOpen(false);
    triggerRef.current?.focus();
  }

  function onPanelKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (event.key === "Escape") {
      event.preventDefault();
      closeAndRestore();
    }
  }

  function onTabKeyDown(
    event: React.KeyboardEvent<HTMLButtonElement>,
    index: number,
  ) {
    const count = FILTERS.length;
    let next = index;
    if (event.key === "ArrowRight") next = (index + 1) % count;
    else if (event.key === "ArrowLeft") next = (index - 1 + count) % count;
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = count - 1;
    else return;
    event.preventDefault();
    setFilter(FILTERS[next].id);
    tabRefs.current[next]?.focus();
  }

  function focusRow(index: number) {
    setFocusedRow(index);
    rowRefs.current[index]?.focus();
  }

  function onRowKeyDown(
    event: React.KeyboardEvent<HTMLButtonElement>,
    index: number,
    id: string,
  ) {
    const count = visible.length;
    if (count === 0) return;
    if (event.key === "ArrowDown") {
      event.preventDefault();
      focusRow((index + 1) % count);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      focusRow((index - 1 + count) % count);
    } else if (event.key === "Home") {
      event.preventDefault();
      focusRow(0);
    } else if (event.key === "End") {
      event.preventDefault();
      focusRow(count - 1);
    } else if (event.key === "Delete" || event.key === "Backspace") {
      event.preventDefault();
      dismiss(id, index);
    }
  }

  function toggleRead(id: string) {
    setNotifications((prev) =>
      prev.map((n) => (n.id === id ? { ...n, unread: !n.unread } : n)),
    );
  }

  function dismiss(id: string, index: number) {
    pendingFocus.current = index;
    setNotifications((prev) => prev.filter((n) => n.id !== id));
  }

  function markAllRead() {
    setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })));
  }

  const panelMotion = {
    initial: reduce
      ? { opacity: 0 }
      : { opacity: 0, y: -8, scale: 0.97 },
    animate: reduce ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 },
    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.97 },
    transition: { duration: reduce ? 0.12 : 0.2, ease: EASE },
  };

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-100 to-white px-4 py-16 text-slate-900 dark:from-slate-950 dark:to-zinc-900 dark:text-slate-100 sm:py-24">
      <style>{`
        @keyframes mnfPulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.5;transform:scale(.78)} }
        @keyframes mnfPop { 0%{transform:scale(0);opacity:0} 60%{transform:scale(1.2)} 100%{transform:scale(1);opacity:1} }
        .mnf-pulse{ animation: mnfPulse 2.2s ease-in-out infinite; }
        .mnf-pop{ animation: mnfPop .32s cubic-bezier(.22,1,.36,1); }
        @media (prefers-reduced-motion: reduce){
          .mnf-pulse,.mnf-pop{ animation: none !important; }
        }
      `}</style>

      <div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-8">
        <header className="text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Northwind Workspace
          </p>
          <h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">
            Stay on top of what changed
          </h2>
          <p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
            Open the bell to triage mentions, replies, and account activity.
          </p>
        </header>

        {/* App toolbar that anchors the dropdown */}
        <div className="relative w-full rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/70">
          <div className="flex items-center justify-between gap-3">
            <div className="flex items-center gap-2.5">
              <span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-sm">
                <BoltIcon className="h-4 w-4" />
              </span>
              <div className="leading-tight">
                <p className="text-sm font-semibold">Northwind</p>
                <p className="text-[11px] text-slate-500 dark:text-slate-400">
                  Product · Engineering
                </p>
              </div>
            </div>

            <div className="relative">
              <button
                ref={triggerRef}
                type="button"
                onClick={() => setOpen((v) => !v)}
                aria-haspopup="dialog"
                aria-expanded={open}
                aria-controls={panelId}
                aria-label={
                  unreadCount > 0
                    ? `Notifications, ${unreadCount} unread`
                    : "Notifications"
                }
                className="relative grid h-10 w-10 place-items-center rounded-xl border border-slate-200 bg-white text-slate-600 outline-none transition hover:bg-slate-50 hover:text-slate-900 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
              >
                <BellIcon className="h-5 w-5" />
                {unreadCount > 0 && (
                  <span
                    key={unreadCount}
                    className="mnf-pop absolute -right-1 -top-1 grid h-5 min-w-5 place-items-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white ring-2 ring-white dark:ring-slate-900"
                  >
                    {unreadCount > 9 ? "9+" : unreadCount}
                  </span>
                )}
              </button>

              <AnimatePresence>
                {open && (
                  <motion.div
                    key="panel"
                    ref={panelRef}
                    id={panelId}
                    role="dialog"
                    aria-labelledby={headingId}
                    tabIndex={-1}
                    onKeyDown={onPanelKeyDown}
                    initial={panelMotion.initial}
                    animate={panelMotion.animate}
                    exit={panelMotion.exit}
                    transition={panelMotion.transition}
                    style={{ transformOrigin: "top right" }}
                    className="absolute right-0 top-[calc(100%+0.6rem)] z-20 flex max-h-[30rem] w-[22rem] flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-left shadow-2xl shadow-slate-900/10 outline-none dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40 sm:w-[24rem]"
                  >
                    {/* header */}
                    <div className="flex items-center justify-between gap-2 border-b border-slate-100 px-4 pb-3 pt-3.5 dark:border-slate-800">
                      <div className="flex items-center gap-2">
                        <h3
                          id={headingId}
                          className="text-sm font-semibold text-slate-900 dark:text-white"
                        >
                          Notifications
                        </h3>
                        {unreadCount > 0 && (
                          <span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
                            {unreadCount} new
                          </span>
                        )}
                      </div>
                      <button
                        type="button"
                        onClick={markAllRead}
                        disabled={unreadCount === 0}
                        className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-indigo-600 outline-none transition hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:text-slate-400 disabled:hover:bg-transparent dark:text-indigo-300 dark:hover:bg-indigo-500/10 dark:disabled:text-slate-600"
                      >
                        <CheckCheckIcon className="h-3.5 w-3.5" />
                        Mark all read
                      </button>
                    </div>

                    {/* filter tabs */}
                    <div
                      role="tablist"
                      aria-label="Filter notifications"
                      className="flex gap-1 border-b border-slate-100 px-3 py-2 dark:border-slate-800"
                    >
                      {FILTERS.map((tab, index) => {
                        const active = tab.id === filter;
                        const count =
                          tab.id === "unread"
                            ? unreadCount
                            : tab.id === "mentions"
                              ? mentionsCount
                              : notifications.length;
                        return (
                          <button
                            key={tab.id}
                            ref={(el) => {
                              tabRefs.current[index] = el;
                            }}
                            role="tab"
                            id={`${uid}-tab-${tab.id}`}
                            aria-selected={active}
                            aria-controls={listId}
                            tabIndex={active ? 0 : -1}
                            onClick={() => setFilter(tab.id)}
                            onKeyDown={(e) => onTabKeyDown(e, index)}
                            className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                              active
                                ? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
                                : "text-slate-500 hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                            }`}
                          >
                            {tab.label}
                            <span
                              className={`rounded-full px-1.5 text-[10px] font-semibold leading-tight ${
                                active
                                  ? "bg-white/20 text-white dark:bg-slate-900/15 dark:text-slate-900"
                                  : "bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-300"
                              }`}
                            >
                              {count}
                            </span>
                          </button>
                        );
                      })}
                    </div>

                    {/* list */}
                    <div
                      id={listId}
                      role="tabpanel"
                      aria-labelledby={`${uid}-tab-${filter}`}
                      className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
                    >
                      {visible.length === 0 ? (
                        <div className="flex flex-col items-center gap-2 px-6 py-12 text-center">
                          <span className="grid h-12 w-12 place-items-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-300">
                            <InboxIcon className="h-6 w-6" />
                          </span>
                          <p className="text-sm font-semibold text-slate-800 dark:text-slate-200">
                            You&apos;re all caught up
                          </p>
                          <p className="text-xs text-slate-500 dark:text-slate-400">
                            No {filter === "all" ? "" : `${filter} `}notifications
                            to show right now.
                          </p>
                        </div>
                      ) : (
                        <ul className="divide-y divide-slate-100 dark:divide-slate-800">
                          <AnimatePresence initial={false}>
                            {visible.map((n, index) => (
                              <motion.li
                                key={n.id}
                                layout={!reduce}
                                initial={false}
                                exit={
                                  reduce
                                    ? { opacity: 0 }
                                    : { opacity: 0, x: 24, height: 0 }
                                }
                                transition={{ duration: reduce ? 0.1 : 0.22, ease: EASE }}
                                className="relative"
                              >
                                <button
                                  ref={(el) => {
                                    rowRefs.current[index] = el;
                                  }}
                                  type="button"
                                  tabIndex={index === focusedRow ? 0 : -1}
                                  onFocus={() => setFocusedRow(index)}
                                  onClick={() => toggleRead(n.id)}
                                  onKeyDown={(e) => onRowKeyDown(e, index, n.id)}
                                  aria-label={`${n.actor} ${n.title}. ${n.time} ago. ${
                                    n.unread ? "Unread." : "Read."
                                  } Enter to mark ${
                                    n.unread ? "read" : "unread"
                                  }, Delete to dismiss.`}
                                  className={`group flex w-full items-start gap-3 px-4 py-3 pr-10 text-left outline-none transition focus-visible:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:focus-visible:bg-indigo-500/10 ${
                                    n.unread
                                      ? "bg-indigo-50/50 hover:bg-indigo-50 dark:bg-indigo-500/[0.06] dark:hover:bg-indigo-500/10"
                                      : "hover:bg-slate-50 dark:hover:bg-slate-800/60"
                                  }`}
                                >
                                  {/* avatar / tile with type badge */}
                                  <span className="relative shrink-0">
                                    <span
                                      className={`grid h-10 w-10 place-items-center rounded-xl text-sm font-semibold ${
                                        n.isPerson
                                          ? paletteFor(n.actor)
                                          : TYPE_TILE[n.type]
                                      }`}
                                    >
                                      {n.isPerson
                                        ? initials(n.actor)
                                        : typeIcon(n.type, "h-5 w-5")}
                                    </span>
                                    {n.isPerson && (
                                      <span
                                        className={`absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full ring-2 ring-white dark:ring-slate-900 ${TYPE_BADGE[n.type]}`}
                                      >
                                        {typeIcon(n.type, "h-2.5 w-2.5")}
                                      </span>
                                    )}
                                  </span>

                                  {/* text */}
                                  <span className="min-w-0 flex-1">
                                    <span className="block text-sm leading-snug text-slate-700 dark:text-slate-300">
                                      <span className="font-semibold text-slate-900 dark:text-white">
                                        {n.actor}
                                      </span>{" "}
                                      {n.title}
                                    </span>
                                    <span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-slate-400">
                                      {n.body}
                                    </span>
                                    <span className="mt-1 block text-[11px] font-medium text-slate-400 dark:text-slate-500">
                                      {n.time} ago
                                    </span>
                                  </span>

                                  {/* unread dot */}
                                  {n.unread && (
                                    <span
                                      aria-hidden="true"
                                      className="mnf-pulse mt-1.5 h-2 w-2 shrink-0 rounded-full bg-indigo-500 dark:bg-indigo-400"
                                    />
                                  )}
                                </button>

                                {/* dismiss (pointer affordance; keyboard uses Delete) */}
                                <button
                                  type="button"
                                  tabIndex={-1}
                                  onClick={() => dismiss(n.id, index)}
                                  aria-label={`Dismiss notification from ${n.actor}`}
                                  className="absolute right-2 top-2.5 grid h-7 w-7 place-items-center rounded-lg text-slate-400 opacity-0 outline-none transition hover:bg-slate-200 hover:text-slate-700 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-indigo-500 group-hover:opacity-100 dark:hover:bg-slate-700 dark:hover:text-slate-200"
                                >
                                  <XIcon className="h-4 w-4" />
                                </button>
                              </motion.li>
                            ))}
                          </AnimatePresence>
                        </ul>
                      )}
                    </div>

                    {/* footer */}
                    <div className="flex items-center justify-between gap-2 border-t border-slate-100 px-3 py-2.5 dark:border-slate-800">
                      <button
                        type="button"
                        className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-medium text-slate-500 outline-none transition hover:bg-slate-100 hover:text-slate-800 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
                      >
                        <GearIcon className="h-3.5 w-3.5" />
                        Settings
                      </button>
                      <button
                        type="button"
                        onClick={closeAndRestore}
                        className="rounded-lg px-2.5 py-1 text-xs font-semibold text-indigo-600 outline-none transition hover:bg-indigo-50 focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
                      >
                        View all notifications
                      </button>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>
        </div>

        <p className="text-center text-xs text-slate-400 dark:text-slate-500">
          Tab to the bell · Arrow keys move between tabs and items · Enter toggles
          read · Delete dismisses · Esc closes
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →