环形进度
圆环填充到新的数值,中间的数字同步向上计数。
0%
资料完成度用 SVG 为 React 制作环形进度条。在 120×120 的 viewBox 中画两个 r = 52 的圆:一个灰色调的轨道,以及一个 stroke-dasharray 等于周长(2πr)、stroke-dashoffset = 周长 × (1 − 数值) 的进度圆。把进度圆旋转 −90°,让它从 12 点钟方向开始,并使用 stroke-linecap: round。用 requestAnimationFrame 在 900ms 内以 ease-out 三次曲线从旧值动画到新值,每一帧同时更新 dashoffset 和中间的百分比文字(tabular-nums)。以 role="progressbar" 暴露组件,设置 aria-valuenow、aria-valuemin 0、aria-valuemax 100,以及 aria-label“资料完成度”。添加一个按钮随机设置新数值。在 prefers-reduced-motion 下直接跳到新数值。
源代码
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const R = 52
const C = 2 * Math.PI * R
export default function CircularProgress({ labels }: { labels: Record<string, string> }) {
const [shown, setShown] = useState(0)
const [goal, setGoal] = useState(0)
const target = useRef(0)
const frame = useRef(0)
const animateTo = (next: number) => {
cancelAnimationFrame(frame.current)
const from = target.current
target.current = next
setGoal(next)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return setShown(next)
const t0 = performance.now()
const tick = (now: number) => {
const t = Math.min((now - t0) / 900, 1)
setShown(from + (next - from) * (1 - (1 - t) ** 3))
if (t < 1) frame.current = requestAnimationFrame(tick)
}
frame.current = requestAnimationFrame(tick)
}
useEffect(() => {
// start on the next frame so the ring animates in from 0 after mount
frame.current = requestAnimationFrame(() => animateTo(72))
return () => cancelAnimationFrame(frame.current)
}, [])
const pct = Math.round(shown)
return (
<div className={s.wrap}>
<div className={s.ring} role="progressbar" aria-valuenow={goal} aria-valuemin={0} aria-valuemax={100} aria-label={labels.label}>
<svg viewBox="0 0 120 120" aria-hidden="true">
<circle className={s.track} cx="60" cy="60" r={R} />
<circle className={s.bar} cx="60" cy="60" r={R} strokeDasharray={C} strokeDashoffset={C * (1 - shown / 100)} />
</svg>
<span className={s.value}>{pct}%</span>
</div>
<span className={s.label}>{labels.label}</span>
<button type="button" className={s.button} onClick={() => animateTo(10 + Math.floor(Math.random() * 90))}>
{labels.again}
</button>
</div>
)
}
.wrap { display: grid; justify-items: center; gap: 8px; }
.ring { position: relative; width: 120px; height: 120px; }
.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.track, .bar { fill: none; stroke-width: 10; }
.track { stroke: var(--stage-rule); }
.bar { stroke: var(--stage-accent); stroke-linecap: round; }
.value {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font: 600 var(--text-2xl) / 1 var(--font-display);
font-variant-numeric: tabular-nums;
color: var(--stage-ink);
}
.label { font-size: var(--text-xs); color: var(--stage-muted); }
.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; }