* post
Jul 13, 2026 · 17 min read
on working with maps
Notes from shipping map features — flyTo transitions, geolocation, search, GeoJSON shapes, theming, and a grand finale that combines them all. Built on react-map-gl with live demos you can poke at.
Maps are hard.
Not in the way people think — not the projection math, not the tile pipeline, not the geodesic distance calculations. Those are solved problems with good libraries. The hard part is that a map is a dozen small decisions stacked on top of each other, and each one is easy to get wrong in isolation and impossible to debug in combination. How long should the camera take to fly somewhere? Do you ask for geolocation on mount or wait for a tap? What happens when the user pans away from the circle you just drew? Do search results re-open after you pick one? (They do. You’ll see.)
But here’s the thing: this is true of every hard problem in engineering. Break it down. Solve the small problem. Move on. The map is not a monolith — it’s a camera, a set of sources, a handful of event handlers, and a design system. This post walks through each one, in order, with live demos you can poke at.
We’ll use react-map-gl — vis.gl’s
React wrapper around mapbox-gl — because that’s the library I use in
production, and because everything you’ll see below is live. Pan it. Zoom it.
Search it. Break it.
The map is not the territory. Except it kind of is.
Let’s start with the smallest possible thing that works:
import Map from "react-map-gl/mapbox";
import "mapbox-gl/dist/mapbox-gl.css";
const initialViewState = {
longitude: -58.38,
latitude: -34.6,
zoom: 11,
};
function MapBlock() {
return (
<Map
mapboxAccessToken={import.meta.env.PUBLIC_MAPBOX_TOKEN}
initialViewState={initialViewState}
mapStyle="mapbox://styles/mapbox/light-v11"
style={{ width: "100%", height: 380 }}
/>
);
}
There’s one decision hiding in this snippet that took me an embarrassing amount of time to internalize: the token is in the bundle.
If you’re coming from the backend, your instinct will be to hide it. Don’t.
The token has to be public — react-map-gl runs entirely in the browser, and
the bundle ships to whoever loads the page. The only lever that actually
matters is scoping the token to your domains and a sensible rate limit in the
Mapbox dashboard. Everything else is theater.
The second thing is the difference between controlled and uncontrolled. If
you’ve ever touched a <input> in React, you already know this dance. An
uncontrolled map is one where you seed an initialViewState and let the
user drive from there. A controlled map is one where you hold viewState
in your own state, pass it down, and react to onMove.
Most production maps are uncontrolled. You only reach for controlled when something else on the page needs to have an opinion about the camera — which, conveniently, is exactly the next three sections.
flyTo, or: the camera is the product
Here’s a question for you. When a user clicks an item on a list, what should the map do?
Option A: instantly re-center. Option B: glide.
A is what you get for free. B is what makes it feel like a product.
The difference isn’t taste. A hard cut reads as a bug. A smooth arc reads as
intent. And the only knob you’re really turning is flyTo:
import Map, { Marker, type MapRef } from "react-map-gl/mapbox";
import { useRef, useState } from "react";
const places = [
{ id: "petra", name: "Petra", longitude: 35.44, latitude: 30.33 },
// …
];
const flyToPlace = (place) => {
setActiveId(place.id);
mapRef.current?.flyTo({
center: [place.longitude, place.latitude],
zoom: 6,
duration: 1400,
curve: 1.4,
essential: true,
});
};
It looks like five lines. It’s actually three decisions stacked on top of each other.
duration is how long the animation takes, in milliseconds. I used to ship
4000 and feel smug about it. Then I watched a real user walk up to a demo,
click, and say “okay, I get it, stop flying.” One to two seconds is the
actual sweet spot. Long enough that you can see the geography change. Short
enough that nobody waits for it.
curve is the shape of the arc. The camera doesn’t fly straight — it zooms
out, scoots sideways, and zooms back in. curve: 1 is a straight line. 1.4
to 1.6 feels like an airplane without lingering. Anything higher and you’ve
made a screensaver.
essential: true is the one nobody tells you about. The browser quietly
throttles animations in background tabs. If you ever notice a flyTo “dying”
midway when you alt-tab away, this is why. Mark it essential and the browser
keeps it alive.
Try it. Pick a place. Watch the camera decide to be elsewhere:
It’s worth saying out loud what just happened. The map responded to a click that wasn’t on the map. The list and the map don’t know about each other — the list sets state, the camera follows. That’s the whole pattern. Everything else in this post is a variation on it.
Locate, and the art of not being weird
Geolocation is the one map feature where the failure mode is mostly social.
The technical part is trivial: the browser knows where you are, you ask the browser, the browser asks the user. The hard part is when. Trigger it the moment the page loads and you’ve just popped a permission prompt into a stranger’s face. Bury it in a settings menu and no one will ever find it.
react-map-gl ships a GeolocateControl that handles the lifecycle for you —
a little crosshair button the user taps, the permission prompt, the watched
position, the GPS marker. You drop it in as a child of <Map>:
import Map, { GeolocateControl } from "react-map-gl/mapbox";
<Map
mapboxAccessToken={import.meta.env.PUBLIC_MAPBOX_TOKEN}
initialViewState={{ longitude: -58.38, latitude: -34.6, zoom: 11 }}
mapStyle="mapbox://styles/mapbox/light-v11"
style={{ width: "100%", height: 380 }}
>
<GeolocateControl
positionOptions={{ enableHighAccuracy: true }}
trackUserLocation
/>
</Map>
By default, this control is just a button. It waits. That’s the entire point
— the user opts in. The trap I want you to avoid is .trigger(). There’s a
method on the underlying control that lets you fire it programmatically, and
you’ll be tempted to call it on mount so the map “just works”. Don’t. That’s
the weird. Let the crosshair sit there; the user will reach for it when they
want to be found:
trackUserLocation keeps the marker pinned to the user as they move. That’s
the right default once they’ve opted in. Before that, it’s surveillance
coded as a feature.
Search, or: coordinates are not the interesting part
Search on a map looks like one feature. It’s two.
The first is: turn “tokyo” into a coordinate. That’s a fetch to the Mapbox Geocoding API:
const searchPlaces = async (query: string) => {
const endpoint = new URL(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json`,
);
endpoint.searchParams.set("access_token", import.meta.env.PUBLIC_MAPBOX_TOKEN);
endpoint.searchParams.set("autocomplete", "true");
endpoint.searchParams.set("limit", "5");
const response = await fetch(endpoint);
return (await response.json()).features;
};
The second is: once they pick a result, fly the camera there. And — here’s the
part that took me too long to see — that’s the same flyTo from earlier.
const selectFeature = (feature: GeocodeFeature) => {
mapRef.current?.flyTo({
center: feature.center,
zoom: 12,
duration: 1200,
curve: 1.4,
essential: true,
});
};
The camera API doesn’t care why you’re moving. A click from a list. A tap on
a search result. A geolocate event. They all reduce to the same call. This is
the part of the post where I’d encourage you to slow down, because once you
see it, you can’t unsee it: the entire interaction surface of a map is just
“what should the camera do, and when.”
The pattern that actually feels good is: debounce the input by ~250ms, render a short list while typing, and only commit to a camera move on explicit selection. Flying on every keystroke is nauseating — not just because of the camera thrash, but because of the network races. A fast typist on a slow connection will out-type their own results, and you’ll start flying to places they didn’t mean:
Drawing shapes: GeoJSON circles and the radius problem
Here’s something that sounds trivial until you try it: draw a circle on a map.
Not a marker. Not a polygon you hardcoded. A real circle — the user picks a radius, the shape grows and shrinks. You’d think this is one line. It’s not.
The problem is that the Earth is not flat. (I know, I know.) A circle on a
globe isn’t a circle in screen space — it’s a geodesic curve, and if you just
plot x = r * cos(θ), y = r * sin(θ) you get an oval that stretches the further
you get from the equator. The fix is to compute the circle in geodesic space
and hand the resulting polygon to mapbox-gl as a GeoJSON source:
const EARTH_RADIUS_KM = 6371;
function circlePolygon(lng, lat, radiusKm) {
const coords = [];
const steps = 64;
for (let i = 0; i <= steps; i++) {
const bearing = (i / steps) * 2 * Math.PI;
const dByR = radiusKm / EARTH_RADIUS_KM;
const latRad = (lat * Math.PI) / 180;
const lngRad = (lng * Math.PI) / 180;
const destLat = Math.asin(
Math.sin(latRad) * Math.cos(dByR) +
Math.cos(latRad) * Math.sin(dByR) * Math.cos(bearing),
);
const destLng = lngRad + Math.atan2(
Math.sin(bearing) * Math.sin(dByR) * Math.cos(latRad),
Math.cos(dByR) - Math.sin(latRad) * Math.sin(destLat),
);
coords.push([
((destLng * 180) / Math.PI + 540) % 360 - 180,
(destLat * 180) / Math.PI,
]);
}
return {
type: "Feature",
geometry: { type: "Polygon", coordinates: [coords] },
};
}
That’s the haversine formula applied 64 times around a center point. Hand the
result to a <Source type="geojson"> with a fill layer and a line layer, and
you have a circle:
import Map, { Source, Layer } from "react-map-gl/mapbox";
<Map /* … */>
<Source id="circle" type="geojson" data={circlePolygon(lng, lat, radiusKm)}>
<Layer
id="circle-fill"
type="fill"
paint={{ "fill-color": "#3b82f6", "fill-opacity": 0.12 }}
/>
<Layer
id="circle-line"
type="line"
paint={{ "line-color": "#3b82f6", "line-width": 1.5 }}
/>
</Source>
</Map>
Now the interesting part: the slider. You hold radiusKm in state, recompute
the GeoJSON on every change, and the circle redraws. The trick is to
useMemo the polygon so you’re only recalculating when the radius actually
changes:
const [radius, setRadius] = useState(5);
const data = useMemo(
() => circlePolygon(lng, lat, radius),
[lng, lat, radius],
);
(If you’re on the React Compiler,
skip the useMemo — the compiler memoizes this for you.)
The slider calls setRadius, and the camera eases back over the circle at a
zoom computed from the radius — so the shape always fits the frame, even if
you panned halfway across the province first. Drag to the max and the map
pulls back until the whole circle is visible.
One more detail that’s easy to miss: the slider is logarithmic, not linear. Radii live on a multiplicative scale — the jump from 1 km to 2 km matters as much as the jump from 20 km to 40 km — and a linear slider spends half its travel on the boring top end.
One gotcha worth calling out: Mapbox paint properties are WebGL, not CSS.
"fill-color": "hsl(var(--link))" looks like it should work — it doesn’t.
WebGL can’t read CSS custom properties. You have to pass an actual color
value, and switch it yourself when the theme changes. And one more trap
inside the trap: mapbox’s color parser only understands the legacy
comma-separated HSL syntax. Modern space-separated HSL —
hsl(172 39% 65%) — fails validation silently and the layer never renders:
const FILL_COLOR = {
dark: "hsl(172, 39%, 65%)",
light: "hsl(192, 60%, 44%)",
};
const theme = useSiteTheme();
<Layer
type="fill"
paint={{
"fill-color": FILL_COLOR[theme],
"fill-opacity": 0.15,
}}
/>
Drag the slider. The circle breathes:
This is the pattern I keep coming back to: React holds the state, GeoJSON is the shape, and the map just renders it. You’re not animating the circle — you’re recomputing it. The map doesn’t know about the slider. It doesn’t need to.
Make it your own: map themes and custom markers
Everything so far has been functional. Let’s talk about the part where the map stops looking like every other map on the internet.
The mapStyle prop is where your visual identity lives. Mapbox ships a handful
of defaults — light-v11, dark-v11, streets-v12, satellite-v9 — and
they’re fine. But “fine” is what makes your map indistinguishable from a
tutorial. The move is to either craft a custom style in the
Mapbox Studio editor, or — if you’re working
within an existing design system — switch styles at runtime based on the user’s
theme preference.
Theme-aware styles with useSyncExternalStore
Every demo on this page watches the site’s data-theme attribute and swaps
between light-v11 and dark-v11. Toggle the site theme right now — the maps
follow. That sounds like a useEffect + MutationObserver job, but it’s
exactly the use case useSyncExternalStore was built for:
import { useSyncExternalStore } from "react";
type Theme = "light" | "dark";
function readTheme(): Theme {
if (typeof document === "undefined") return "light";
return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
}
function subscribe(callback: () => void) {
const observer = new MutationObserver(callback);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return () => observer.disconnect();
}
export function useSiteTheme(): Theme {
return useSyncExternalStore(subscribe, readTheme, () => "light");
}
No effects. No stale closures. No serializer/deserializer mismatch between SSR
and hydration. subscribe connects the MutationObserver; readTheme is the
snapshot the component sees. React handles the rest.
Then use it directly in the map’s mapStyle:
const theme = useSiteTheme();
<Map
mapStyle={
theme === "dark"
? "mapbox://styles/mapbox/dark-v11"
: "mapbox://styles/mapbox/light-v11"
}
// …
/>
Custom markers with currentColor
The default Marker component drops a mapbox teardrop pin. It’s recognizable,
which is another way of saying it’s generic. But Marker accepts any React
node as a child — you can render an SVG, an emoji, a styled div, anything.
Here’s a pin drawn from scratch with two paths and a circle:
function PinMarker() {
return (
<svg width="22" height="30" viewBox="0 0 22 30" fill="none">
<path
d="M11 0C5.4 0 1 4.4 1 10c0 7 10 20 10 20s10-13 10-20c0-5.6-4.4-10-10-10z"
fill="currentColor"
opacity={0.18}
/>
<path
d="M11 1.5c-4.7 0-8.5 3.8-8.5 8.5 0 5.8 8.5 17 8.5 17s8.5-11.2 8.5-17c0-4.7-3.8-8.5-8.5-8.5z"
stroke="currentColor"
strokeWidth={1.5}
fill="none"
/>
<circle cx="11" cy="10" r="3" fill="currentColor" />
</svg>
);
}
<Marker longitude={lng} latitude={lat} anchor="bottom">
<span className="text-[hsl(var(--link))]">
<PinMarker />
</span>
</Marker>
currentColor means it inherits whatever text color you wrap it in. Toggle the
theme and the pin recolors automatically — the marker doesn’t know about the
theme, it just reads the CSS cascade.
Layer colors: WebGL is not CSS
Here’s a trap I fell into: I tried to use CSS custom properties in GeoJSON layer paint:
// ❌ Doesn't work — WebGL can't read CSS custom properties
<Layer
type="fill"
paint={{ "fill-color": "hsl(var(--link))" }}
/>
It compiles. It doesn’t render. The paint object is handed to mapbox-gl’s WebGL renderer, which has no access to the DOM’s CSS cascade. You need literal color values, switched yourself when the theme changes:
// ✅ Works — real color values, swapped per theme
const COLORS = {
dark: "hsl(172 39% 65%)",
light: "hsl(192 60% 44%)",
};
const theme = useSiteTheme();
<Layer
type="fill"
paint={{ "fill-color": COLORS[theme], "fill-opacity": 0.15 }}
/>
Toggle the site theme and both the map style and the circle fill change in lockstep — but only because you wired it, not because CSS cascaded into WebGL.
Data-driven paint expressions
The real power move is data-driven paint — layer properties that change based on feature data or zoom level. You can make the circle fill get more opaque as you zoom in, or change color based on a property:
<Layer
type="fill"
paint={{
"fill-color": COLORS[theme],
"fill-opacity": [
"interpolate",
["linear"],
["zoom"],
8, 0.08, // zoom 8: faint
12, 0.20, // zoom 12: stronger
],
}}
/>
That’s a mapbox expression
— the first argument is the expression type ("interpolate"), then the
interpolation method, then the input (["zoom"]), then stop pairs. At zoom
8 the fill is barely visible. At zoom 12 it’s solid enough to read. The user
zooms; the map reacts. No React state, no event handlers, just a paint
expression.
Or switch the fill color based on a feature property — useful when you have classed data:
<Layer
type="fill"
paint={{
"fill-color": [
"match",
["get", "category"],
"park", "#2d6a4f",
"water", "#48cae4",
"#6c757d", // fallback
],
}}
/>
["get", "category"] reads the category property from each GeoJSON
feature. match maps values to colors. The fallback handles anything
unmatched.
The point is this: the map is not a foreign object embedded in your page. It’s a citizen of your design system — theme-aware, color-consistent, and capable of expressing dynamic visual logic through paint expressions alone.
Closing thoughts: putting it all together
Search a place. Drag the radius. Hit the crosshair.
Every pattern from this post — flyTo, geolocation, GeoJSON shapes, theme-aware
colors — lives in that one map. Same camera. Same React state. Different things
setting it. That’s what “maps are hard” actually looks like once you break it
down: a handful of small problems, each with a clear answer, composed into
something that feels like one thing.
A few notes for the road:
flyTowithoutessential: truewill throttle in background tabs. If you’ve ever shipped a demo and had someone say “it stops when I switch tabs”, this is why.- Import the stylesheet.
import "mapbox-gl/dist/mapbox-gl.css"at the entry of your island. Without it, markers and controls render unstyled — and “unstyled” here is more visible than you’re imagining. This one is so easy to miss that even react-map-gl’s own geocoder example shipped without its stylesheet — I sent the fix. - Search results will re-open after selection if you’re not careful. The naive pattern is to set the input to the selected place name — but that triggers your debounce, which fetches new results, which re-opens the dropdown. Use a ref to suppress the next search cycle. It’s one line, but it’s the difference between “that feels broken” and “that feels right.”
- WebGL can’t read CSS custom properties. Use literal color values and
swap them yourself when the theme changes.
hsl(var(--link))in apaintobject silently renders nothing.
The map is a dozen small problems. Solve them one at a time.
Bonus: React Native — same problems, different libraries
Everything above is about the web. But if you’re building a mobile app — and at some point, you will be — the mental models transfer. The camera is still the camera. The token is still public. The user still needs to opt in to geolocation. You’re still breaking the same dozen problems into the same small pieces — just with different libraries.
The libraries are different. Here’s the stack I’d reach for today:
@rnmapbox/maps is the React Native
port of mapbox-gl. It’s the closest thing to a “just use this” answer — same
API concepts, same style URLs, same GeoJSON sources and layers. If you’ve read
this far, you already know how to use it. The one gotcha is that it bridges
into native, so you’ll spend an afternoon linking native modules and cursing
at Xcode. That’s the tax.
react-native-better-clustering solves the problem I deliberately didn’t cover above:
what happens when you have thousands of points. On the web, mapbox-gl has
built-in clustering via a cluster: true GeoJSON source. On React Native,
that path is slower and jankier than it should be. This library does the
supercluster math on the JS side and hands clean results to the native map.
It’s the one I’d reach for the moment point counts cross four digits.
react-native-nitro-map is the one I’m watching. It’s built on
Nitro Modules — Margelo’s JSI-based native
module system — which means it bypasses the React Native bridge entirely.
That’s the kind of architectural shift that makes camera animations,
clustering, and gesture handling actually feel native instead of “good enough.”
It’s not stable yet, but the performance numbers are the direction mobile maps
have been heading for years.
The point is this: the libraries change. The patterns don’t. flyTo is still
flyTo. Geolocation still needs consent. And the camera is still the product.