音频跳动条
一排均衡器条以不规则的节奏跳动,表示正在播放,暂停时随之定住。
正在播放 · 退潮
用 SVG 和 CSS 制作带播放/暂停切换的均衡器动画。在 SVG 中画七个等高的圆角矩形,设置 transform-box: fill-box 和 transform-origin bottom,让它们从底线开始缩放。每根条用 alternate infinite、ease-in-out 的 keyframe 在 scaleY 0.2 到 1 之间变化,每根有自己的时长(0.45s 到 0.9s 之间)和负延迟,让节奏永远对不齐。切换按钮在外层设置 data-playing;不播放时设置 animation-play-state: paused,并让条缓缓回落到 scaleY(0.2)。按钮使用 aria-pressed,文字在“播放”和“暂停”之间切换。离开屏幕时也暂停。在 prefers-reduced-motion 下显示高度各异的静态条。说明文字为“正在播放 · 退潮”。
源代码
'use client'
import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const BARS = [0.62, 0.48, 0.81, 0.55, 0.9, 0.45, 0.7]
export default function AudioBars({ labels }: { labels: Record<string, string> }) {
const [playing, setPlaying] = useState(true)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(([e]) => el.toggleAttribute('data-offscreen', !e.isIntersecting))
io.observe(el)
return () => io.disconnect()
}, [])
return (
<div ref={ref} className={s.player} data-playing={playing || undefined}>
<svg viewBox="0 0 76 40" className={s.bars} aria-hidden="true">
{BARS.map((d, i) => (
<rect
key={i}
x={i * 11}
y="0"
width="6"
height="40"
rx="3"
className={s.bar}
style={{ '--dur': `${d}s`, '--delay': `${-i * 0.23}s`, '--rest': 0.3 + (i % 3) * 0.25 } as React.CSSProperties}
/>
))}
</svg>
<span className={s.track}>{labels.track}</span>
<button type="button" className={s.btn} aria-pressed={playing} onClick={() => setPlaying(!playing)}>
{playing ? labels.pause : labels.play}
</button>
</div>
)
}
.player {
display: grid;
justify-items: center;
gap: 12px;
}
.bars { width: 96px; height: 52px; }
.bar {
fill: var(--stage-accent);
transform-box: fill-box;
transform-origin: bottom;
transform: scaleY(0.2);
transition: transform 400ms var(--ease-out);
}
.player[data-playing] .bar {
animation: bounce var(--dur) ease-in-out var(--delay) infinite alternate;
}
.player[data-offscreen] .bar { animation-play-state: paused; }
@keyframes bounce {
from { transform: scaleY(0.2); }
to { transform: scaleY(1); }
}
.track {
font: 500 var(--text-sm) / 1 var(--font-body);
color: var(--stage-muted);
}
.btn {
height: 32px;
padding: 0 14px;
border: 1px solid var(--stage-rule);
border-radius: 999px;
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
cursor: pointer;
}
.btn:hover { border-color: var(--stage-accent); }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.player[data-playing] .bar { animation: none; transform: scaleY(var(--rest)); }
}