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

Créer une boîte de réception de notifications React avec filtres

Une boîte de réception notifications React stocke les éléments en state local, dérive une liste filtrée depuis la sélection d'un pill de filtre, et anime les entrées/sorties avec Framer Motion AnimatePresence et le prop layout sur chaque item. Marquer comme lu et supprimer sont de simples mises à jour de state avec useCallback.

  • Stack : React 18 + Framer Motion 11 + Lucide React, ~320 lignes, zéro dépendance supplémentaire.
  • Cinq types de notifications (message, alerte, mention, invitation, système) chacun mappé à une icône et une couleur.
  • AnimatePresence avec le prop layout gère l'insertion/suppression fluide, les items s'effondrent à zéro hauteur à la sortie.
  • Accessible : les boutons portent des attributs title ; le badge non-lu utilise une couleur d'accent à contraste suffisant.
  • Entièrement responsive. La rangée de pills défile horizontalement sur les petits écrans.

Notification Inbox est un panneau React autonome qui reproduit le feed qu'on trouve dans des apps comme Slack ou Linear : une toolbar, des pills de filtre par type, une liste défilante, et des actions lecture/suppression par item. Toutes les interactions sont en state local, donc il s'intègre dans n'importe quelle page sans backend. La liste animée est la partie intéressante : Framer Motion gère à la fois les transitions de filtre par type et l'effondrement à la suppression pour que l'UI ne saute jamais.

Anatomie

Le composant est construit en trois zones empilées dans une carte arrondie. La toolbar en haut affiche le titre, un badge de compteur non-lu en direct, et un bouton 'tout marquer comme lu' qui n'apparaît qu'en présence d'items non-lus. En dessous, une rangée de pills défilable permet de basculer entre Tout, Non lus et les cinq filtres par type. La liste occupe l'espace vertical restant avec un max-height et overflow-y:auto. Chaque ligne comporte un avatar 36px (initiales ou icône de type), titre + message + timestamp, et un ou deux boutons iconiques. Un point en position absolue sur l'avatar indique le statut non-lu.

Comment ça marche

La liste filtrée est une valeur dérivée calculée de façon synchrone depuis le state items et le filtre actif, aucun effet nécessaire. Quand un utilisateur supprime un item, la mise à jour du state le retire immédiatement du tableau ; AnimatePresence détecte la clé qui quitte le DOM et joue l'animation de sortie (opacity 0, height 0) avant de démonter. Le prop layout sur chaque motion.div indique à Framer Motion d'animer les siblings restants vers leurs nouvelles positions pour que la liste comble le vide sans à-coup. Marquer comme lu est un simple map sur le tableau qui pose isRead:true, ce qui efface aussi le point non-lu et le badge sans coût d'animation supplémentaire.

Comment le coder en React

  1. Modélise les notifications et initialise le state

    Définis une interface InboxNotification avec id, type, title, message, timestamp, isRead et des champs sender optionnels. Stocke la liste dans useState. Dérive la liste filtrée et le compteur non-lu synchronement depuis ce state, aucun useEffect nécessaire.

    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. Construis la rangée de pills de filtre

    Mappe sur un tableau fixe de clés de filtre et rends un bouton pour chacune. Le pill actif obtient une bordure et un fond accent via color-mix ; les pills inactifs sont transparents avec une bordure atténuée. Pose overflow-x:auto et white-space:nowrap sur le conteneur pour qu'il défile sur les petits écrans au lieu de wraper.

    {(["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. Anime la liste avec AnimatePresence + layout

    Enveloppe la liste mappée dans AnimatePresence avec initial={false} pour que les items existants n'animent pas au montage. Chaque motion.div a besoin d'une clé stable (l'id de la notification), du prop layout, et des variants initial/animate/exit qui pilotent opacity et height. La sortie vers height:0 crée l'effet d'effondrement quand les items sont supprimés ou filtrés.

    <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. Branche marquer-comme-lu et suppression

    Les deux actions sont des transformations de state pures enveloppées dans useCallback. Marquer-comme-lu mappe sur le tableau et bascule isRead ; supprimer filtre l'item hors du tableau. Puisque AnimatePresence surveille la liste par clé, l'animation de sortie se déclenche automatiquement à la suppression sans déclencheur manuel.

    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));
    }, []);

Quand l'utiliser

Ce composant convient partout où un utilisateur doit trier un flux mixte d'événements : dashboards SaaS, panneaux admin, outils développeur, apps internes. Les pills de filtre le rendent pratique quand il existe plusieurs types de notifications que les utilisateurs veulent isoler. À éviter pour les flux à type unique (chat pur, alertes pures) où une liste plus simple sans la surcharge des pills est plus lisible. Côté backend, remplace le tableau useState par un abonnement temps réel (WebSocket, SSE, ou polling SWR) et le composant s'intègre sans modification.

Utilisé par

  • Linear, Centre de notifications style inbox avec filtres par type, compteurs non-lus, et archivage piloté au clavier.
  • GitHub, Boîte de réception notifications avec filtres par raison (mentions, revues, CI) et actions groupées de lecture/rejet.
  • Slack, Panneau de fil d'activité avec filtrage par type et indicateurs de points non-lus sur chaque entrée.
  • Vercel, Fil de notifications dashboard avec types d'événements déploiement, équipe et facturation, chacun code couleur.

FAQ

Comment connecter ce composant à un vrai backend ?

Remplace l'initialisateur de useState par un fetch ou un appel SWR au montage, et achemine les mises à jour temps réel (WebSocket, SSE) vers setItems. Les handlers marquer-comme-lu et supprimer nécessitent un appel API correspondant avant ou après la mise à jour optimiste du state.

Pourquoi AnimatePresence a besoin de initial={false} ?

Sans ça, chaque item déjà dans la liste joue son animation d'entrée au premier montage du composant, ce qui paraît incorrect. initial={false} indique à AnimatePresence de sauter l'animation d'entrée pour les items présents au montage et de n'animer que les items réellement ajoutés ou supprimés ensuite.

Peut-on lever le state du filtre vers un param URL pour le deep linking ?

Oui. Remplace useState par useSearchParams (Next.js) ou l'API query de ton routeur. Lire et écrire la clé de filtre dans l'URL rend l'onglet actif bookmarkable et partageable sans aucun autre changement dans la logique du composant.

L'animation layout est-elle coûteuse avec beaucoup d'items ?

Pour les volumes habituels de notifications (moins de 100 items), le prop layout n'a aucun coût perceptible. Framer Motion groupe les recalculs de layout. Si tu rendes des centaines d'items, envisage de virtualiser la liste avec react-virtual et de gérer AnimatePresence uniquement pour la fenêtre visible.

"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

Avis

Boîte de réception notifications React avec filtres, Code