Overlays & Feedbackv0.1.0
Notification Badge
A diagonal slide, spring pop and rolling count.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/notification-badge.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Add the theme tokens
This component uses --success and --success-foreground, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --success-foreground: #ffffff; } .dark { --success: #4ade80; --success-foreground: #052e16; } @theme inline { --color-success: var(--success); --color-success-foreground: var(--success-foreground); }Copy the source
components/ui/notification-badge/notification-badge.tsx"use client"; import { useState, type ComponentProps, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface NotificationBadgeProps extends Omit<ComponentProps<"span">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** The element the badge sits on, e.g. an icon button. */ children: ReactNode; /** Number to show. The badge hides at 0 unless showZero is set. @default 0 */ count?: number; /** Counts above this show as "99+". @default 99 */ max?: number; /** Show a dot instead of a number. @default false */ dot?: boolean; /** Keep the badge visible at 0. @default false */ showZero?: boolean; /** Add a soft, repeating ring to draw attention. @default false */ pulse?: boolean; /** Badge color. @default "destructive" */ tone?: "destructive" | "primary" | "success"; /** Screen-reader announcement when the count changes. @default (n) => `${n} notifications` */ label?: (count: number) => string; } const tones = { destructive: "bg-destructive text-white", primary: "bg-primary text-primary-foreground", success: "bg-success text-success-foreground", } as const; const ringTones = { destructive: "bg-destructive", primary: "bg-primary", success: "bg-success" } as const; export function NotificationBadge({ children, count = 0, max = 99, dot = false, showZero = false, pulse = false, tone = "destructive", label = (n) => `${n} notification${n === 1 ? "" : "s"}`, className, ...props }: NotificationBadgeProps) { const reduceMotion = useReducedMotion(); const visible = dot || count > 0 || showZero; const text = count > max ? `${max}+` : String(count); // Digits roll up when the count rises and down when it falls. const [previous, setPrevious] = useState(count); const [direction, setDirection] = useState(1); if (previous !== count) { setDirection(count > previous ? 1 : -1); setPrevious(count); } const roll: Variants = reduceMotion ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: (dir: number) => ({ y: dir > 0 ? "90%" : "-90%", opacity: 0 }), animate: { y: "0%", opacity: 1 }, exit: (dir: number) => ({ y: dir > 0 ? "-90%" : "90%", opacity: 0 }), }; return ( <span className={cn("relative inline-flex", className)} {...props}> {children} <AnimatePresence> {visible ? ( <motion.span key="badge" aria-hidden="true" layout={!reduceMotion} // Slides in diagonally from the corner and pops with a spring. initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.3, x: 6, y: -6 }} animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} exit={ reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.3, x: 6, y: -6, transition: { duration: duration.quick, ease: ease.out } } } transition={reduceMotion ? { duration: duration.quick } : { type: "spring", stiffness: 500, damping: 18 }} style={{ borderRadius: 999 }} className={cn( "pointer-events-none absolute flex items-center justify-center ring-2 ring-background", dot ? "-top-0.5 -right-0.5 size-2.5" : "-top-1.5 -right-1.5 h-5 min-w-5 px-1.5 text-[11px] leading-none font-semibold tabular-nums", tones[tone], )} > {pulse ? ( <motion.span className={cn("absolute inset-0 rounded-full", ringTones[tone])} initial={{ opacity: 0.55, scale: 1 }} animate={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 2.4 }} transition={{ duration: 1.4, ease: ease.out, repeat: reduceMotion ? 0 : Infinity, repeatDelay: 0.4 }} /> ) : null} {dot ? null : ( <span className="relative inline-flex h-full items-center overflow-hidden"> <AnimatePresence mode="popLayout" initial={false} custom={direction}> <motion.span key={text} custom={direction} variants={roll} initial="initial" animate="animate" exit="exit" transition={{ duration: duration.quick + 0.04, ease: ease.out }} layout={reduceMotion ? false : "position"} className="inline-block" > {text} </motion.span> </AnimatePresence> </span> )} </motion.span> ) : null} </AnimatePresence> <span role="status" className="sr-only"> {visible && !dot ? label(count) : ""} </span> </span> ); }
Usage
import { NotificationBadge } from "@/components/ui/notification-badge/notification-badge";
export function BellButton({ unread }: { unread: number }) {
return (
<NotificationBadge count={unread}>
<button aria-label={`Notifications, ${unread} unread`}>
<BellIcon />
</button>
</NotificationBadge>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | – | The element the badge sits on, e.g. an icon button. |
count | number | 0 | Number to show. The badge hides at 0 unless showZero is set. |
max | number | 99 | Counts above this show as "99+". |
dot | boolean | false | Show a dot instead of a number. |
showZero | boolean | false | Keep the badge visible at 0. |
pulse | boolean | false | Add a soft, repeating ring to draw attention. |
tone | "destructive" | "primary" | "success" | "destructive" | Badge color. |
label | (count: number) => string | (n) => `${n} notifications` | Screen-reader announcement when the count changes. |
Also accepts every native <span> attribute, including ref and className.
- The badge slides in diagonally from the corner and pops on a low-damping spring; its width morphs as digits are added.
- Counts above max show as "99+".
Accessibility
- The badge itself is aria-hidden; put the count in the wrapped control's accessible name, as in the examples.
- A polite status region announces the new count when it changes (customise the wording with label).
- Status is never conveyed by color alone: numbers are shown as text, and dots should be paired with a label such as "online".
- With reduced motion enabled, the badge fades in and out without sliding, the count cross-fades and the pulse is off.