Dynamic Choropleths: Mastering Mapbox GL JS Expressions
Stop manually updating layer styles. Learn how to use Mapbox GL JS expressions to build high-performance, data-driven choropleth maps with GPU-accelerated interpolations.
30 Sept 2025, 07:10 UTC

The Problem: Static Styles in a Dynamic World
Building a choropleth map—where areas are shaded in proportion to a measurement—often leads developers to a frustrating choice: either hard-code a few static color buckets or manually loop through thousands of features to update styles via JavaScript. The former lacks precision, and the latter kills performance by forcing the map to re-render the entire layer every time a value changes.
The solution is Data-Driven Styling using Mapbox GL JS expressions. Instead of telling the map which color to use, you provide a functional rule that the GPU evaluates per-feature at render time. This allows for smooth color interpolations and instant interactive highlights without mutating your underlying data source.
How Expressions Work
Expressions are Lisp-like arrays that define a logic path for a style property. They replace the legacy "stops" syntax with a type-safe, functional approach. Because these are evaluated on the GPU, they can handle thousands of features while maintaining 60fps.
The most critical operator for choropleths is ['interpolate']. Unlike a ['match'] expression, which acts like a switch statement for discrete categories, ['interpolate'] creates a continuous gradient between defined stops. This is essential for representing numeric data like population density or temperature.
Key Expression Operators
['get', 'property_name']: Retrieves a value from the feature's properties.['interpolate', ['linear'], ...]: Maps an input range to an output range (e.g., 0-100 becomes Blue-Red).['feature-state', 'key']: Accesses a separate, volatile state object for high-frequency updates like hover effects.['number', value, fallback]: Coerces a value to a number, preventing the map from crashing if a property is null or a string.
Worked Example: Population Density Map
Imagine you have a vector tile source where each region has a density property. You want the map to transition from light yellow (low density) to deep red (high density), and highlight a region in blue when the user hovers over it.
Implementation: Run this within your map.addLayer() configuration. This assumes you are using Mapbox GL JS v2.x or v3.x.
map.addLayer({
id: 'region-fills',
type: 'fill',
source: 'regions-source',
paint: {
'fill-color': [
'case',
// 1. Check if the feature is currently hovered via feature-state
['boolean', ['feature-state', 'hover'], false],
'#0000FF', // Hover color: Blue
// 2. Otherwise, interpolate based on the 'density' property
['interpolate', ['linear'], ['get', 'density'],
0, '#ffffcc', // Low density: Light Yellow
500, '#ffeda0', // Mid density
1000, '#e31a1c' // High density: Deep Red
]
],
'fill-opacity': 0.7
}
});
To trigger the hover state, use map.setFeatureState. This is significantly faster than setData() because it doesn't re-process the geometry:
map.on('mousemove', 'region-fills', (e) => {
// Remove hover state from all features first
map.setFeatureState({
source: 'regions-source',
id: e.features[0].id
}, { hover: true });
});
Performance Trade-offs and Limitations
While expressions are powerful, they have strict boundaries. The most common pitfall is type coercion. If your density property is stored as a string in your tiles, the ['interpolate'] operator will fail silently or produce unexpected colors. Always wrap your getters in ['number', ['get', 'prop'], 0] if your data source is inconsistent.
Another limitation is the GPU evaluation scope. Expressions cannot access global JavaScript variables or properties from other layers. They can only see:
- The current feature's properties.
- The current zoom level (
['zoom']). - The feature-state object.
- Heatmap density (if using a heatmap layer).
For massive datasets (over 50,000 features), avoid deeply nested ['case'] expressions. Instead, precompute derived values (like ratios or percentages) using Tippecanoe during the tiling process. Computing a complex division ['/', ['get', 'a'], ['get', 'b']] for every pixel on every frame will eventually drop your frame rate.
Verification and Testing
To verify your expressions are working as intended without guessing, use the browser console to check the active style. You can filter your layers to find the specific paint property:
// Run in DevTools to verify the current interpolation logic
const layer = map.getStyle().layers.find(l => l.id === 'region-fills');
console.log(layer.paint['fill-color']);
If the map doesn't render the expected colors, check that your vector tiles have a stable id field. setFeatureState requires a numeric ID; without it, the hover logic will fail to map the state to the correct geometry.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.