Radar Link Sweep
RadarCircular radar visualization with concentric rings, a rotating conic-gradient sweep beam, and icon links arranged radially around the circle — each one glowing briefly as the sweep passes.
Create a Svelte 5 (runes) radar-style link showcase. A square container holds 3 concentric ring outlines (nested absolutely-positioned circles at different `inset` percentages) plus a crosshair (thin horizontal + vertical lines through the center), a pulsing center dot (a `animate-ping` ring behind a solid dot), and a rotating sweep beam built from a `conic-gradient` (mostly transparent, with a bright wedge trailing off near 360deg) animated via a CSS `@keyframes` rotate, `linear infinite`. Around the circle, position N icon links using trigonometry: `angle = (i / total) * 360 - 90`, then `x = 50 + radius * cos(angle)`, `y = 50 + radius * sin(angle)` as percentages, each rendered as an absolutely-positioned `<a>` centered on that point (translate -50%/-50%) containing a small circular icon badge (resolved from a string key against a small Lucide icon map) and a text label hidden below the `sm` breakpoint to avoid crowding on mobile. Each icon badge gets its own `box-shadow` pulse keyframe animation with a per-item `animation-delay` computed as `(angle / 360) * sweepDurationSeconds`, so the glow appears to trigger right as the rotating sweep passes that icon's position — matching the sweep's own animation duration exactly. Add hover states (scale + border/shadow tint) and respect `prefers-reduced-motion` by disabling both animations. Data-driven via `content: { heading?, subtext?, center_label?, items: {label, href, icon, description?}[] }`.
<script lang="ts">
import { reveal } from '$lib/actions/reveal';
import CodeIcon from '@lucide/svelte/icons/code';
import DatabaseIcon from '@lucide/svelte/icons/database';
import PaletteIcon from '@lucide/svelte/icons/palette';
import CpuIcon from '@lucide/svelte/icons/cpu';
import NetworkIcon from '@lucide/svelte/icons/network';
import ShieldIcon from '@lucide/svelte/icons/shield';
import ServerIcon from '@lucide/svelte/icons/server';
import GlobeIcon from '@lucide/svelte/icons/globe';
import LayersIcon from '@lucide/svelte/icons/layers';
import ZapIcon from '@lucide/svelte/icons/zap';
import WrenchIcon from '@lucide/svelte/icons/wrench';
import RadarIcon from '@lucide/svelte/icons/radar';
type RadarItem = { label: string; href: string; icon: string; description?: string };
type Props = {
content: {
heading?: string;
subtext?: string;
center_label?: string;
items: RadarItem[];
};
};
let { content }: Props = $props();
const icons: Record<string, typeof RadarIcon> = {
code: CodeIcon,
database: DatabaseIcon,
palette: PaletteIcon,
cpu: CpuIcon,
network: NetworkIcon,
shield: ShieldIcon,
server: ServerIcon,
globe: GlobeIcon,
layers: LayersIcon,
zap: ZapIcon,
wrench: WrenchIcon,
radar: RadarIcon,
};
const SWEEP_SECONDS = 7;
const ITEM_RADIUS = 36;
const RING_INSETS = [0, 16, 32];
const blips = $derived(
content.items.map((item, i) => {
const angle = (i / content.items.length) * 360 - 90;
const rad = (angle * Math.PI) / 180;
return {
...item,
x: 50 + ITEM_RADIUS * Math.cos(rad),
y: 50 + ITEM_RADIUS * Math.sin(rad),
delay: (((angle + 90) % 360 + 360) % 360) / 360 * SWEEP_SECONDS,
};
})
);
</script>
<section class="relative bg-background py-16 sm:py-20 lg:py-24">
<div class="mx-auto max-w-screen-xl px-4 lg:px-6">
{#if content.heading || content.subtext}
<div class="mx-auto mb-12 max-w-2xl text-center" use:reveal>
{#if content.heading}
<h2 class="text-3xl font-extrabold tracking-tight text-foreground sm:text-4xl">{content.heading}</h2>
{/if}
{#if content.subtext}
<p class="mt-4 text-muted-foreground">{content.subtext}</p>
{/if}
</div>
{/if}
<div
class="relative mx-auto aspect-square w-full max-w-[460px]"
use:reveal={{ animation: 'scale', delay: 150 }}
>
<!-- Concentric rings -->
{#each RING_INSETS as inset}
<div class="absolute rounded-full border border-border/50" style="inset: {inset}%"></div>
{/each}
<!-- Crosshair -->
<div class="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border/40"></div>
<div class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/40"></div>
<!-- Rotating sweep -->
<div class="radar-sweep absolute inset-0 rounded-full" aria-hidden="true"></div>
<!-- Center marker -->
<div class="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2">
<span class="relative flex size-3">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60"></span>
<span class="relative inline-flex size-3 rounded-full bg-primary"></span>
</span>
{#if content.center_label}
<span class="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{content.center_label}
</span>
{/if}
</div>
<!-- Items around the radar -->
{#each blips as item (item.href)}
{@const Icon = icons[item.icon] ?? RadarIcon}
<a
href={item.href}
title={item.description ?? item.label}
class="group absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-2"
style="left: {item.x}%; top: {item.y}%;"
>
<span
class="radar-blip flex size-10 items-center justify-center rounded-full border border-border bg-card text-foreground shadow-sm transition-all duration-300 group-hover:scale-110 group-hover:border-primary group-hover:text-primary group-hover:shadow-lg sm:size-12"
style="animation-delay: {item.delay}s"
>
<Icon class="size-4 sm:size-5" aria-hidden="true" />
</span>
<span
class="hidden max-w-20 text-center text-[11px] font-medium leading-tight text-muted-foreground transition-colors group-hover:text-foreground sm:block"
>
{item.label}
</span>
</a>
{/each}
</div>
</div>
</section>
<style>
.radar-sweep {
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 300deg,
color-mix(in srgb, var(--primary) 40%, transparent) 345deg,
color-mix(in srgb, var(--primary) 65%, transparent) 358deg,
transparent 360deg
);
animation: radar-spin 7s linear infinite;
}
@keyframes radar-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.radar-blip {
animation: radar-blip-glow 7s ease-in-out infinite;
}
@keyframes radar-blip-glow {
0%,
94%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--primary) 0%, transparent);
}
96% {
box-shadow: 0 0 0 6px color-mix(in srgb, var(--primary) 25%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.radar-sweep {
animation: none;
opacity: 0.5;
}
.radar-blip {
animation: none;
}
}
</style>