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

Cómo crear una bandeja de notificaciones con filtros en React

Una bandeja de notificaciones React almacena los elementos en state local, deriva una lista filtrada a partir de la selección de un pill de filtro y anima las entradas y salidas con Framer Motion AnimatePresence usando el prop layout en cada item. Marcar como leído y eliminar son simples actualizaciones de state con useCallback.

  • Stack: React 18 + Framer Motion 11 + Lucide React, ~320 líneas, cero dependencias adicionales.
  • Cinco tipos de notificación (mensaje, alerta, mención, invitación, sistema), cada uno asociado a un icono y un color.
  • AnimatePresence con el prop layout gestiona la inserción y eliminación fluida, los items se contraen a altura cero al salir.
  • Accesible: los botones llevan atributos title; el badge de no leído usa un color de acento con contraste suficiente.
  • Totalmente responsive. La fila de pills de filtro se desplaza horizontalmente en pantallas estrechas.

Notification Inbox es un panel React autónomo que reproduce el feed que encuentras dentro de apps como Slack o Linear: una toolbar, pills de filtro por tipo, una lista desplazable y acciones de lectura y eliminación por item. Toda la interacción vive en state local, así que se integra en cualquier página sin backend. La lista animada es la parte interesante: Framer Motion gestiona tanto las transiciones de filtro por tipo como el colapso al eliminar, de modo que la UI nunca da saltos.

Anatomía

El componente se construye en tres zonas apiladas dentro de una tarjeta redondeada. La toolbar superior muestra el título, un badge de contador de no leídos en vivo y un botón 'marcar todo como leído' que solo aparece cuando hay items sin leer. Debajo, una fila de pills desplazable permite alternar entre Todos, No leídos y los cinco filtros por tipo. La lista ocupa el espacio vertical restante con un max-height y overflow-y:auto. Cada fila tiene un avatar de 36px (iniciales o icono de tipo), título + mensaje + timestamp y uno o dos botones iconográficos. Un punto en posición absoluta sobre el avatar indica el estado de no leído.

Cómo funciona

La lista filtrada es un valor derivado calculado de forma síncrona a partir del state items y el filtro activo, sin necesidad de effect. Cuando un usuario elimina un item, la actualización del state lo retira del array de inmediato; AnimatePresence detecta la clave que abandona el DOM y reproduce la animación de salida (opacity 0, height 0) antes de desmontar. El prop layout en cada motion.div indica a Framer Motion que anime los siblings restantes hacia sus nuevas posiciones para que la lista cierre el hueco sin sobresaltos. Marcar como leído es un simple map sobre el array que pone isRead:true, lo que también elimina el punto de no leído y el badge sin coste de animación adicional.

Cómo crearlo en React

  1. Modela tus notificaciones y prepara el state

    Define una interfaz InboxNotification con id, type, title, message, timestamp, isRead y campos sender opcionales. Almacena la lista en useState. Deriva la lista filtrada y el contador de no leídos de forma síncrona a partir de ese state, sin necesidad de useEffect.

    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. Construye la fila de pills de filtro

    Recorre con map un array fijo de claves de filtro y renderiza un botón para cada una. El pill activo recibe un borde y un fondo de acento mediante color-mix; los pills inactivos son transparentes con un borde atenuado. Aplica overflow-x:auto y white-space:nowrap en el contenedor para que se desplace en pantallas estrechas en lugar de envolver.

    {(["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. Anima la lista con AnimatePresence + layout

    Envuelve la lista recorrida en AnimatePresence con initial={false} para que los items existentes no se animen al montar. Cada motion.div necesita una clave estable (el id de la notificación), el prop layout y variants initial/animate/exit que controlan opacity y height. La salida hacia height:0 crea el efecto de colapso cuando los items se eliminan o se filtran.

    <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. Conecta marcar-como-leído y eliminar

    Ambas acciones son transformaciones puras de state envueltas en useCallback. Marcar-como-leído recorre el array y cambia isRead; eliminar filtra el item fuera del array. Como AnimatePresence vigila la lista por clave, la animación de salida se dispara automáticamente al eliminar sin ningún disparador manual.

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

Cuándo usarlo

Este componente encaja en cualquier lugar donde un usuario deba clasificar un flujo mixto de eventos: dashboards SaaS, paneles de administración, herramientas para desarrolladores, apps internas. Los pills de filtro lo hacen práctico cuando hay varios tipos de notificaciones que los usuarios quieren aislar. Evítalo para feeds de un solo tipo (chat puro, alertas puras) donde una lista más simple sin la sobrecarga de los pills se lee mejor. En el lado del backend, reemplaza el array de useState por una suscripción en tiempo real (WebSocket, SSE o polling con SWR) y el componente encaja sin cambios.

Usado por

  • Linear, Centro de notificaciones estilo inbox con filtros por tipo, contadores de no leídos y marcado-como-hecho controlado por teclado.
  • GitHub, Bandeja de notificaciones con filtros por motivo (menciones, revisiones, CI) y acciones masivas de lectura y descarte.
  • Slack, Panel de feed de actividad con filtrado por tipo e indicadores de punto de no leído en cada entrada.
  • Vercel, Feed de notificaciones del dashboard con tipos de eventos de despliegue, equipo y facturación, cada uno con código de color.

FAQ

¿Cómo conecto este componente a un backend real?

Reemplaza el inicializador de useState por una llamada fetch o SWR al montar, y canaliza las actualizaciones en tiempo real (WebSocket, SSE) hacia setItems. Los handlers de marcar-como-leído y eliminar necesitan una llamada API correspondiente antes o después de la actualización optimista del state.

¿Por qué AnimatePresence necesita initial={false}?

Sin él, cada item que ya está en la lista reproduce su animación de entrada cuando el componente se monta por primera vez, lo que se ve mal. initial={false} indica a AnimatePresence que omita la animación de entrada para los items presentes al montar y que solo anime los items que se añaden o eliminan realmente después.

¿Se puede elevar el state del filtro a un parámetro de URL para deep linking?

Sí. Reemplaza useState por useSearchParams (Next.js) o la API query de tu router. Leer y escribir la clave del filtro en la URL hace que la pestaña activa se pueda guardar en marcadores y compartir sin ningún otro cambio en la lógica del componente.

¿La animación layout es costosa con muchos items?

Para volúmenes habituales de notificaciones (menos de 100 items), el prop layout no tiene coste perceptible. Framer Motion agrupa los recálculos de layout. Si renderizas cientos de items, considera virtualizar la lista con react-virtual y gestionar AnimatePresence solo para la ventana 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

Reseñas

Bandeja de notificaciones React con filtros, Código + Demo