Radar Sweep
A rotating sweep reveals blips as it passes.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/radar-sweep.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/radar-sweep/radar-sweep.tsx"use client"; import type { ComponentProps, CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface RadarBlip { /** Bearing in degrees, clockwise from the top. */ angle: number; /** Distance from the center, 0–1. */ distance: number; /** Name read by screen readers and shown on hover. */ label?: string; } export interface RadarSweepProps extends Omit<ComponentProps<"div">, "children"> { /** Points that light up as the sweep passes. */ blips?: RadarBlip[]; /** Seconds per revolution. @default 4 */ duration?: number; /** Sweep and blip color. @default "var(--primary)" */ color?: string; /** Number of range rings. @default 4 */ rings?: number; /** Accessible label for the radar. @default "Radar" */ label?: string; } // Keyframes ship with the component (React 19 hoists and de-duplicates this <style>). const KEYFRAMES = "@keyframes hantui-radar-spin{to{transform:rotate(360deg)}}@keyframes hantui-radar-blip{0%{opacity:1;transform:translate(-50%,-50%) scale(1.5)}6%{opacity:1;transform:translate(-50%,-50%) scale(1)}70%,100%{opacity:.12;transform:translate(-50%,-50%) scale(1)}}"; export function RadarSweep({ blips = [], duration = 4, color = "var(--primary)", rings = 4, label = "Radar", className, style, ...props }: RadarSweepProps) { return ( <div role="img" aria-label={blips.length ? `${label}: ${blips.map((b) => b.label).filter(Boolean).join(", ") || `${blips.length} contacts`}` : label} className={cn("relative aspect-square w-full max-w-80 overflow-hidden rounded-full border border-border bg-background", className)} style={{ "--radar": color, ...style } as CSSProperties} {...props} > <style href="hantui-radar-sweep" precedence="hantui"> {KEYFRAMES} </style> {Array.from({ length: rings }, (_, i) => ( <span key={i} aria-hidden="true" className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border" style={{ width: `${((i + 1) / rings) * 100}%`, height: `${((i + 1) / rings) * 100}%` }} /> ))} <span aria-hidden="true" className="absolute top-0 left-1/2 h-full w-px -translate-x-1/2 bg-border" /> <span aria-hidden="true" className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-border" /> {/* The sweep: a conic gradient whose bright leading edge sits at 0°, with a fading trail behind it. */} <span aria-hidden="true" className="absolute inset-0 rounded-full [animation:hantui-radar-spin_var(--radar-duration)_linear_infinite] motion-reduce:[animation:none] motion-reduce:rotate-[210deg]" style={ { "--radar-duration": `${duration}s`, background: "conic-gradient(from 0deg, transparent 0deg 260deg, color-mix(in oklab, var(--radar) 28%, transparent) 358deg, color-mix(in oklab, var(--radar) 70%, transparent) 360deg)", } as CSSProperties } /> {blips.map((blip, i) => { const radians = (blip.angle * Math.PI) / 180; const x = 50 + Math.sin(radians) * blip.distance * 50; const y = 50 - Math.cos(radians) * blip.distance * 50; // A negative delay puts each blip's flash exactly when the sweep reaches its bearing. const delay = ((((blip.angle % 360) + 360) % 360) / 360) * duration - duration; return ( <span key={i} title={blip.label} aria-hidden="true" className="absolute size-2.5 rounded-full shadow-[0_0_10px_var(--radar)] [animation:hantui-radar-blip_var(--radar-duration)_linear_infinite] motion-reduce:[animation:none] motion-reduce:-translate-1/2 motion-reduce:opacity-70" style={{ left: `${x}%`, top: `${y}%`, background: "var(--radar)", "--radar-duration": `${duration}s`, animationDelay: `${delay}s` } as CSSProperties} /> ); })} <span aria-hidden="true" className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ background: "var(--radar)" }} /> </div> ); }
Usage
import { RadarSweep } from "@/components/ui/radar-sweep/radar-sweep";
<RadarSweep blips={[{ angle: 35, distance: 0.55, label: "Maya's laptop" }]} label="Nearby devices" />API reference
| Prop | Type | Default | Description |
|---|---|---|---|
blips | RadarBlip[] | – | Points that light up as the sweep passes. |
duration | number | 4 | Seconds per revolution. |
color | string | "var(--primary)" | Sweep and blip color. |
rings | number | 4 | Number of range rings. |
label | string | "Radar" | Accessible label for the radar. |
Also accepts every native <div> attribute, including ref and className.
- Pure CSS: the sweep is a rotating conic gradient, and each blip runs the same-length fade cycle with a negative delay, so it flashes exactly as the sweep crosses its bearing. The keyframes ship inside the component through a React 19 hoisted <style>.
Accessibility
- The radar is a single labelled image that lists its contacts, so screen readers get the information without the animation.
- With reduced motion the sweep holds still and every blip stays softly lit.