顶部加载条
页面加载时一条细条在顶部慢慢爬行,越走越慢,加载完成后冲到尽头再淡出。
用 React 制作页面顶部的加载条。开始时显示一条 3px 的主色进度条,transform: scaleX(0.08),transform-origin 为 left。每隔 300ms 让它前进剩余距离的一部分(progress += (0.9 − progress) × 0.15),使它不断前进、逐渐变慢,但永远不会自己走到终点。transform 加 300ms ease-out 过渡。加载完成时(用 1.8s 的 timeout 模拟),用更快的 200ms 过渡把进度设为 1,然后在 300ms 内淡出并重置。用伪元素的 box-shadow 在前端加一点柔和的光晕。组件卸载时清除所有计时器。在 prefers-reduced-motion 下不做爬行,完成前显示一条静止的进度条。
源代码
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
type Phase = 'idle' | 'loading' | 'done'
export default function TopLoadingBar({ labels }: { labels: Record<string, string> }) {
const [phase, setPhase] = useState<Phase>('idle')
const [p, setP] = useState(0)
const timers = useRef<number[]>([])
const clear = () => {
timers.current.forEach((t) => (clearInterval(t), clearTimeout(t)))
timers.current = []
}
useEffect(() => clear, [])
const start = () => {
clear()
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
setPhase('loading')
setP(reduced ? 0.6 : 0.08)
if (!reduced) timers.current.push(window.setInterval(() => setP((v) => v + (0.9 - v) * 0.15), 300))
timers.current.push(
window.setTimeout(() => {
clear()
setPhase('done')
setP(1)
timers.current.push(window.setTimeout(() => (setPhase('idle'), setP(0)), 700))
}, 1800),
)
}
return (
<div className={s.window}>
<span className={s.bar} data-phase={phase} style={{ transform: `scaleX(${p})` }} aria-hidden="true" />
<div className={s.chrome}>
<span />
<span />
<span />
</div>
<div className={s.body}>
<button type="button" className={s.btn} onClick={start} disabled={phase === 'loading'}>
{labels.load}
</button>
<span className={s.status} role="status">
{phase === 'done' ? labels.loaded : ''}
</span>
</div>
</div>
)
}
.window {
position: relative;
width: min(300px, 100%);
overflow: hidden;
border: 1px solid var(--stage-rule);
border-radius: 12px;
background: var(--stage-surface);
}
.bar {
position: absolute;
left: 0;
top: 0;
z-index: 1;
width: 100%;
height: 3px;
background: var(--stage-accent);
transform-origin: left;
opacity: 0;
transition: transform 300ms var(--ease-out), opacity 300ms;
}
.bar::after {
content: '';
position: absolute;
right: 0;
top: 0;
width: 60px;
height: 100%;
box-shadow: 0 0 10px 1px var(--stage-accent);
}
.bar[data-phase='loading'] { opacity: 1; }
.bar[data-phase='done'] { opacity: 0; transition: transform 200ms var(--ease-out), opacity 300ms 250ms; }
.chrome {
display: flex;
gap: 6px;
padding: 12px;
border-bottom: 1px solid var(--stage-rule);
}
.chrome span { width: 8px; height: 8px; border-radius: 50%; background: var(--stage-rule); }
.body {
display: grid;
justify-items: center;
gap: 8px;
padding: 28px 16px;
}
.btn {
height: 36px;
padding: 0 16px;
border: 0;
border-radius: var(--radius-sm);
background: var(--stage-accent);
color: var(--stage-accent-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
cursor: pointer;
}
.btn:disabled { opacity: 0.6; cursor: progress; }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
.status {
min-height: 1em;
font: 500 var(--text-xs) / 1 var(--font-mono);
color: var(--stage-muted);
}
@media (prefers-reduced-motion: reduce) {
.bar, .bar[data-phase='done'] { transition: opacity 150ms; }
}