Layer

Declaratively add a GeoJSON source and a styled layer as a child of the map.

Usage

<Layer> owns a GeoJSON source and the layer drawn from it. Give it data, a layer type, and the usual Mapbox paint spec - data-driven expressions work exactly as they do in Mapbox GL.

The example below draws ZIP boundaries over Denver and reports the code under the cursor. Its data is a URL rather than an object, so Mapbox fetches the GeoJSON itself and the geometry never enters your bundle.

Same source, different layer type
"use client";

import type { ExpressionSpecification } from "mapbox-gl";
import { useMemo, useState } from "react";

import { Button } from "@/components/ui/button";
import { Layer } from "@/components/ui/layer";
import { Map } from "@/components/ui/map";

type Kind = "circle" | "heatmap";

const stops: [number, number][] = [
  [-104.9903, 39.7392],
  [-104.9847, 39.7476],
  [-105.0008, 39.745],
  [-104.9781, 39.7405],
  [-104.9942, 39.7508],
  [-105.0104, 39.7362],
  [-104.9689, 39.7331],
  [-104.9876, 39.7269],
  [-105.0201, 39.7481],
  [-104.9617, 39.7524],
  [-104.9755, 39.7601],
  [-105.0053, 39.7215],
];

const heatColor: ExpressionSpecification = [
  "interpolate",
  ["linear"],
  ["heatmap-density"],
  0,
  "rgba(109,133,156,0)",
  0.4,
  "rgba(109,133,156,0.45)",
  1,
  "rgba(71,90,110,0.8)",
];

export function LayerExample() {
  const [kind, setKind] = useState<Kind>("circle");

  const points = useMemo(
    () => ({
      type: "FeatureCollection" as const,
      features: stops.map(([lng, lat], index) => ({
        type: "Feature" as const,
        properties: { weight: ((index * 7) % 10) / 10 + 0.2 },
        geometry: { type: "Point" as const, coordinates: [lng, lat] },
      })),
    }),
    [],
  );

  return (
    <div className="relative h-[420px] w-full">
      <Map center={[-104.99, 39.742]} zoom={12.2}>
        {kind === "circle" ? (
          <Layer
            id="stops"
            type="circle"
            data={points}
            paint={{
              "circle-radius": [
                "interpolate",
                ["linear"],
                ["zoom"],
                10,
                4,
                14,
                12,
              ],
              "circle-color": "#6d859c",
              "circle-opacity": 0.9,
              "circle-stroke-width": 1.5,
              "circle-stroke-color": "#ffffff",
            }}
          />
        ) : (
          <Layer
            id="stops"
            type="heatmap"
            data={points}
            paint={{
              "heatmap-weight": ["get", "weight"],
              "heatmap-radius": [
                "interpolate",
                ["linear"],
                ["zoom"],
                10,
                20,
                14,
                45,
              ],
              "heatmap-color": heatColor,
              "heatmap-opacity": 0.85,
            }}
          />
        )}
      </Map>

      <div className="bg-background/85 absolute top-3 left-3 z-10 flex gap-1 rounded-md border p-1 backdrop-blur">
        {(["circle", "heatmap"] as Kind[]).map((value) => (
          <Button
            key={value}
            size="sm"
            variant={kind === value ? "default" : "ghost"}
            className="capitalize"
            onClick={() => setKind(value)}
          >
            {value}
          </Button>
        ))}
      </div>

      <div className="bg-background/85 text-muted-foreground absolute right-3 bottom-3 z-10 rounded-md border px-2 py-1 text-[11px] backdrop-blur">
        Same source, different layer type
      </div>
    </div>
  );
}

Everything here is the plain Mapbox style spec, so anything you can express in a Mapbox paint or layout block works unchanged. The component only manages the lifecycle.

Sharing a Source

Layers are usually paired - a fill plus its outline. Point the second layer at the first with source instead of passing data twice:

<Layer id="zips" type="fill" data={zips} paint={{ "fill-color": "#60a5fa" }} />
<Layer id="zips-outline" type="line" source="zips" paint={{ "line-color": "#1e3a8a" }} />

A layer that names an existing source never creates or removes one, so the source outlives it.

Hover and Hit Layers

A hairline border is almost impossible to hit with a cursor. The usual fix is a transparent fill over the same source that exists purely to catch pointer events, with the visible outline drawn on top:

<Layer id="zips-hit" type="fill" data={zips}
  paint={{ "fill-color": "#000", "fill-opacity": 0 }} />
<Layer id="zips-border" type="line" source="zips-hit"
  paint={{ "line-color": "#3b82f6", "line-width": 0.7 }} />

A fully transparent fill is still rendered geometry, so Mapbox's layer-scoped events fire on it. Subscribe through useMap() and read the feature from the event:

const { map } = useMap();

useEffect(() => {
  if (!map) return;
  const move = (e) => setHovered(e.features?.[0]?.properties ?? null);
  map.on("mousemove", "zips-hit", move);
  return () => map.off("mousemove", "zips-hit", move);
}, [map]);

To highlight the hovered feature, point a third layer at the same source and drive its filter from state. filter is applied with setFilter, so this costs nothing per frame.

Updates

Changing data calls setData on the existing source rather than rebuilding the layer, so animations and transitions stay smooth. Pass a stable reference - wrap inline objects in useMemo so a re-render alone does not push data.

paint, layout and filter are compared by value and applied per property, so inline objects are safe there.

Style swaps are handled. map.setStyle() drops every user source and layer - which is what happens when the site theme changes. This component re-adds itself on each new style, so your data survives a light/dark toggle without any work on your side.

Props

Every other property of the Mapbox layer spec for the given type - filter, minzoom, maxzoom, slot - is passed straight through and typed per layer type.

PropTypeDefaultDescription
idstring—Unique layer id. Also names the source this layer creates.
type"fill" | "line" | "circle" | "symbol" | "heatmap" | "fill-extrusion" | ...—Any Mapbox layer type.
dataGeoJSON | string—GeoJSON object or a URL. The layer creates and owns a source for it.
sourcestring—Use an existing source instead of creating one. Takes precedence over data.
sourceOptionsobject—Extra source options, such as cluster, promoteId or generateId.
paintobject—Mapbox paint spec for the layer type. Applied per property on change.
layoutobject—Mapbox layout spec for the layer type.
beforeIdstring—Insert this layer below another. Ignored when that layer is absent.