"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; const TONES = { destructive: "[--drip:var(--color-destructive)] text-white", primary: "[--drip:var(--color-primary)] text-primary-foreground", info: "[--drip:var(--color-info)] text-white", success: "[--drip:var(--color-success)] text-white", } as const; const H = 18; const PAD = 14; const roll = { enter: (d: number) => ({ y: d * 9, opacity: 0 }), center: { y: 0, opacity: 1 }, exit: (d: number) => ({ y: -d * 9, opacity: 0 }), }; // Same keys as `roll` so the server and a reduced-motion client render identical styles. const fade = { enter: { y: 0, opacity: 0 }, center: { y: 0, opacity: 1 }, exit: { y: 0, opacity: 0 } }; type Splash = { id: number; kind: "drip" | "split" }; export interface DripBadgeProps { count: number; max?: number; tone?: keyof typeof TONES; children: ReactNode; showZero?: boolean; className?: string; } export function DripBadge({ count, max = 99, tone = "destructive", children, showZero = false, className }: DripBadgeProps) { const reduce = useReducedMotion(); const filterId = `drip-badge-${useId().replace(/:/g, "")}`; const previous = useRef(count); const nextId = useRef(0); const [splashes, setSplashes] = useState([]); const [shown, setShown] = useState(count); const [direction, setDirection] = useState<1 | -1>(1); if (count !== shown) { setShown(count); setDirection(count > shown ? 1 : -1); } useEffect(() => { const before = previous.current; previous.current = count; if (count === before) return; if (reduce || (count === 0 && !showZero) || (before === 0 && !showZero)) return; const splash: Splash = { id: nextId.current++, kind: count > before ? "drip" : "split" }; setSplashes((s) => [...s.slice(-2), splash]); }, [count, reduce, showZero]); const visible = count > 0 || showZero; const label = count > max ? `${max}+` : String(count); const width = Math.max(H, Math.round(label.length * 6.6 + 10)); return ( {children} {count === 0 ? "No unread notifications" : `${count} unread`} {visible && ( )} {splashes.map((splash) => splash.kind === "drip" ? ( setSplashes((s) => s.filter((x) => x.id !== splash.id))} style={{ left: PAD + width / 2 - 4, top: PAD + H - 9, width: 8, height: 8 }} transition={{ duration: 0.7, times: [0, 0.3, 0.55, 1], ease: ["easeOut", "easeIn", "easeIn"] }} /> ) : ( setSplashes((s) => s.filter((x) => x.id !== splash.id))} style={{ left: PAD + width - 12, top: PAD + (H - 12) / 2, width: 12, height: 12 }} transition={{ duration: 0.5, times: [0, 0.4, 1], ease: ["easeOut", "easeIn"] }} /> ), )} {visible && ( {label} )} ); }