wui
组件

消息提示 Notification Badge

在图标、头像或任意控件的右上角附着展示未读计数或红点提示。

第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

包裹任意图标按钮或头像,通过数字或圆点呈现待办与未读提醒。点击「收到新消息」时数字向上滚动,「全部已读」时徽标收回;点击收件箱可清除带波纹的圆点:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/notification-badge
安装基础依赖与工具函数
pnpm add motion class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/notification-badge.tsx
components/ui/notification-badge.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"

const EASE_OUT = [0.22, 1, 0.36, 1] as const

/** Visual surface of the indicator. Positioning lives on a separate anchor element. */
const notificationBadgeVariants = cva(
  "relative inline-flex min-w-5 items-center justify-center rounded-full border-2 border-background px-1 text-[11px] font-semibold tabular-nums leading-none",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground",
        destructive: "bg-destructive text-destructive-foreground",
        success: "bg-success text-success-foreground",
        warning: "bg-warning text-warning-foreground",
        info: "bg-info text-info-foreground",
      },
      dot: {
        true: "size-2 min-w-0 border-0 p-0 ring-2 ring-background",
        false: "h-5",
      },
    },
    defaultVariants: {
      variant: "destructive",
      dot: false,
    },
  }
)

export interface NotificationBadgeProps
  extends Omit<React.ComponentProps<"span">, "content"> {
  /** Content displayed in the indicator. Numeric values respect `max` and roll when they change. */
  count?: React.ReactNode
  /** Highest numeric value shown before using a trailing plus sign. @default 99 */
  max?: number
  /** Keep a numeric zero visible. @default false */
  showZero?: boolean
  /** Render a small presence dot instead of count content. @default false */
  dot?: boolean
  /** Emit a soft expanding ring around the dot to draw attention. Only applies with `dot`. @default false */
  pulse?: boolean
  /** Controlled indicator visibility. @default true */
  visible?: boolean
  /** Semantic color of the indicator. @default "destructive" */
  variant?: "default" | "destructive" | "success" | "warning" | "info"
  /** Pixel offset from the top-right anchor. @default [0, 0] */
  offset?: readonly [number, number]
  /** Accessible name for the indicator. */
  label?: string
}

/** One digit column that slides the new value in from the direction of change. */
function RollingDigit({
  digit,
  direction,
  reduceMotion,
}: {
  digit: string
  direction: number
  reduceMotion: boolean | null
}) {
  return (
    <span className="relative inline-flex h-[1em] overflow-hidden">
      <AnimatePresence mode="popLayout" initial={false} custom={direction}>
        <motion.span
          key={digit}
          custom={direction}
          className="inline-block"
          variants={{
            enter: (dir: number) => ({ y: reduceMotion ? 0 : `${dir * 100}%`, opacity: 0 }),
            center: { y: "0%", opacity: 1 },
            exit: (dir: number) => ({ y: reduceMotion ? 0 : `${dir * -100}%`, opacity: 0 }),
          }}
          initial="enter"
          animate="center"
          exit="exit"
          transition={reduceMotion ? { duration: 0 } : { duration: 0.28, ease: EASE_OUT }}
        >
          {digit}
        </motion.span>
      </AnimatePresence>
    </span>
  )
}

/** Places a count or dot indicator over any icon, avatar, or control. */
function NotificationBadge({
  className,
  count,
  max = 99,
  showZero = false,
  dot = false,
  pulse = false,
  visible = true,
  variant = "destructive",
  offset = [0, 0],
  label,
  children,
  ...props
}: NotificationBadgeProps) {
  const reduceMotion = useReducedMotion()
  const isNumeric = typeof count === "number"
  const isEmpty =
    count === null || count === undefined || (isNumeric && count === 0)
  const showIndicator = visible && (dot || !isEmpty || (isNumeric && showZero))
  const overflow = isNumeric && count > max
  const accessibleLabel =
    label ??
    (dot ? "有新通知" : isNumeric ? `${count} 条未读通知` : undefined)

  // Remember the previous number so digits roll up when it grows and down when it shrinks.
  const [previous, setPrevious] = React.useState(isNumeric ? count : 0)
  const [direction, setDirection] = React.useState(1)
  if (isNumeric && count !== previous) {
    setDirection(count > previous ? 1 : -1)
    setPrevious(count)
  }

  let content: React.ReactNode = null
  if (!dot) {
    if (isNumeric && !overflow) {
      // Right-align digits so the ones column keeps its identity (key) as the number grows.
      const digits = String(count).split("")
      content = (
        <span className="inline-flex">
          {digits.map((digit, index) => (
            <RollingDigit
              key={digits.length - index}
              digit={digit}
              direction={direction}
              reduceMotion={reduceMotion}
            />
          ))}
        </span>
      )
    } else {
      content = overflow ? `${max}+` : count
    }
  }

  return (
    <span
      data-slot="notification-badge"
      className={cn("relative inline-flex w-fit align-middle", className)}
      {...props}
    >
      {children}
      <AnimatePresence initial={false}>
        {showIndicator ? (
          <span
            key="indicator"
            data-slot="notification-badge-anchor"
            className="pointer-events-none absolute right-0 top-0 z-10"
            style={{
              transform: `translate(calc(50% + ${offset[0]}px), calc(-50% + ${offset[1]}px))`,
            }}
          >
            <motion.span
              data-slot="notification-badge-indicator"
              data-dot={dot ? "" : undefined}
              role="status"
              aria-label={accessibleLabel}
              className={cn(
                notificationBadgeVariants({ variant, dot }),
                "pointer-events-auto"
              )}
              initial={reduceMotion ? { opacity: 0 } : { scale: 0, opacity: 0 }}
              animate={{ scale: 1, opacity: 1 }}
              exit={reduceMotion ? { opacity: 0 } : { scale: 0, opacity: 0 }}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { type: "spring", stiffness: 560, damping: 26, mass: 0.6 }
              }
            >
              {dot && pulse && !reduceMotion ? (
                <motion.span
                  aria-hidden="true"
                  className="absolute inset-0 rounded-full bg-inherit"
                  initial={{ scale: 1, opacity: 0.6 }}
                  animate={{ scale: 2.6, opacity: 0 }}
                  transition={{ duration: 1.6, ease: "easeOut", repeat: Infinity }}
                />
              ) : null}
              {content}
            </motion.span>
          </span>
        ) : null}
      </AnimatePresence>
    </span>
  )
}

export { NotificationBadge, notificationBadgeVariants }

属性 Props

NotificationBadge 支持以下配置属性,并可包裹任意子元素进行定位:

属性类型默认值说明
countReact.ReactNode—徽标展示的内容。传入数字时会自动受 max 限制,数值变化时各位数字按增减方向上下滚动;也可以传入文本或自定义节点。
maxnumber99数字上限阈值。超过该数值时将显示为 `${max}+`(例如 99+)。
showZerobooleanfalse当 count 为数字 0 时是否强制展示徽标。默认自动隐藏。
dotbooleanfalse是否以极简微圆点形态展示,不显示任何具体数字文本。
pulsebooleanfalse仅在 dot 模式下生效。圆点周围持续扩散柔和的波纹,用于提示需要尽快关注的状态(如在线、待处理)。开启减弱动态效果时不播放。
visiblebooleantrue受控显隐状态。切换时徽标以弹簧缩放弹出或收回;为 false 时退场结束后移除徽标节点。
variant"default" | "destructive" | "success" | "warning" | "info""destructive"徽标的语义色彩主题。
offsetreadonly [number, number][0, 0]相对于右上角基准定位点的像素偏移量 [x, y],支持正负微调。
labelstring—为读屏器提供的显式可访问描述文本(如 '3 条未读工单')。
classNamestring—应用于外层包裹容器的额外 CSS 类名。

事件 Events

NotificationBadge 容器本身为行内语义容器,透传所有原生 DOM 事件,同时不会阻碍内部子控件(如 Button 或链接)的正常点击与快捷键交互:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLSpanElement>) => void—在徽标外层容器上点击时触发的原生 DOM 事件。

使用场景与设计规范

NotificationBadge 专用于附着在既有控件角落的轻量级角标状态提醒。

  • NotificationBadge vs 独立 Badge:
    • NotificationBadge:附着在图标/头像右上角(绝对定位),用于未读数量、在线指示。
    • Badge:独立流式排版的标签或徽章(如“PRO”、“已发布”)。
  • 红点(Dot)vs 数字(Count):
    • 红点:适用于弱提醒或用户无需知道精确数量的场景(如“有新版本发布”、“有新系统公告”)。
    • 数字:适用于待办、未读私信、待审批等用户需要明确感知工作量的场景。
  • 合理设置最大上限(max):
    • 导航栏或移动端等小尺寸区域建议设置 max={9} 或 max={99},防止多位数字破坏界面视觉平衡。
  • 白边遮罩(Outline Ring):组件内置与背景同色的 border-background,确保徽标在重叠在复杂图形或深色头像边缘时具有清晰的轮廓边界与视觉呼吸感。

场景示例

语义色彩主题

提供 destructive(紧急/消息)、default(主色)、info(信息)、warning(预警)与 success(成功/在线)五种语义主题:

Loading…

数字上限与零值控制

通过 max 属性控制数字截断格式,使用 showZero 控制是否在计数归零时保持可见:

Loading…

像素偏移定位微调

针对圆形头像、矩形按钮或文字链接,通过 offset={[x, y]} 进行精准的坐标补偿:

Loading…

无障碍与交互 Accessibility

  • 状态通报角色:徽标节点内置 role="status" 与 tabular-nums,确保视障辅助设备可以正确捕获未读数量变动。
  • 自动无障碍文案生成:当未显式传入 label 时,组件会根据传入的 count 自动合成 aria-label="X 条未读通知";在 dot 模式下自动合成 aria-label="有新通知"。
  • 动效降级:开启系统「减弱动态效果」后,数字滚动、弹出缩放与圆点波纹全部停用,仅保留透明度切换。
  • 子元素可访问性独立性:被包裹的图标按钮仍需保持自身具备合法的 aria-label(如 aria-label="查看消息通知"),徽标不会破坏底层按钮的可访问名称。