路径描绘
线条插画一笔一笔地画出来,画完后一个小圆点沿着路线移动。
用 CSS 让 React 中的 SVG 线条图自己画出来。对每个 <path> 用 getTotalLength() 读取长度并设为 --len;初始设置 stroke-dasharray: var(--len) 和 stroke-dashoffset: var(--len),然后在 1.6 秒内把 stroke-dashoffset 动画到 0,缓动为 cubic-bezier(0.65, 0, 0.35, 1),每条路径依次错开 250ms。使用 stroke-linecap: round 和 fill: none。画完后,用相同路径数据的 offset-path 让一个强调色的小圆点沿主路线移动,offset-distance 在 1.8 秒内从 0% 动画到 100%。添加一个“重播”按钮,通过改变 key 重新播放。给 SVG 设置 role="img" 和 aria-label“从家到工作室的路线”。在 prefers-reduced-motion 下直接显示完成的图,圆点停在终点。
源代码
'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const ROUTE = 'M20 120 C 60 120, 60 40, 110 40 S 160 110, 200 80 S 240 30, 260 30'
export default function PathDraw({ labels }: { labels: Record<string, string> }) {
const [run, setRun] = useState(0)
const svg = useRef<SVGSVGElement>(null)
useLayoutEffect(() => {
svg.current?.querySelectorAll('path').forEach((p) => p.style.setProperty('--len', String(Math.ceil(p.getTotalLength()))))
}, [run])
return (
<div className={s.wrap}>
<svg key={run} ref={svg} className={s.svg} viewBox="0 0 280 150" role="img" aria-label={labels.label}>
<path className={s.grid} style={{ '--d': 0 } as React.CSSProperties} d="M20 135 H260" />
<path className={s.house} style={{ '--d': 1 } as React.CSSProperties} d="M8 120 V100 L20 90 L32 100 V120 Z" />
<path className={s.studio} style={{ '--d': 2 } as React.CSSProperties} d="M248 30 V10 H272 V30 Z M254 18 H266" />
<path className={s.route} style={{ '--d': 3 } as React.CSSProperties} d={ROUTE} />
<circle className={s.dot} r="5" style={{ offsetPath: `path("${ROUTE}")` }} />
</svg>
<button type="button" className={s.button} onClick={() => setRun(run + 1)}>
{labels.replay}
</button>
</div>
)
}
.wrap { display: grid; justify-items: center; gap: 12px; width: min(300px, 100%); }
.svg { width: 100%; height: auto; overflow: visible; }
.svg path {
fill: none;
stroke: var(--stage-ink);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: var(--len, 1000);
stroke-dashoffset: var(--len, 1000);
animation: draw 1.6s var(--ease-in-out) forwards;
animation-delay: calc(var(--d) * 250ms);
}
.svg .grid { stroke: var(--stage-rule); }
.svg .route { stroke: var(--stage-accent); stroke-width: 3; stroke-dasharray: var(--len, 1000); }
.dot {
fill: var(--stage-ink);
offset-distance: 0%;
opacity: 0;
animation: ride 1.8s 2.2s var(--ease-in-out) forwards;
}
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes ride {
0% { offset-distance: 0%; opacity: 1; }
100% { offset-distance: 100%; opacity: 1; }
}
.button {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: 8px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.button:hover { border-color: var(--stage-accent); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.svg path { animation: none; stroke-dashoffset: 0; }
.dot { animation: none; opacity: 1; offset-distance: 100%; }
}