雷达扫描
一道光束绕着雷达盘扫过,每个目标点在光束经过的瞬间亮起,然后慢慢消失。
扫描中
用 SVG 和 CSS 制作雷达扫描效果。用弱化色描边画三个同心圆环和两条十字线。在其上放一个圆盘,背景为 conic-gradient(from 0deg, 50% 不透明度的主色, transparent 70deg),以 3s linear infinite 旋转 360deg,让一道柔和的扇形拖在明亮的前沿之后。在已知角度上放三个目标点。每个目标点运行一个 3s 的 keyframe(opacity 0 → 2% 时为 1 → 40% 时为 0,scale 1.6 → 1),animation-delay = (角度 / 360) × 3s,使它正好在光束经过时亮起。离开屏幕时暂停所有动画。在 prefers-reduced-motion 下光束保持静止,目标点以半透明显示。说明文字为“扫描中”。
源代码
'use client'
import { useEffect, useRef } from 'react'
import s from './demo.module.css'
const PERIOD = 3
// Angles are measured clockwise from 12 o'clock, matching the conic-gradient's start.
const BLIPS = [
{ angle: 50, r: 0.55 },
{ angle: 160, r: 0.8 },
{ angle: 285, r: 0.35 },
]
export default function RadarSweep({ labels }: { labels: Record<string, string> }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const io = new IntersectionObserver(([e]) => el.toggleAttribute('data-paused', !e.isIntersecting))
io.observe(el)
return () => io.disconnect()
}, [])
return (
<div ref={ref} className={s.wrap}>
<div className={s.radar}>
<svg viewBox="0 0 100 100" className={s.grid} aria-hidden="true">
<circle cx="50" cy="50" r="48" />
<circle cx="50" cy="50" r="32" />
<circle cx="50" cy="50" r="16" />
<path d="M50 2v96M2 50h96" />
</svg>
<div className={s.beam} />
{BLIPS.map(({ angle, r }) => {
const rad = (angle * Math.PI) / 180
return (
<span
key={angle}
className={s.blip}
style={{
left: `${50 + Math.sin(rad) * r * 48}%`,
top: `${50 - Math.cos(rad) * r * 48}%`,
animationDelay: `${(angle / 360) * PERIOD}s`,
}}
/>
)
})}
</div>
<span className={s.caption}>{labels.caption}</span>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 12px;
}
.radar {
position: relative;
width: 160px;
aspect-ratio: 1;
border-radius: 50%;
overflow: hidden;
}
.grid {
position: absolute;
inset: 0;
fill: none;
stroke: var(--stage-rule);
stroke-width: 0.6;
}
.beam {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from 0deg, color-mix(in oklch, var(--stage-accent) 50%, transparent), transparent 70deg);
transform: scaleX(-1);
animation: spin 3s linear infinite;
}
/* scaleX(-1) puts the bright edge in front of the wedge as it turns clockwise */
@keyframes spin {
from { transform: scaleX(-1) rotate(0deg); }
to { transform: scaleX(-1) rotate(-360deg); }
}
.blip {
position: absolute;
width: 8px;
height: 8px;
margin: -4px 0 0 -4px;
border-radius: 50%;
background: var(--stage-accent);
box-shadow: 0 0 10px var(--stage-accent);
opacity: 0;
animation: blip 3s linear infinite backwards;
}
@keyframes blip {
0% { opacity: 0; transform: scale(1.6); }
2% { opacity: 1; transform: scale(1.6); }
40% { opacity: 0; transform: scale(1); }
100% { opacity: 0; }
}
.wrap[data-paused] .beam,
.wrap[data-paused] .blip { animation-play-state: paused; }
.caption {
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--stage-muted);
}
@media (prefers-reduced-motion: reduce) {
.beam, .blip { animation: none; }
.blip { opacity: 0.5; }
}