Cards

Notification Card

Inbox-style notification with a fading unread dot, hover-revealed actions, swipe- or button-dismiss, and a stack that closes gaps via layout animation.

Install

npx shadcn@latest add @paragon/notification-card

notification-card.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type PanInfo,
} from "motion/react";
import {
  AlertTriangle,
  CheckCircle2,
  Info,
  MessageSquare,
  UserPlus,
  X,
} from "lucide-react";
import { cn } from "@/lib/utils";

export type NotificationType =
  | "info"
  | "success"
  | "warning"
  | "mention"
  | "invite";

const typeIcons: Record<
  NotificationType,
  React.ComponentType<{ className?: string }>
> = {
  info: Info,
  success: CheckCircle2,
  warning: AlertTriangle,
  mention: MessageSquare,
  invite: UserPlus,
};

const typeIconClasses: Record<NotificationType, string> = {
  info: "text-muted-foreground",
  success: "text-emerald-600 dark:text-emerald-400",
  warning: "text-amber-600 dark:text-amber-400",
  mention: "text-blue-600 dark:text-blue-400",
  invite: "text-violet-600 dark:text-violet-400",
};

export interface NotificationAction {
  label: string;
  onClick?: () => void;
}

export interface NotificationCardProps
  extends Omit<React.ComponentProps<typeof motion.div>, "title"> {
  title: React.ReactNode;
  description?: React.ReactNode;
  /** Picks the leading icon and its tint. */
  type?: NotificationType;
  /** Relative timestamp shown on the trailing edge, e.g. "2h". */
  timestamp?: string;
  /** Unread state — renders the dot, which fades out when this flips false. */
  unread?: boolean;
  /** Quick actions revealed on hover (fine pointers only). */
  actions?: NotificationAction[];
  /** Called after the dismiss gesture or button. Omit to hide dismissal. */
  onDismiss?: () => void;
  /** Disables the swipe-to-dismiss drag gesture. */
  static?: boolean;
}

/**
 * An inbox-style notification row.
 *
 * The unread dot fades (rather than pops) when read; the action row rises
 * 4px + fades in on hover, gated to fine pointers so touch users are never
 * stranded. Dismissal exits right with a fade — via the button on desktop
 * or a drag past the threshold on touch. Rendered as a motion layout child,
 * so a `NotificationStack` closes the gap smoothly when one leaves.
 */
export function NotificationCard({
  title,
  description,
  type = "info",
  timestamp,
  unread = false,
  actions,
  onDismiss,
  static: isStatic = false,
  className,
  ...props
}: NotificationCardProps) {
  const reducedMotion = useReducedMotion();
  const Icon = typeIcons[type];
  const draggable = !isStatic && !reducedMotion && Boolean(onDismiss);

  const handleDragEnd = (
    _: PointerEvent | MouseEvent | TouchEvent,
    info: PanInfo,
  ) => {
    if (info.offset.x > 96 || info.velocity.x > 500) onDismiss?.();
  };

  return (
    <motion.div
      layout={!reducedMotion}
      data-slot="notification-card"
      data-unread={unread || undefined}
      initial={false}
      exit={{
        opacity: 0,
        x: reducedMotion ? 0 : 64,
        transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
      }}
      drag={draggable ? "x" : false}
      dragConstraints={{ left: 0, right: 0 }}
      dragElastic={{ left: 0, right: 0.6 }}
      dragSnapToOrigin
      onDragEnd={draggable ? handleDragEnd : undefined}
      className={cn(
        "group/notification relative flex items-start gap-3 rounded-lg bg-card p-3 shadow-border",
        draggable && "touch-pan-y",
        className,
      )}
      {...props}
    >
      <span className="mt-0.5 shrink-0" aria-hidden>
        <Icon className={cn("size-4", typeIconClasses[type])} />
      </span>

      <div className="min-w-0 flex-1">
        <div className="flex items-baseline gap-2">
          <p className="min-w-0 flex-1 truncate text-sm font-medium">
            {title}
          </p>
          {timestamp ? (
            <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
              {timestamp}
            </span>
          ) : null}
          <span
            aria-hidden
            className={cn(
              "size-1.5 shrink-0 self-center rounded-full bg-blue-500 transition-opacity duration-(--duration-base) ease-out",
              unread ? "opacity-100" : "opacity-0",
            )}
          />
          <span className="sr-only">{unread ? "Unread" : "Read"}</span>
        </div>
        {description ? (
          <p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">
            {description}
          </p>
        ) : null}
        {actions && actions.length > 0 ? (
          <div
            data-slot="notification-actions"
            className="mt-2 flex gap-1.5 [@media(hover:hover)_and_(pointer:fine)]:translate-y-1 [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:transition-[opacity,translate] [@media(hover:hover)_and_(pointer:fine)]:duration-(--duration-quick) [@media(hover:hover)_and_(pointer:fine)]:ease-out [@media(hover:hover)_and_(pointer:fine)]:group-hover/notification:translate-y-0 [@media(hover:hover)_and_(pointer:fine)]:group-hover/notification:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/notification:translate-y-0 [@media(hover:hover)_and_(pointer:fine)]:group-focus-within/notification:opacity-100 motion-reduce:translate-y-0 motion-reduce:transition-none"
          >
            {actions.map((action) => (
              <button
                key={action.label}
                type="button"
                onClick={action.onClick}
                className="pressable rounded-md bg-secondary px-2.5 py-1 text-xs font-medium text-secondary-foreground transition-colors duration-150 ease-out hover:bg-secondary/80"
              >
                {action.label}
              </button>
            ))}
          </div>
        ) : null}
      </div>

      {onDismiss ? (
        <button
          type="button"
          aria-label="Dismiss notification"
          onClick={onDismiss}
          className="pressable relative -mr-1 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2 [@media(hover:hover)_and_(pointer:fine)]:opacity-0 [@media(hover:hover)_and_(pointer:fine)]:transition-[opacity,scale] [@media(hover:hover)_and_(pointer:fine)]:group-hover/notification:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:focus-visible:opacity-100"
        >
          <X className="size-3.5" />
        </button>
      ) : null}
    </motion.div>
  );
}

export interface NotificationStackProps extends React.ComponentProps<"div"> {}

/**
 * Container for a list of NotificationCards. Wraps children in
 * AnimatePresence with `popLayout`, so dismissed cards exit right while the
 * rest close the gap via layout animation. Give each card a stable `key`.
 */
export function NotificationStack({
  className,
  children,
  ...props
}: NotificationStackProps) {
  return (
    <div
      data-slot="notification-stack"
      className={cn("flex flex-col gap-2", className)}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        {children}
      </AnimatePresence>
    </div>
  );
}