wui
组件

全局提示 Message

用于在用户操作后提供轻量、非阻塞的全局反馈通知,支持卡片堆叠队列、手势拖拽移除与流畅物理微动效。

第三方依赖 · radix-ui第三方依赖 · lucide-react第三方依赖 · @animateicons/react第三方依赖 · motion

基础用法

通过 useMessage() 钩子可在任意组件内即时触发不同语义状态的浮层提示:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/message
安装依赖库与动效库
pnpm add radix-ui motion @animateicons/react lucide-react clsx tailwind-merge
复制组件源码到 components/ui/message.tsx
components/ui/message.tsx
"use client"

import * as React from "react"
import { Portal } from "radix-ui"
import { XIcon as CloseIcon, LoaderCircleIcon } from "lucide-react"
import {
  CircleCheckIcon,
  InfoIcon,
  TriangleAlertIcon,
  XIcon,
} from "@animateicons/react/lucide"
import {
  AnimatePresence,
  motion,
  useReducedMotion,
  type Transition,
} from "motion/react"

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

export type MessageVariant =
  "default" | "info" | "success" | "warning" | "destructive" | "loading"

export type MessagePosition =
  "top-left" | "top" | "top-right" | "bottom-left" | "bottom" | "bottom-right"

export interface MessageMotionConfig {
  /** Distance travelled while entering and leaving, in pixels. @default 16 */
  offset: number
  /** Scale at the beginning of the enter animation. @default 0.96 */
  scale: number
  /** Opacity at the beginning of the enter animation. @default 0 */
  initialOpacity: number
  /** Opacity at the end of the exit animation. @default 0 */
  exitOpacity: number
  /** Initial backdrop blur, in pixels. @default 6 */
  blur: number
  /** Enter and layout spring stiffness. @default 420 */
  stiffness: number
  /** Enter and layout spring damping. @default 34 */
  damping: number
  /** Enter and layout spring mass. @default 0.72 */
  mass: number
  /** Exit animation duration, in seconds. @default 0.16 */
  exitDuration: number
}

const defaultMotion: MessageMotionConfig = {
  offset: 16,
  scale: 0.96,
  initialOpacity: 0,
  exitOpacity: 0,
  blur: 6,
  stiffness: 420,
  damping: 34,
  mass: 0.72,
  exitDuration: 0.16,
}

export interface MessageOptions {
  /** Optional title displayed above the message content. */
  title?: React.ReactNode
  /** Main message content. */
  description: React.ReactNode
  /** Semantic appearance and animated default icon. @default "default" */
  variant?: MessageVariant
  /** Screen position. Uses the provider default when omitted. */
  position?: MessagePosition
  /** Time before dismissal in milliseconds. Set to 0 to persist. */
  duration?: number
  /** Custom leading icon. Pass `false` to hide it. */
  icon?: React.ReactNode | false
  /** Show a close control. @default false */
  closable?: boolean
  /** Override drag-to-dismiss behavior for this message. */
  dragToDismiss?: boolean
  /** Final opacity of the complete message surface. @default 1 */
  opacity?: number
  /** Per-message motion overrides. */
  motion?: Partial<MessageMotionConfig>
  /** Additional class name applied to the floating surface. */
  className?: string
  /** Called after the message has been dismissed. */
  onClose?: () => void
}

export interface MessageProviderProps {
  children: React.ReactNode
  /** Default screen position. @default "top" */
  position?: MessagePosition
  /** Default time before dismissal in milliseconds. @default 3000 */
  duration?: number
  /** Maximum number of visible messages. Older messages are dismissed first. @default 5 */
  maxCount?: number
  /** Distance between the viewport and the window edge, in pixels. @default 20 */
  offset?: number
  /** Space between stacked messages, in pixels. @default 10 */
  gap?: number
  /** Stack messages as overlapping layers instead of a vertical list. @default false */
  stacked?: boolean
  /** Maximum number of visible layers in a stacked queue. @default 3 */
  maxVisibleMessages?: number
  /** Scale reduction applied to each successive stacked layer. @default 0.05 */
  scaleFactor?: number
  /** Expand stacked messages into a list on hover or keyboard focus. @default true */
  expandOnHover?: boolean
  /** Allow messages to be dismissed by dragging horizontally. @default true */
  dragToDismiss?: boolean
  /** Pause auto-dismiss timers while the pointer rests on a message group. @default true */
  pauseOnHover?: boolean
  /** Horizontal drag distance required to dismiss, in pixels. @default 72 */
  dragThreshold?: number
  /** Motion defaults shared by all messages. */
  motion?: Partial<MessageMotionConfig>
}

type MessageRecord = MessageOptions & {
  id: string
  position: MessagePosition
}

export interface MessageApi {
  open: (options: MessageOptions) => string
  info: (
    description: React.ReactNode,
    options?: Omit<MessageOptions, "description" | "variant">
  ) => string
  success: (
    description: React.ReactNode,
    options?: Omit<MessageOptions, "description" | "variant">
  ) => string
  warning: (
    description: React.ReactNode,
    options?: Omit<MessageOptions, "description" | "variant">
  ) => string
  error: (
    description: React.ReactNode,
    options?: Omit<MessageOptions, "description" | "variant">
  ) => string
  /** Show a persistent message with a spinner; pair it with `update` to resolve it in place. */
  loading: (
    description: React.ReactNode,
    options?: Omit<MessageOptions, "description" | "variant">
  ) => string
  /**
   * Morph an existing message in place (e.g. loading → success). Passing
   * `duration` restarts its auto-dismiss timer; turning a loading message into
   * another variant without a duration uses the provider default.
   */
  update: (id: string, options: Partial<MessageOptions>) => void
  dismiss: (id: string) => void
  clear: () => void
}

type AnimatedIconHandle = {
  startAnimation: () => void
  stopAnimation: () => void
}

function MessageStatusIcon({ variant }: { variant: MessageVariant }) {
  const ref = React.useRef<AnimatedIconHandle>(null)

  React.useEffect(() => {
    const frame = requestAnimationFrame(() => ref.current?.startAnimation())
    return () => cancelAnimationFrame(frame)
  }, [])

  const props = { ref, size: 20, color: "currentColor" }

  if (variant === "success") return <CircleCheckIcon {...props} />
  if (variant === "warning") return <TriangleAlertIcon {...props} />
  if (variant === "destructive") return <XIcon {...props} />
  if (variant === "loading")
    return <LoaderCircleIcon className="size-[18px] animate-spin" />
  return <InfoIcon {...props} />
}

const MessageContext = React.createContext<MessageApi | null>(null)

const messagePositionClasses: Record<MessagePosition, string> = {
  "top-left": "left-0 top-0 items-start",
  top: "left-1/2 top-0 -translate-x-1/2 items-center",
  "top-right": "right-0 top-0 items-end",
  "bottom-left": "bottom-0 left-0 items-start",
  bottom: "bottom-0 left-1/2 -translate-x-1/2 items-center",
  "bottom-right": "bottom-0 right-0 items-end",
}

const messagePositions = Object.keys(
  messagePositionClasses
) as MessagePosition[]

function getViewportStyle(
  position: MessagePosition,
  offset: number,
  gap: number
) {
  return {
    gap,
    ...(position.startsWith("top") ? { top: offset } : { bottom: offset }),
    ...(position.endsWith("left") ? { left: offset } : {}),
    ...(position.endsWith("right") ? { right: offset } : {}),
  }
}

function getMotionOffset(position: MessagePosition, offset: number) {
  if (position === "top-left") return { x: -offset, y: -offset * 0.5 }
  if (position === "top-right") return { x: offset, y: -offset * 0.5 }
  if (position === "bottom-left") return { x: -offset, y: offset * 0.5 }
  if (position === "bottom-right") return { x: offset, y: offset * 0.5 }
  return { y: position === "top" ? -offset : offset }
}

interface MessageItemProps {
  message: MessageRecord
  providerMotion: Partial<MessageMotionConfig>
  dragToDismiss: boolean
  dragThreshold: number
  frontHeight: number
  expanded: boolean
  expandedOffset: number
  hidden: boolean
  stackIndex: number
  stacked: boolean
  stackGap: number
  scaleFactor: number
  onHeightChange: (id: string, height: number) => void
  onHeightRemove: (id: string) => void
  onDismiss: (id: string) => void
}

function MessageItem({
  message,
  providerMotion,
  dragToDismiss,
  dragThreshold,
  frontHeight,
  expanded,
  expandedOffset,
  hidden,
  stackIndex,
  stacked,
  stackGap,
  scaleFactor,
  onHeightChange,
  onHeightRemove,
  onDismiss,
}: MessageItemProps) {
  const itemRef = React.useRef<HTMLDivElement>(null)
  const reduceMotion = useReducedMotion()
  // Set when the message is flung away so it leaves in the drag direction.
  const [swipeDirection, setSwipeDirection] = React.useState(0)
  const config = {
    ...defaultMotion,
    ...providerMotion,
    ...message.motion,
  }
  const offset = getMotionOffset(message.position, config.offset)
  const opacity = message.opacity ?? 1
  const isFrontmost = !stacked || stackIndex === 0
  const isInteractive = !hidden && (!stacked || expanded || isFrontmost)
  const canDrag = isInteractive && (message.dragToDismiss ?? dragToDismiss)
  const stackDirection = message.position.startsWith("bottom") ? -1 : 1
  const stackY = stacked
    ? expanded
      ? expandedOffset
      : stackDirection * stackIndex * stackGap
    : 0
  const stackScale =
    stacked && !expanded ? Math.max(0.7, 1 - stackIndex * scaleFactor) : 1
  const enterTransition: Transition = {
    type: "spring",
    stiffness: config.stiffness,
    damping: config.damping,
    mass: config.mass,
  }

  React.useLayoutEffect(() => {
    const element = itemRef.current
    if (!element) return

    const updateHeight = () => onHeightChange(message.id, element.offsetHeight)
    updateHeight()

    const observer = new ResizeObserver(updateHeight)
    observer.observe(element)

    return () => {
      observer.disconnect()
      onHeightRemove(message.id)
    }
  }, [message.id, onHeightChange, onHeightRemove])

  return (
    <motion.div
      ref={itemRef}
      layout="position"
      data-slot="message"
      data-variant={message.variant ?? "default"}
      data-frontmost={isFrontmost || undefined}
      data-hidden={hidden || undefined}
      data-index={stackIndex}
      aria-hidden={hidden || undefined}
      role={
        message.variant === "warning" || message.variant === "destructive"
          ? "alert"
          : "status"
      }
      className={cn(
        "text-popover-foreground pointer-events-auto flex min-h-12 max-w-full items-start gap-3 rounded-md border px-4 py-3 text-sm shadow-md",
        (message.variant === undefined ||
          message.variant === "default" ||
          message.variant === "loading") &&
          "border-border bg-popover",
        message.variant === "info" && "border-info-border bg-info-subtle",
        message.variant === "success" &&
          "border-success-border bg-success-subtle",
        message.variant === "warning" &&
          "border-warning-border bg-warning-subtle",
        message.variant === "destructive" &&
          "border-destructive-border bg-destructive-subtle",
        stacked && "absolute inset-x-0",
        stacked && message.position.startsWith("top") && "top-0",
        stacked && message.position.startsWith("bottom") && "bottom-0",
        stacked &&
          !expanded &&
          !isFrontmost &&
          "pointer-events-none overflow-hidden",
        hidden && "pointer-events-none",
        canDrag && "cursor-grab active:cursor-grabbing",
        message.className
      )}
      style={{
        zIndex: stacked ? 100 - stackIndex : undefined,
        height: stacked && !expanded && !isFrontmost ? frontHeight : undefined,
      }}
      drag={canDrag ? "x" : false}
      dragConstraints={{ left: 0, right: 0 }}
      dragElastic={0.6}
      whileDrag={reduceMotion ? undefined : { scale: 0.985 }}
      onDragEnd={(_, info) => {
        if (
          Math.abs(info.offset.x) >= dragThreshold ||
          Math.abs(info.velocity.x) > 800
        ) {
          setSwipeDirection(Math.sign(info.offset.x) || 1)
          onDismiss(message.id)
        }
      }}
      initial={
        reduceMotion
          ? { opacity }
          : {
              opacity: config.initialOpacity,
              scale: config.scale,
              filter: `blur(${config.blur}px)`,
              ...offset,
            }
      }
      animate={{
        opacity: hidden ? 0 : opacity,
        scale: stackScale,
        filter: "blur(0px)",
        x: 0,
        y: stackY,
      }}
      exit={
        reduceMotion
          ? { opacity: 0 }
          : swipeDirection
            ? {
                x: swipeDirection * 420,
                opacity: 0,
                transition: { duration: 0.22, ease: [0.4, 0, 1, 1] },
              }
            : {
                opacity: config.exitOpacity,
                scale: Math.min(0.985, (config.scale + 1) / 2),
                filter: `blur(${config.blur * 0.5}px)`,
                transition: {
                  duration: config.exitDuration,
                  ease: [0.4, 0, 1, 1],
                },
                ...offset,
              }
      }
      transition={
        reduceMotion
          ? { duration: 0 }
          : {
              ...enterTransition,
              layout: enterTransition,
              opacity: { duration: 0.2, ease: "easeOut" },
              filter: { duration: 0.2, ease: "easeOut" },
            }
      }
    >
      {message.icon !== false ? (
        <span
          data-slot="message-icon"
          className={cn(
            "text-muted-foreground flex size-5 shrink-0 items-center justify-center [&>div]:flex [&>div]:items-center",
            message.variant === "info" && "text-info",
            message.variant === "success" && "text-success",
            message.variant === "warning" && "text-warning",
            message.variant === "destructive" && "text-destructive"
          )}
        >
          {message.icon ?? (
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={message.variant ?? "default"}
                className="flex items-center justify-center"
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
                transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
              >
                <MessageStatusIcon variant={message.variant ?? "default"} />
              </motion.span>
            </AnimatePresence>
          )}
        </span>
      ) : null}

      <div data-slot="message-content" className="min-w-0 flex-1 leading-5">
        {message.title ? (
          <div
            data-slot="message-title"
            className={cn(
              "font-semibold",
              message.variant === "info" && "text-info",
              message.variant === "success" && "text-success",
              message.variant === "warning" && "text-warning",
              message.variant === "destructive" && "text-destructive"
            )}
          >
            {message.title}
          </div>
        ) : null}
        <div className={cn(message.title && "text-muted-foreground mt-0.5 text-xs")}>
          {message.description}
        </div>
      </div>

      {message.closable ? (
        <button
          type="button"
          data-slot="message-close"
          aria-label="关闭消息"
          tabIndex={isInteractive ? undefined : -1}
          className={cn(
            "text-muted-foreground hover:bg-background hover:text-foreground focus-visible:ring-ring/30 -mr-1 flex size-6 shrink-0 items-center justify-center rounded-sm outline-none transition-colors focus-visible:ring-[3px] [&_svg]:size-3.5",
            message.variant === "info" && "text-info hover:text-info",
            message.variant === "success" && "text-success hover:text-success",
            message.variant === "warning" && "text-warning hover:text-warning",
            message.variant === "destructive" &&
              "text-destructive hover:text-destructive"
          )}
          onClick={() => onDismiss(message.id)}
        >
          <CloseIcon />
        </button>
      ) : null}
    </motion.div>
  )
}

/** Provides portal-based transient messages and the imperative `useMessage` API. */
function MessageProvider({
  children,
  position: defaultPosition = "top",
  duration: defaultDuration = 3000,
  maxCount = 5,
  offset = 20,
  gap = 10,
  stacked = false,
  maxVisibleMessages = 3,
  scaleFactor = 0.05,
  expandOnHover = true,
  dragToDismiss = true,
  dragThreshold = 72,
  pauseOnHover = true,
  motion: providerMotion = {},
}: MessageProviderProps) {
  const reduceMotion = useReducedMotion()
  const [messages, setMessages] = React.useState<MessageRecord[]>([])
  const [messageHeights, setMessageHeights] = React.useState<
    Record<string, number>
  >({})
  const [expandedPositions, setExpandedPositions] = React.useState<
    Set<MessagePosition>
  >(() => new Set())
  const sequence = React.useRef(0)
  // The ref mirrors `messages` synchronously so callbacks (onClose, timers)
  // run outside state updaters and see the latest queue.
  const messagesRef = React.useRef<MessageRecord[]>([])
  const timers = React.useRef(
    new Map<
      string,
      { handle?: ReturnType<typeof setTimeout>; remaining: number; startedAt: number }
    >()
  )
  const pausedPositions = React.useRef(new Set<MessagePosition>())

  const commit = React.useCallback((next: MessageRecord[]) => {
    messagesRef.current = next
    setMessages(next)
  }, [])

  const clearTimer = React.useCallback((id: string) => {
    const timer = timers.current.get(id)
    if (timer?.handle) clearTimeout(timer.handle)
    timers.current.delete(id)
  }, [])

  const handleHeightChange = React.useCallback((id: string, height: number) => {
    setMessageHeights((current) =>
      current[id] === height ? current : { ...current, [id]: height }
    )
  }, [])

  const handleHeightRemove = React.useCallback((id: string) => {
    setMessageHeights((current) => {
      if (!(id in current)) return current
      const next = { ...current }
      delete next[id]
      return next
    })
  }, [])

  const dismiss = React.useCallback(
    (id: string) => {
      clearTimer(id)
      const target = messagesRef.current.find((message) => message.id === id)
      if (!target) return
      commit(messagesRef.current.filter((message) => message.id !== id))
      target.onClose?.()
    },
    [clearTimer, commit]
  )

  const startTimer = React.useCallback(
    (id: string, position: MessagePosition, duration: number) => {
      clearTimer(id)
      if (duration <= 0) return
      const paused = pauseOnHover && pausedPositions.current.has(position)
      timers.current.set(id, {
        remaining: duration,
        startedAt: Date.now(),
        handle: paused ? undefined : setTimeout(() => dismiss(id), duration),
      })
    },
    [clearTimer, dismiss, pauseOnHover]
  )

  const setPositionPaused = React.useCallback(
    (position: MessagePosition, paused: boolean) => {
      if (!pauseOnHover) return
      if (paused === pausedPositions.current.has(position)) return
      if (paused) pausedPositions.current.add(position)
      else pausedPositions.current.delete(position)

      const now = Date.now()
      messagesRef.current
        .filter((message) => message.position === position)
        .forEach((message) => {
          const timer = timers.current.get(message.id)
          if (!timer) return
          if (paused && timer.handle) {
            clearTimeout(timer.handle)
            timer.handle = undefined
            timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt))
          } else if (!paused && !timer.handle) {
            timer.startedAt = now
            timer.handle = setTimeout(() => dismiss(message.id), timer.remaining)
          }
        })
    },
    [dismiss, pauseOnHover]
  )

  const open = React.useCallback(
    (options: MessageOptions) => {
      const id = `message-${sequence.current++}`
      const position = options.position ?? defaultPosition
      const messageDuration =
        options.duration ??
        (options.variant === "loading" ? 0 : defaultDuration)

      const next = [...messagesRef.current, { ...options, id, position }]
      const overflow = Math.max(0, next.length - maxCount)
      const evicted = next.slice(0, overflow)
      evicted.forEach((message) => clearTimer(message.id))
      commit(overflow ? next.slice(overflow) : next)
      evicted.forEach((message) => message.onClose?.())

      startTimer(id, position, messageDuration)
      return id
    },
    [clearTimer, commit, defaultDuration, defaultPosition, maxCount, startTimer]
  )

  const update = React.useCallback(
    (id: string, options: Partial<MessageOptions>) => {
      const target = messagesRef.current.find((message) => message.id === id)
      if (!target) return
      const { position: _ignored, ...rest } = options
      const updated: MessageRecord = { ...target, ...rest }
      commit(
        messagesRef.current.map((message) =>
          message.id === id ? updated : message
        )
      )
      const leavesLoading =
        target.variant === "loading" && updated.variant !== "loading"
      if (options.duration !== undefined || leavesLoading) {
        startTimer(
          id,
          target.position,
          options.duration ??
            (updated.variant === "loading" ? 0 : defaultDuration)
        )
      }
    },
    [commit, defaultDuration, startTimer]
  )

  const clear = React.useCallback(() => {
    const current = messagesRef.current
    current.forEach((message) => clearTimer(message.id))
    commit([])
    current.forEach((message) => message.onClose?.())
  }, [clearTimer, commit])

  React.useEffect(
    () => () => {
      timers.current.forEach((timer) => {
        if (timer.handle) clearTimeout(timer.handle)
      })
      timers.current.clear()
    },
    []
  )

  const api = React.useMemo<MessageApi>(
    () => ({
      open,
      info: (description, options) =>
        open({ ...options, description, variant: "info" }),
      success: (description, options) =>
        open({ ...options, description, variant: "success" }),
      warning: (description, options) =>
        open({ ...options, description, variant: "warning" }),
      error: (description, options) =>
        open({ ...options, description, variant: "destructive" }),
      loading: (description, options) =>
        open({ ...options, description, variant: "loading" }),
      update,
      dismiss,
      clear,
    }),
    [clear, dismiss, open, update]
  )

  return (
    <MessageContext.Provider value={api}>
      {children}
      <Portal.Root>
        {messagePositions.map((position) => {
          const positionedMessages = messages.filter(
            (message) => message.position === position
          )
          const renderedMessages = stacked
            ? [...positionedMessages].reverse()
            : positionedMessages
          const frontMessage = renderedMessages[0]
          const frontHeight = frontMessage
            ? (messageHeights[frontMessage.id] ?? 48)
            : 0
          const visibleLayerCount = Math.min(
            renderedMessages.length,
            Math.max(1, maxVisibleMessages)
          )
          const isExpanded =
            stacked && expandOnHover && expandedPositions.has(position)
          const visibleMessages = renderedMessages.slice(0, visibleLayerCount)
          const expandedHeight = visibleMessages.reduce(
            (total, message) =>
              total + (messageHeights[message.id] ?? frontHeight),
            Math.max(0, visibleLayerCount - 1) * gap
          )

          function setPositionExpanded(expanded: boolean) {
            if (!stacked || !expandOnHover) return
            setExpandedPositions((current) => {
              const next = new Set(current)
              if (expanded) next.add(position)
              else next.delete(position)
              return next
            })
          }

          return (
            <div
              key={position}
              data-slot="message-viewport"
              data-position={position}
              className={cn(
                "pointer-events-none fixed z-[100] flex w-[min(24rem,calc(100vw-2rem))] flex-col",
                stacked && "pointer-events-auto",
                !stacked && position.startsWith("bottom") && "flex-col-reverse",
                messagePositionClasses[position]
              )}
              style={{
                ...getViewportStyle(position, offset, stacked ? 0 : gap),
                height: stacked
                  ? isExpanded
                    ? expandedHeight
                    : frontHeight + Math.max(0, visibleLayerCount - 1) * gap
                  : undefined,
                transition: reduceMotion
                  ? "none"
                  : "height 240ms cubic-bezier(0.22, 1, 0.36, 1)",
              }}
              onPointerEnter={() => {
                setPositionExpanded(true)
                setPositionPaused(position, true)
              }}
              onPointerLeave={() => {
                setPositionExpanded(false)
                setPositionPaused(position, false)
              }}
              onFocusCapture={() => setPositionExpanded(true)}
              onBlurCapture={(event) => {
                if (!event.currentTarget.contains(event.relatedTarget)) {
                  setPositionExpanded(false)
                }
              }}
            >
              <AnimatePresence initial={false}>
                {renderedMessages.map((message, index) => {
                  const precedingHeight = renderedMessages
                    .slice(0, index)
                    .reduce(
                      (total, item) =>
                        total + (messageHeights[item.id] ?? frontHeight),
                      0
                    )
                  const direction = position.startsWith("bottom") ? -1 : 1
                  const expandedOffset =
                    direction * (precedingHeight + index * gap)

                  return (
                    <MessageItem
                      key={message.id}
                      message={message}
                      providerMotion={providerMotion}
                      dragToDismiss={dragToDismiss}
                      dragThreshold={dragThreshold}
                      frontHeight={frontHeight}
                      expanded={isExpanded}
                      expandedOffset={expandedOffset}
                      hidden={stacked && index >= maxVisibleMessages}
                      stackIndex={index}
                      stacked={stacked}
                      stackGap={gap}
                      scaleFactor={scaleFactor}
                      onHeightChange={handleHeightChange}
                      onHeightRemove={handleHeightRemove}
                      onDismiss={dismiss}
                    />
                  )
                })}
              </AnimatePresence>
            </div>
          )
        })}
      </Portal.Root>
    </MessageContext.Provider>
  )
}

/** Returns the global message API from the nearest `MessageProvider`. */
function useMessage() {
  const context = React.useContext(MessageContext)
  if (!context) {
    throw new Error("useMessage must be used inside <MessageProvider>.")
  }
  return context
}

export { MessageProvider, useMessage }

属性 Props

MessageProvider

全局 Provider 容器,负责管理所有浮动视口通道与堆叠消息队列:

属性类型默认值说明
position"top-left" | "top" | "top-right" | "bottom-left" | "bottom" | "bottom-right""top"消息在屏幕视口中的默认弹出停靠位置。
durationnumber3000默认自动关闭的停留时间(毫秒)。设为 0 则不自动关闭。
maxCountnumber5同时显示的最大消息总数,超出时最旧的消息将被自动移除。
offsetnumber20消息视口距离屏幕边缘的物理像素距离。
gapnumber10消息之间的垂直间距或堆叠层级露出高度(像素)。
stackedbooleantrue是否开启卡片多层重叠堆叠模式(若为 false 则始终为普通纵向排列列表)。
maxVisibleMessagesnumber3堆叠模式下最多露出的卡片层数。
scaleFactornumber0.05堆叠队列中后置卡片按层级依次缩小的比例系数。
expandOnHoverbooleantrue当鼠标悬停或键盘聚焦到消息堆时,是否自动展开为完整列表。
dragToDismissbooleantrue是否允许通过鼠标或触控向左/向右滑动手势直接移除顶层消息。
dragThresholdnumber72触发手势滑动关闭所需的水平位移阈值(像素)。快速甩动时即使未达到阈值也会移除,消息会沿甩动方向飞出。
pauseOnHoverbooleantrue指针悬停在某一方位的消息上时,暂停该方位所有消息的自动关闭倒计时,移开后按剩余时间继续。
motionPartial<MessageMotionConfig>—全局弹簧动效与滤镜过渡参数配置对象。

MessageOptions (useMessage 触发参数)

调用 open、info、success、warning、error、loading 时传入的单条消息配置:

属性类型默认值说明
descriptionReact.ReactNode—消息的核心文本或自定义结构内容。
variant"default" | "info" | "success" | "warning" | "destructive" | "loading""default"消息的色彩语义变体,同时决定动态图标样式。"loading" 显示旋转指示器,且默认不自动关闭。
positionMessagePosition—单条消息弹出位置(覆盖 Provider 预设值)。
durationnumber—单条消息自动关闭毫秒数。设为 0 可使其一直保持显示。
iconReact.ReactNode | false—自定义前导图标。传入 `false` 可完全隐藏左侧图标。
closablebooleanfalse是否在右侧渲染显式的关闭叉号按钮。
dragToDismissboolean—单独覆盖当前消息的手势拖拽关闭开关。
opacitynumber1消息外层卡片的最终不透明度(0 ~ 1)。
motionPartial<MessageMotionConfig>—单独覆盖当前消息的动画进入/退出物理参数。
classNamestring—应用于消息浮层卡片的额外 CSS 类名。
onClose() => void—消息被关闭(超时、手动移除或被挤出队列)后触发的回调函数。

MessageApi (useMessage 返回值)

属性类型默认值说明
open(options: MessageOptions) => string—打开一条消息并返回其 id。
info / success / warning / error(description, options?) => string—按语义变体快速打开消息,error 对应 destructive 变体。
loading(description, options?) => string—打开一条带旋转指示器的加载消息,默认 duration 为 0(不自动关闭),通常与 update 配合使用。
update(id: string, options: Partial<MessageOptions>) => void—原地更新已有消息(位置不可变)。图标会交叉缩放切换,高度变化平滑过渡。传入 duration 会重新计时;从 loading 切换到其他变体且未指定 duration 时,按 Provider 默认时长自动关闭。
dismiss(id: string) => void—关闭指定消息。
clear() => void—关闭全部消息。

事件 Events

属性类型默认值说明
onClose() => void—单条消息因超时自动倒计时结束、点击叉号按钮、手势滑动移除或超出 maxCount 队列时触发。

使用场景与设计规范

Message 用于对用户的轻量级操作提供即时非阻塞反馈(如“复制成功”、“草稿已保存”、“正在上传”等)。

  • 非阻塞原则:Message 通过 Portal 挂载在最顶层,不应抢占用户的交互焦点,也不应强制用户点击确认才能继续操作。
  • 简短精炼:提示文字应尽量控制在 20 个字符以内(1~2 秒即可阅读完)。若需展示多行报错信息、排查说明或复杂表单错误,请使用 Alert 或 Dialog。
  • 避免多条轰炸:若同一操作触发了多次异步回调,优先用 loading + update 合并为单条消息原地更新,或依赖组件内置的 stacked 堆叠与 maxCount 机制避免屏幕被弹窗淹没。

场景示例

弹出方位 (Positions)

支持 6 种视口停靠方位(top-left、top、top-right、bottom-left、bottom、bottom-right):

Loading…

停留时长与异步加载 (Duration)

可通过 duration 设置消息停留时间,duration: 0 表示不自动关闭;悬停在消息上会暂停计时。异步任务使用 message.loading 打开加载消息,完成后以 message.update 原地切换为结果,图标与文案平滑过渡而不是弹出第二条消息:

Loading…

卡片堆叠与手势移除 (Stacked & Drag)

多条提示默认智能分层重叠,鼠标悬浮时丝滑展开,支持左右滑动手势快速移除:

Loading…

自定义动效与透明度 (Motion & Opacity)

可微调物理弹簧刚度(stiffness)、阻尼比(damping)与进出场位移:

Loading…

业务场景:复制密钥、上传同步与撤销操作

在真实 SaaS 产品中,Message 可用于敏感密钥复制确认、后台资源同步以及支持即时“撤销”的数据归档:

Loading…

无障碍与交互 Accessibility

  • 动态实时区域 (Live Region):
    • warning 与 destructive 消息自动分配 role="alert",协助读屏器及时播报错误。
    • 普通信息与成功提示使用 role="status",确保在不打断用户当前焦点操作的前提下进行提示。
  • 焦点与键盘交互:当展开为列表并包含 closable 按钮或交互链接时,支持通过键盘进入并激活操作。
  • 减弱动态效果 (Reduced Motion):组件会自动监听操作系统的减弱动态效果偏好(prefers-reduced-motion),开启时将禁用位移、缩放与毛玻璃模糊动效,以纯淡入淡出形式呈现。