Radar Sweep
A beam sweeps round a radar dish and each blip flashes the moment the beam passes over it, then fades.
Create a radar sweep in SVG and CSS. Draw three concentric circle rings and two crosshair lines in a muted stroke. Over them, place a disc with a conic-gradient(from 0deg, accent at 50% opacity, transparent 70deg) that rotates 360deg every 3s linear infinite, so a soft wedge trails behind a bright leading edge. Place three blip dots at known angles. Each blip runs a 3s keyframe (opacity 0 → 1 at 2% → 0 at 40%, scale 1.6 → 1) with animation-delay = (angle / 360) × 3s, so it flashes exactly as the beam passes. Pause all animation when off screen. Under prefers-reduced-motion, hold the beam still and show the blips at half opacity. The caption reads "Scanning".
Source
'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; }
}