组件
手绘批注与高亮 Annotation
带有手绘笔触风格的动态荧光笔扫掠高亮、手绘箭头引导与路径圈注组件。
基础用法
进入视口时,关键词被荧光笔动态扫掠高亮,关键指标被手绘椭圆路径圈出,箭头指向核心 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"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 用于在行内文字下方绘制动态扫过的荧光高亮笔触:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 被高亮包裹的行内文字或短语。 |
| variant | "smooth" | "rough" | "smooth" | 笔触风格。`smooth` 为平整荧光笔刷,`rough` 为带有自然抖动手绘感的双层笔触。 |
| color | string | "oklch(0.88 0.16 92 / 0.58)" | 高亮笔触颜色(支持 OKLCH、Hex、RGBA 等任意有效 CSS 颜色)。 |
| duration | number | 0.65 | 高亮笔触从左向右扫掠完成所需的秒数。 |
| delay | number | 0 | 动画开始前的等待延迟时间(秒)。 |
| inView | boolean | true | 是否在文字滚动进入视口时才触发动画绘制。 |
| once | boolean | true | 是否仅在第一次进入视口时绘制一次。 |
| className | string | — | 应用于行内包装容器的 CSS 类名。 |
AnnotationPath
AnnotationPath 用于绘制任意自定义 SVG 路径(如箭头、圈注、涂鸦线):
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| path | string | "M5 70C35 70 30 17 72 22C108 26 112 61 154 10" | SVG 路径字符串(d 属性)。 |
| viewBox | string | "0 0 160 80" | SVG 画布的 viewBox 属性。 |
| color | string | "currentColor" | 线条描边与箭头的填充颜色。 |
| strokeWidth | number | 3 | SVG 线条描边粗细。 |
| arrow | boolean | true | 路径终点是否自动添加手绘箭头。 |
| duration | number | 0.8 | 路径绘制总时长(秒)。 |
| delay | number | 0 | 开始绘制前的延迟时间(秒)。 |
| inView | boolean | true | 是否随视口进入触发。 |
| once | boolean | true | 是否仅执行一次。 |
事件 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 描绘过程;初始结构与服务端保持一致,不会产生水合差异。