Inbox-style notification with a fading unread dot, hover-revealed actions, swipe- or button-dismiss, and a stack that closes gaps via layout animation.
npx shadcn@latest add @paragon/notification-card"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>
);
}