Retour au catalogue

Notification Inbox

Panneau de boite de reception de notifications avec filtres, marquer comme lu, tri par date et actions groupees.

notificationcomplex Both Responsive a11y
minimalcorporatesaasuniversalstacked
Theme

How to build a notification inbox with filters in React

A React notification inbox stores items in local state, derives a filtered list from a filter pill selection, and animates entry/exit with Framer Motion AnimatePresence using layout prop on each item. Mark-as-read and delete are plain state updates with useCallback.

  • Stack: React 18 + Framer Motion 11 + Lucide React, ~320 lines, zero extra dependencies.
  • Five notification types (message, alert, mention, invite, system) each mapped to an icon and color.
  • AnimatePresence with layout prop handles smooth insert/remove, items collapse to zero height on exit.
  • Accessible: buttons carry title attributes; unread badge uses a contrast-safe accent color.
  • Fully responsive. The filter pill row scrolls horizontally on narrow viewports.

Notification Inbox is a self-contained React panel that mimics the feed you find inside apps like Slack or Linear, a toolbar, filterable type pills, a scrollable list, and per-item read/delete actions. Every interaction is local state, so it drops into any page without a backend. The animated list is the interesting part: Framer Motion handles both the type-filter transitions and the delete collapse so the UI never jumps.

Anatomy

The component is built in three stacked zones inside a rounded card. The toolbar at the top shows the title, a live unread count badge, and a 'mark all read' button that only appears when there are unread items. Below it, a scrollable pill row lets the user switch between All, Unread, and the five type filters. The list takes the remaining vertical space with a max-height and overflow-y:auto. Each row has a 36px avatar (initials or type icon), title + message + timestamp, and one or two icon buttons. An absolute-positioned dot on the avatar indicates unread status.

How it works

The filtered list is a derived value computed synchronously from items state and the active filter, no effect needed. When a user deletes an item, the state update removes it from the array immediately; AnimatePresence detects the key leaving the DOM and plays the exit animation (opacity 0, height 0) before unmounting. The layout prop on each motion.div tells Framer Motion to animate the remaining siblings into their new positions so the list closes the gap smoothly. Mark-as-read is a simple map over the array setting isRead:true, which also collapses the unread dot and badge without any animation overhead.

How to build it in React

  1. Model your notifications and set up state

    Define an InboxNotification interface with id, type, title, message, timestamp, isRead, and optional sender fields. Store the list in useState. Derive the filtered list and unread count synchronously from that state, no useEffect required.

    type NotificationType = "message" | "alert" | "mention" | "invite" | "system";
    interface InboxNotification {
      id: string;
      type: NotificationType;
      title: string;
      message: string;
      timestamp: string;
      isRead: boolean;
      sender?: string;
      senderInitials?: string;
    }
    const [items, setItems] = useState<InboxNotification[]>(initialNotifications);
    const filtered = items.filter(item =>
      filter === "all" ? true : filter === "unread" ? !item.isRead : item.type === filter
    );
  2. Build the filter pill row

    Map over a fixed array of filter keys and render a button for each. The active pill gets an accent border and background via color-mix; inactive pills are transparent with a muted border. Set overflow-x:auto and white-space:nowrap on the container so it scrolls on narrow screens instead of wrapping.

    {(["all", "unread", "message", "alert", "mention", "invite", "system"] as FilterType[]).map(f => (
      <button
        key={f}
        onClick={() => setFilter(f)}
        style={{
          border: filter === f ? "1px solid var(--color-accent)" : "1px solid var(--color-border)",
          background: filter === f ? "color-mix(in srgb, var(--color-accent) 10%, transparent)" : "transparent",
          color: filter === f ? "var(--color-accent)" : "var(--color-foreground-muted)",
        }}
      >
        {filterLabels[f]}
      </button>
    ))}
  3. Animate the list with AnimatePresence + layout

    Wrap the mapped list in AnimatePresence with initial={false} so existing items don't animate on mount. Each motion.div needs a stable key (the notification id), the layout prop, and initial/animate/exit variants that drive opacity and height. The exit to height:0 creates the collapsing effect when items are deleted or filtered out.

    <AnimatePresence initial={false}>
      {filtered.map(notif => (
        <motion.div
          key={notif.id}
          layout
          initial={{ opacity: 0, height: 0 }}
          animate={{ opacity: 1, height: "auto" }}
          exit={{ opacity: 0, height: 0 }}
          transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
          style={{ overflow: "hidden" }}
        >
          {/* row content */}
        </motion.div>
      ))}
    </AnimatePresence>
  4. Wire mark-as-read and delete

    Both actions are pure state transformations wrapped in useCallback. Mark-as-read maps over the array and flips isRead; delete filters the item out. Because AnimatePresence watches the keyed list, the exit animation fires automatically on delete without any manual trigger.

    const markAsRead = useCallback((id: string) => {
      setItems(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n));
    }, []);
    
    const deleteNotification = useCallback((id: string) => {
      setItems(prev => prev.filter(n => n.id !== id));
    }, []);

When to use it

This component fits anywhere a user needs to triage a mixed stream of events: SaaS dashboards, admin panels, developer tools, internal apps. The filter pills make it practical when there are multiple notification types that users want to isolate. Avoid it for single-type feeds (pure chat, pure alerts) where a simpler list without the pill overhead reads better. On the backend side, replace the useState array with a real-time subscription (WebSocket, SSE, or SWR polling) and the component slots in unchanged.

Used by

  • Linear, Inbox-style notification center with type filters, unread counts, and keyboard-driven mark-as-done.
  • GitHub, Notifications inbox with reason-based filters (mentions, reviews, CI) and bulk read/dismiss actions.
  • Slack, Activity feed panel with per-type filtering and unread dot indicators on each entry.
  • Vercel, Dashboard notification feed with deployment, team, and billing event types, each color-coded.

FAQ

How do I connect this to a real backend?

Replace the useState initializer with a fetch or SWR call on mount, and pipe real-time updates (WebSocket, SSE) into setItems. The mark-as-read and delete handlers need a corresponding API call before or after the optimistic state update.

Why does AnimatePresence need initial={false}?

Without it, every item already in the list plays its enter animation when the component first mounts, which looks wrong. initial={false} tells AnimatePresence to skip the entry animation for items present at mount and only animate items that are genuinely added or removed afterward.

Can the filter state be lifted to a URL param for deep linking?

Yes. Replace useState with useSearchParams (Next.js) or your router's query API. Reading and writing the filter key in the URL makes the active tab bookmarkable and shareable with no other change to the component logic.

Is the layout animation expensive with many items?

For typical notification volumes (under 100 items), the layout prop has no perceptible cost. Framer Motion batches layout recalculations. If you're rendering hundreds of items, consider virtualizing the list with react-virtual and managing AnimatePresence only for the visible window.

"use client";

import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Bell, Inbox, Check, CheckCheck, Trash2, Filter, Circle, MessageSquare, AlertTriangle, Star, UserPlus, Mail } from "lucide-react";
import React from "react";

type NotificationType = "message" | "alert" | "mention" | "invite" | "system";
type FilterType = "all" | "unread" | NotificationType;

interface InboxNotification {
  id: string;
  type: NotificationType;
  title: string;
  message: string;
  timestamp: string;
  isRead: boolean;
  sender?: string;
  senderInitials?: string;
}

interface NotificationInboxProps {

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Notification Inbox with Filters, Code + Demo