Data-Driven Color Ramps with Interpolate Expressions

Use ["interpolate", ["linear"], ["get", "value"], stop, color, …] for a continuous color ramp and ["step", ["get", "value"], default, stop, color, …] for a classed choropleth — both bind directly to one numeric attribute in the tile and are evaluated per feature by the renderer, so the ramp changes with a style edit and never a tile rebuild.

When to use interpolate vs step vs match

The choice is driven entirely by how the source attribute is distributed and how you want the legend to read.

Expression Use when the attribute is Legend reads as Example
["interpolate", ["linear"], …] Continuous numeric with meaningful in-between values A smooth gradient bar Temperature, elevation, normalized density
["interpolate", ["exponential", b], …] Continuous but heavily skewed (a few very large values) A gradient weighted toward the low end Population density, income, traffic counts
["step", …] Continuous numeric you want to bucket into classes Discrete swatches with hard breaks Quantile choropleth, risk bands
["match", ["get", key], …] A categorical code or label, not a magnitude Discrete swatches, unordered Land-use class, zoning code, party

["interpolate"] and ["step"] both require a number as input. ["match"] is the correct operator for categorical strings and is covered in binding data-driven properties to vector layers; reaching for ["match"] on a continuous value forces you to enumerate every discrete value, which does not scale.

Specification detail

text
["interpolate", <interpolation>, <input>, <stop1>, <output1>, <stop2>, <output2>, …]
Element Values Notes
<interpolation> ["linear"] Constant rate between stops
["exponential", base] base > 1 biases growth toward higher stops; base < 1 toward lower. base = 1 is identical to linear
["cubic-bezier", x1, y1, x2, y2] Custom easing curve; rarely needed for color
<input> ["get", "value"] Must resolve to a number. Wrap in ["coalesce", …, sentinel] for null safety
<stopN> ascending numbers Must be strictly increasing literal numbers, not expressions
<outputN> CSS color / number For a color ramp these are colors; MapLibre interpolates in the color space of the values

["step"] has a slightly different shape — the value before the first stop is the default, given up front:

text
["step", <input>, <default_output>, <stop1>, <output1>, <stop2>, <output2>, …]

Values below the first stop take the default; each stop is a lower bound (inclusive) for its output. ["interpolate"] instead clamps: inputs below the first stop get the first output, inputs above the last stop get the last output. There is no fallback slot in either operator, so a null input does not hit a default — it produces null and the feature renders with the layer’s own default paint. That is why the fallback belongs on the input via ["coalesce"], not as a trailing argument. All operators here are supported in MapLibre GL JS 1.0+ and Mapbox GL JS 0.41+.

Production layer definition

The block below is a complete fill layer with a five-stop exponential ramp bound to a pop_density attribute (people per km²), plus a commented step variant for a classed choropleth. Paste it into the layers array of a MapLibre GL JSON style document.

json
{
  "id": "density-choropleth",
  "type": "fill",
  "source": "census",
  "source-layer": "districts",
  "paint": {
    "fill-color": [
      "interpolate",
      ["exponential", 1.4],
      ["coalesce", ["get", "pop_density"], -1],
      -1,    "#d9d9d9",
      0,     "#fef0d9",
      1000,  "#fdcc8a",
      4000,  "#fc8d59",
      12000, "#e34a33",
      30000, "#b30000"
    ],
    "fill-opacity": 0.85,
    "fill-outline-color": "#ffffff"
  }
}

The -1 stop is a dedicated sentinel: the ["coalesce"] converts any missing or null pop_density into -1, which maps to a neutral grey so no-data districts read as “no data” rather than as the lowest real class. Because the ramp is exponential with base 1.4, most of the color range is spent on the dense low-to-mid values where the data actually clusters, instead of being flattened by a handful of very dense districts.

For a classed choropleth with hard breaks (for example, matching a printed legend), swap fill-color for a ["step"]:

json
"fill-color": [
  "step",
  ["coalesce", ["get", "pop_density"], -1],
  "#d9d9d9",
  0,     "#fef0d9",
  1000,  "#fdcc8a",
  4000,  "#fc8d59",
  12000, "#e34a33",
  30000, "#b30000"
]

Same breakpoints, same colors, but every district within a band is a single flat swatch — no blending across the break. The default #d9d9d9 (given right after the input) now catches the -1 sentinel because -1 falls below the first stop of 0.

Interaction effects

The attribute must exist and be numeric in the tile schema. A ramp is only as reliable as the encoding step feeding it. If pop_density was dropped during tiling, every feature falls through to ["coalesce"]'s sentinel and the whole layer renders grey. Whitelist the key with Tippecanoe’s -y pop_density and force its type with -T pop_density:float, following the attribute filtering rules that govern which properties survive encoding. This is the same retention discipline described in binding data-driven properties to vector layers, applied to a single numeric ramp input.

Precompute the numeric code upstream. If your ramp input is derived — a per-district quantile class, a normalized index, a log of a raw count — compute it once in the pipeline and store it as a plain numeric column rather than reconstructing it in the expression. A ["step"] over a precomputed density_class integer (0–5) is far cheaper to evaluate and easier to keep synchronized with your legend than an ["interpolate"] that recomputes classification on every feature.

Renaming the source layer breaks the binding silently. The ramp reads source-layer: "districts"; if the tile build renames that layer, the expression still parses but paints nothing. Keep the style and tile schema aligned through the same discipline used when syncing MapLibre filters with renamed source layers, and treat the layer name as a contract in the broader layer filter synchronization workflow.

Feature-state for interactive ramps. When the ramp value comes from a runtime join (a fetched metric, a user-selected year) rather than the baked tile attribute, swap ["get", "pop_density"] for ["coalesce", ["feature-state", "metric"], ["get", "pop_density"], -1]. The renderer re-evaluates the ramp when you call map.setFeatureState(...), giving live recoloring with no tile round-trip.

Performance impact

Factor Effect Guideline
Interpolation type ["exponential"] costs the same as ["linear"] at draw time Choose by data shape, not speed
Number of stops Each stop is a comparison; cost is roughly linear in stop count 4–7 stops covers almost every legend; past ~12 the legend is unreadable before it is slow
Input type Numeric ["get"] is a direct dictionary read; a string input that must be coerced is slower and often fails Encode the ramp attribute as a number in the tile
["coalesce"] wrapper One extra null check per feature; negligible Always keep it — the correctness gain dwarfs the cost
Recolor trigger A ramp edit is style-only Bump the style version, never the tile URL, when only colors or breakpoints change

Keeping stops few and the input numeric is the whole performance story here. The expensive mistake is not the ramp — it is a non-numeric input that forces per-feature coercion or silently defeats the ramp entirely.

Common mistakes

1. String value where a number is needed — the ramp collapses to one color

Symptom: Every feature renders the same color (usually the first stop), with no gradient. The console may show Expected value to be of type number, but found string.

Cause: pop_density is encoded as a quoted string ("4200") instead of a number, so ["interpolate"] cannot order it against the numeric stops.

Fix: Coerce at encoding time and verify:

bash
tippecanoe -o census.mbtiles -y pop_density -T pop_density:float districts.geojson
tippecanoe-decode census.mbtiles 8 72 96 | \
  jq '.features[0].properties.pop_density | type'   # must print "number"

If you cannot re-tile immediately, coerce in the expression with ["to-number", ["get", "pop_density"]] as a stopgap — but fix the encoding, because ["to-number"] returns 0 for unparseable strings and quietly biases the low end of the ramp.

2. Missing fallback — null features vanish into the base paint

Symptom: Districts with no data render with the layer default (often transparent or black), not a “no-data” color, leaving holes in the choropleth.

Cause: ["interpolate"] and ["step"] have no fallback slot; a null input yields null, and MapLibre falls back to the property’s own default.

Fix: Wrap the input in ["coalesce", ["get", "pop_density"], -1] and add a stop (interpolate) or rely on the default output (step) for the -1 sentinel, exactly as in the production block above.

3. Too many stops — an unreadable legend and brittle breakpoints

Symptom: A 15-stop ramp that is impossible to map to a legend and shifts unpredictably when the data range changes.

Cause: Treating ["interpolate"] like a lookup table instead of a ramp.

Fix: Reduce to 5–6 perceptually spaced stops, or move to ["step"] with quantile breaks computed once upstream. Fewer, well-chosen breakpoints read better and survive data updates without a full re-tune.


Parent: Dynamic Attribute Mapping