Command Search
Search for a page
/ Components / Radar Link Sweep

Radar Link Sweep

Radar

Circular 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.

radarlinksanimationcircularicons
Open preview
Live Preview
AI Generation Prompt

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>