wui
组件

手绘批注与高亮 Annotation

带有手绘笔触风格的动态荧光笔扫掠高亮、手绘箭头引导与路径圈注组件。

第三方依赖 · motion

基础用法

进入视口时,关键词被荧光笔动态扫掠高亮,关键指标被手绘椭圆路径圈出,箭头指向核心 CTA 按钮:

Loading…

安装与引入

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

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

import * as React from "react"
import {
  motion,
  useReducedMotion,
  type HTMLMotionProps,
  type SVGMotionProps,
} from "motion/react"

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

const DEFAULT_PATH = "M5 70C35 70 30 17 72 22C108 26 112 61 154 10"

const visiblePath = { opacity: 1, pathLength: 1 }
const hiddenPath = { opacity: 0, pathLength: 0 }

export interface AnnotationPathProps extends Omit<
  SVGMotionProps<SVGSVGElement>,
  "color"
> {
  /** SVG path data to draw. */
  path?: string
  /** Stroke color. @default "currentColor" */
  color?: string
  /** Stroke width in viewBox units. @default 3 */
  strokeWidth?: number
  /** Add an arrowhead after the path finishes drawing. @default true */
  arrow?: boolean
  /** Seconds used to draw the path. @default 0.8 */
  duration?: number
  /** Delay before drawing, in seconds. @default 0 */
  delay?: number
  /** Start drawing when the path enters the viewport. @default true */
  inView?: boolean
  /** Only draw the first time the path enters the viewport. @default true */
  once?: boolean
  /** Additional props forwarded to the animated path element. */
  pathProps?: Omit<SVGMotionProps<SVGPathElement>, "d">
}

/** Draws a curved arrow or any custom SVG path to guide attention. */
function AnnotationPath({
  path = DEFAULT_PATH,
  viewBox = "0 0 160 80",
  color = "currentColor",
  strokeWidth = 3,
  arrow = true,
  duration = 0.8,
  delay = 0,
  inView = true,
  once = true,
  className,
  pathProps,
  ...props
}: AnnotationPathProps) {
  const reduceMotion = useReducedMotion()
  const markerId = `annotation-arrow-${React.useId().replace(/[^\w-]/g, "")}`
  const transition = {
    duration: reduceMotion ? 0 : duration,
    delay: reduceMotion ? 0 : delay,
    ease: [0.22, 1, 0.36, 1] as const,
  }
  const arrowTransition = {
    duration: reduceMotion ? 0 : Math.min(0.2, duration * 0.25),
    delay: reduceMotion ? 0 : delay + duration * 0.82,
    ease: "easeOut" as const,
  }

  return (
    <motion.svg
      aria-hidden="true"
      data-slot="annotation-path"
      viewBox={viewBox}
      fill="none"
      className={cn("pointer-events-none overflow-visible", className)}
      {...props}
    >
      {arrow ? (
        <defs>
          <marker
            id={markerId}
            viewBox="0 0 10 10"
            refX="8.5"
            refY="5"
            markerWidth="3.5"
            markerHeight="3.5"
            orient="auto"
            markerUnits="strokeWidth"
          >
            <path d="M1 1L9 5L1 9Z" fill={color} />
          </marker>
        </defs>
      ) : null}
      <motion.path
        d={path}
        stroke={color}
        strokeWidth={strokeWidth}
        strokeLinecap="round"
        strokeLinejoin="round"
        initial={hiddenPath}
        animate={!inView || reduceMotion ? visiblePath : undefined}
        whileInView={inView && !reduceMotion ? visiblePath : undefined}
        viewport={{ once, amount: 0.35 }}
        transition={transition}
        {...pathProps}
      />
      {arrow ? (
        <motion.path
          d={path}
          stroke="transparent"
          strokeWidth={strokeWidth}
          markerEnd={`url(#${markerId})`}
          initial={{ opacity: 0 }}
          animate={!inView || reduceMotion ? { opacity: 1 } : undefined}
          whileInView={inView && !reduceMotion ? { opacity: 1 } : undefined}
          viewport={{ once, amount: 0.35 }}
          transition={arrowTransition}
        />
      ) : null}
    </motion.svg>
  )
}

export interface AnnotationHighlightProps extends Omit<
  HTMLMotionProps<"span">,
  "children" | "color"
> {
  /** Inline content placed above the marker stroke. */
  children: React.ReactNode
  /** Shape of the marker stroke. @default "smooth" */
  variant?: "smooth" | "rough"
  /** Marker color. @default "oklch(0.88 0.16 92 / 0.58)" */
  color?: string
  /** Seconds used to sweep across the content. @default 0.65 */
  duration?: number
  /** Delay before highlighting, in seconds. @default 0 */
  delay?: number
  /** Start highlighting when the content enters the viewport. @default true */
  inView?: boolean
  /** Only highlight the first time the content enters the viewport. @default true */
  once?: boolean
}

/** Sweeps a smooth or hand-drawn highlighter stroke behind inline content. */
function AnnotationHighlight({
  children,
  variant = "smooth",
  color = "oklch(0.88 0.16 92 / 0.58)",
  duration = 0.65,
  delay = 0,
  inView = true,
  once = true,
  className,
  ...props
}: AnnotationHighlightProps) {
  const reduceMotion = useReducedMotion()
  const transition = {
    duration: reduceMotion ? 0 : duration,
    delay: reduceMotion ? 0 : delay,
    ease: [0.22, 1, 0.36, 1] as const,
  }
  const activation = {
    initial: hiddenPath,
    animate: !inView || reduceMotion ? visiblePath : undefined,
    whileInView: inView && !reduceMotion ? visiblePath : undefined,
    viewport: { once, amount: 0.7 },
  }

  return (
    <motion.span
      data-slot="annotation-highlight"
      className={cn(
        "relative isolate inline-block whitespace-nowrap",
        className
      )}
      {...props}
    >
      <svg
        aria-hidden="true"
        viewBox="0 0 100 24"
        preserveAspectRatio="none"
        className="pointer-events-none absolute -inset-x-[3%] bottom-[4%] z-0 h-[72%] w-[106%] overflow-visible"
      >
        <motion.path
          d={
            variant === "rough"
              ? "M2 13C14 9 24 16 37 12C52 8 61 16 74 11C84 8 91 14 98 10"
              : "M2 12C28 11.5 72 12.5 98 12"
          }
          fill="none"
          stroke={color}
          strokeWidth={variant === "rough" ? 15 : 14}
          strokeLinecap={variant === "rough" ? "square" : "round"}
          strokeLinejoin="round"
          transition={transition}
          {...activation}
        />
        {variant === "rough" ? (
          <motion.path
            d="M1 16C16 12 27 17 42 14C58 11 70 17 99 13"
            fill="none"
            stroke={color}
            strokeWidth="5"
            strokeLinecap="square"
            transition={{ ...transition, delay: transition.delay + 0.06 }}
            {...activation}
          />
        ) : null}
      </svg>
      <span data-slot="annotation-highlight-content" className="relative z-10">
        {children}
      </span>
    </motion.span>
  )
}

export { AnnotationHighlight, AnnotationPath }

属性 Props

AnnotationHighlight

AnnotationHighlight 用于在行内文字下方绘制动态扫过的荧光高亮笔触:

属性类型默认值说明
childrenReact.ReactNode—被高亮包裹的行内文字或短语。
variant"smooth" | "rough""smooth"笔触风格。`smooth` 为平整荧光笔刷,`rough` 为带有自然抖动手绘感的双层笔触。
colorstring"oklch(0.88 0.16 92 / 0.58)"高亮笔触颜色(支持 OKLCH、Hex、RGBA 等任意有效 CSS 颜色)。
durationnumber0.65高亮笔触从左向右扫掠完成所需的秒数。
delaynumber0动画开始前的等待延迟时间(秒)。
inViewbooleantrue是否在文字滚动进入视口时才触发动画绘制。
oncebooleantrue是否仅在第一次进入视口时绘制一次。
classNamestring—应用于行内包装容器的 CSS 类名。

AnnotationPath

AnnotationPath 用于绘制任意自定义 SVG 路径(如箭头、圈注、涂鸦线):

属性类型默认值说明
pathstring"M5 70C35 70 30 17 72 22C108 26 112 61 154 10"SVG 路径字符串(d 属性)。
viewBoxstring"0 0 160 80"SVG 画布的 viewBox 属性。
colorstring"currentColor"线条描边与箭头的填充颜色。
strokeWidthnumber3SVG 线条描边粗细。
arrowbooleantrue路径终点是否自动添加手绘箭头。
durationnumber0.8路径绘制总时长(秒)。
delaynumber0开始绘制前的延迟时间(秒)。
inViewbooleantrue是否随视口进入触发。
oncebooleantrue是否仅执行一次。

事件 Events

属性类型默认值说明
onAnimationComplete() => void—当路径绘制或高亮扫掠动画结束时触发。

使用场景与设计规范

Annotation 适用于营销主标题强调、表单指引引导、核心数据圈注、操作引导提示:

  • 聚焦重点而非通篇标注:每屏建议只放置 1 ~ 2 处批注或高亮,过多的手绘元素反而会分散用户的视觉焦点。
  • 与上下文语义相符:荧光笔高亮通常用于正向关键卖点;箭头优先使用 var(--muted-foreground) 等中性色,只有需要强烈提醒时才使用 var(--destructive) 等语义色。

场景示例

荧光笔变体

smooth 为平整的荧光笔,rough 为双层手绘笔触。颜色可以直接用主题令牌与 color-mix 调出半透明底色,亮暗模式自动适配:

Loading…

路径形状

同一个 AnnotationPath 通过不同的 path 与 arrow 组合出引导箭头、圈选与手绘下划线:

Loading…

引导发布操作按钮

手绘箭头从说明文字指向发布按钮:

Loading…

无障碍与交互 Accessibility

  • 文本选区与朗读无阻碍:高亮笔触位于文字底层(z-0),文字始终保持在最顶层(z-10),用户可以正常选中并复制文字。
  • 动态效果减弱:在 prefers-reduced-motion: reduce 下,路径与笔触以零时长直接呈现完整形态,跳过 pathLength 描绘过程;初始结构与服务端保持一致,不会产生水合差异。