Build an Interactive World Map in React
Render a world map with animated arcs connecting locations, built with equirectangular projection and canvas bezier curves.
Global reach is a story that is hard to tell with words alone. Showing it on a map, with arcs flying between cities, makes the point instantly. WorldMap renders a canvas-based world map with animated connection arcs that you configure with plain coordinate pairs.
The final result
What we are building
A canvas component that draws a lat/lng grid over a dark background, places glowing dots at city coordinates, and animates bezier arcs between defined connection pairs. Each arc draws itself on over time and resets, creating a continuous live-data effect.
Setting up
No external dependencies. Everything uses the browser's Canvas2D API.
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';Building the component
Two coordinate conversion utilities handle all the mapping math.
The projection converts geographic coordinates to canvas pixels using equirectangular projection. This is the simplest map projection: longitude maps linearly to x, latitude maps linearly to y (inverted, because canvas y increases downward):
function latLngToXY(
lat: number,
lng: number,
width: number,
height: number
): [number, number] {
const x = ((lng + 180) / 360) * width;
const y = ((90 - lat) / 180) * height;
return [x, y];
}The arc builder computes a quadratic bezier curve between two projected points. The control point is placed above the midpoint, with height proportional to the distance between the endpoints:
function bezierArcPoints(x1, y1, x2, y2, steps = 40): [number, number][] {
const midX = (x1 + x2) / 2;
const midY = (y1 + y2) / 2;
const dist = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
const cx = midX;
const cy = midY - dist * 0.35;
const points: [number, number][] = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const bx = (1 - t) ** 2 * x1 + 2 * (1 - t) * t * cx + t ** 2 * x2;
const by = (1 - t) ** 2 * y1 + 2 * (1 - t) * t * cy + t ** 2 * y2;
points.push([bx, by]);
}
return points;
}Pre-computing the arc points as an array rather than using ctx.quadraticCurveTo directly gives you easy control over partial drawing, which is exactly what the animation uses.
The draw function runs every frame. Arc progress is stored in a progressRef array, one value per arc between 0 and 1:
arcs.forEach((_, i) => {
const points = arcPaths[i];
const color = arcs[i].color || lineColor;
const progress = animated ? progressRef.current[i] : 1;
const endIdx = Math.floor(progress * (points.length - 1));
if (endIdx < 1) return;
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let j = 1; j <= endIdx; j++) {
ctx.lineTo(points[j][0], points[j][1]);
}
const grad = ctx.createLinearGradient(
points[0][0], points[0][1],
points[endIdx][0], points[endIdx][1]
);
grad.addColorStop(0, `${color}00`);
grad.addColorStop(0.5, color);
grad.addColorStop(1, color);
ctx.strokeStyle = grad;
ctx.lineWidth = 1.5;
ctx.stroke();
const tip = points[endIdx];
ctx.beginPath();
ctx.arc(tip[0], tip[1], 3, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
});The gradient fades from transparent at the start to full color at the midpoint and tip. This gives the arc a "traveling head" look where the leading edge is brightest and the trail fades behind it. The glowing dot at points[endIdx] reinforces the leading head.
Progress advances each tick and resets when it exceeds 1.3 (the extra 0.3 gives the arc a moment to hold fully drawn before resetting):
const tick = () => {
progressRef.current = progressRef.current.map(p => {
const next = p + speed * (0.5 + Math.random() * 0.5);
return next > 1.3 ? 0 : next;
});
draw();
animRef.current = requestAnimationFrame(tick);
};The random speed variation (0.5 + Math.random() * 0.5) means arcs reset at slightly different times, so they never all redraw simultaneously.
How to use it
<WorldMap
dots={[
{ lat: 40.7128, lng: -74.006, label: 'New York' },
{ lat: 51.5074, lng: -0.1278, label: 'London' },
{ lat: 35.6762, lng: 139.6503, label: 'Tokyo' },
]}
arcs={[
{ from: { lat: 40.7128, lng: -74.006 }, to: { lat: 51.5074, lng: -0.1278 } },
{ from: { lat: 51.5074, lng: -0.1278 }, to: { lat: 35.6762, lng: 139.6503 } },
]}
dotColor="#38bdf8"
lineColor="#38bdf8"
backgroundColor="#0f172a"
animated={true}
/>Set animated={false} to render all arcs at full progress instantly. Useful for static exports or print views.
Key takeaways
- Pre-computing arc points as an array makes partial drawing trivial: just slice to
endIdxinstead of needing to parameterize a bezier curve evaluation at render time. - A transparent-to-opaque gradient along the arc creates the traveling-head effect without needing to track a separate "head" position.
- Resetting progress at 1.3 rather than 1.0 gives each arc a brief fully-drawn pause before it disappears, which looks more intentional than an immediate reset.