Create a heatmap in Google Maps with data driven styling
Learn how to build an interactive heatmap in Google Maps with data driven styling and React.
In this post, I'll show you how to use data driven styling for boundaries to build a heatmap in Google Maps that shades San Francisco postal codes by median household income. We'll build on the map from the last section, layering household income data from the US Census Bureau onto Google's built-in postal code boundaries.

Part 1: Style a Google Map any way you want
Part 2: Apply styles for Google Maps using JSON style arrays
Part 3: Cloud based map styling for Google Maps
Part 4: Google Maps data driven styling for boundaries
Part 5: Create a heatmap in Google Maps with data driven styling (this article)
Part 6: Upload datasets to the Google Cloud Console using the Maps Datasets API
Part 7: Style Google Maps with your own data using data driven styling
Income inequality in San Francisco
San Francisco consistently ranks among the most unequal US cities, and it's unusual in being both very wealthy and highly unequal at once. In 2024 its median household income was about $139,800, second only to neighboring San Jose among large US cities. It was also one of the fastest rising, up 10.3% in a single year, the third largest jump among the 50 biggest cities.
That growth came even as Bay Area tech companies shed tens of thousands of jobs. The rising median isn't broad based prosperity. A small, extraordinarily well paid cohort pulled the top of the distribution upward while the broader job market contracted. The boom is concentrated among AI workers rather than the wider economy, which only deepens the city's inequality.
A table of incomes by postal code tells you inequality exists. A map shows you where it lives. In this tutorial, I'll show you how to use data driven styling for boundaries to color each San Francisco postal code by median household income, turning the city's economic divide into something that you can see. Hover and click events make the map interactive, inviting users to explore the data directly.
Google Maps heatmap worked example
This tutorial uses two files. App.jsx is the entry point. It uses @vis.gl/react-google-maps to display a Google map while SFMedianHHIncomeHeatmap.jsx contains all the heat map code. To follow along, paste the code snippets from the end of this post into a new React project and run it locally. Then experiment. Change the heatmap colors or swap in a different dataset, and watch how the map responds.
Choosing a good heatmap color scale
Selecting an appropriate color scheme for our heatmap can help users quickly interpret the data and patterns represented in the heat map. Best practice is to pick a sequential color scale that runs from light to dark along a single hue (or a smooth transition between two related hues). For this project, I've chosen the Color Brewer 9-class Purples palette. Others palettes work well too, but I like purple 😀.

Looking at the data when binned into intervals of $25,000, we see a peak at the $125 - $150k interval and gradual fall off on both sides. Choosing a heatmap color scheme is more art than science to be honest, but we'll go with this for now and see how it looks like on a map.
How to create a heatmap in Google Maps
These are the five steps to create a heatmap in Google Maps with data driven styling.
- Collect data. Luckily, US household income data is easy to come by. The Census Bureau's American Community Survey collects detailed demographic, social, economic, and housing data on the American population every year, and table B19013 covers median household income specifically. They even have an API! For this exercise, we'll use data from the 2024 5-year estimates.
Endpoint: GET
https://api.census.gov/data/2024/acs/acs5?get=NAME,B19013_001E&for=zip%20code%20tabulation%20area:94102,94103,94104,94105,94107,94108,94109,94110,94111,94112,94114,94115,94116,94117,94118,94121,94122,94123,94124,94127,94129,94130,94131,94132,94133,94134,94158We then map the postal codes to the median household income values like this:
const medianIncomeByZip = {
"94102": 55888,
"94103": 93143,
"94104": 42591,
"94105": 244662,
"94107": 164289,
"94108": 65392,
"94109": 104476,
"94110": 143938,
"94111": 135735,
"94112": 112795,
"94114": 169459,
"94115": 138023,
"94116": 134652,
"94117": 174419,
"94118": 139043,
"94121": 116970,
"94122": 130708,
"94123": 194098,
"94124": 66618,
"94127": 180768,
"94129": 218717,
"94130": 83077,
"94131": 181329,
"94132": 93995,
"94133": 71063,
"94134": 93068,
"94158": 161391
};- Define boundaries. Since we're using Google Maps data driven styling for boundaries, this part is easy. Find a list of San Francisco postal codes (OpenDataSF offers a CSV download), then call the Google Geocoding API to retrieve the
placeIdfor each one. We'll use thisplaceIdin the postal code layerFeatureStyleFunctionto automatically draw the postal code boundary on the map.
Endpoint: GET
https://maps.googleapis.com/maps/api/geocode/json?address=San Francisco, CA 94133&key={GOOGLE_MAPS_API_KEY}Response
{
"results" :
[
{
"address_components" :
[
//... address components
],
"formatted_address" : "San Francisco, CA 94133, USA",
"geometry" :
{
//... geometry data
},
"place_id" : "ChIJ61hhQeGAhYARo_x_aAlCar8",
"types" :
[
"postal_code"
]
}
],
"status" : "OK"
}The place_id in the response (ChIJ61hhQeGAhYARo_x_aAlCar8 in the above example) is what we are after.
- Map Place IDs to data. This one is data driven styling specific. Place IDs are the join key Google's boundary system is built on. With data driven styling, you don't need to supply your own polygon geometry. Google renders its boundary tiles and our code decides how each feature gets painted. For that to work, you need to use Google's representation of each region, the Place ID. From the Geocoding API call we have the mapping of postal codes to Place IDs:
const zipToPlaceId = {
"94102": "ChIJs88qnZmAhYARk8u-7t1Sc2g",
"94103": "ChIJ09mpM52AhYARm2WOMfyfxhs",
"94104": "ChIJD6M14YmAhYAR5WVbcn7uWPk",
"94105": "ChIJDXK6UmKAhYARfzuOY6DDgeM",
"94107": "ChIJg0__2jN-j4AR479OXNRG7O8",
"94108": "ChIJx5rJUYyAhYARxagLGBVGeFs",
"94109": "ChIJy0ilcOmAhYARCLOo6oZQNxk",
"94110": "ChIJjxQcAEF-j4ARegNFVBwq4vg",
"94111": "ChIJ49w0El-AhYAR9WVSnuOiWM8",
"94112": "ChIJ_wUjuoN-j4ARLYl5YXTu7iQ",
"94114": "ChIJ_dA2rgV-j4ARt3snzTZ80Ds",
"94115": "ChIJzyP3rbeAhYARTmAfNPJZzeY",
"94116": "ChIJddwdf4N9j4ARNjehk8mVCNA",
"94117": "ChIJ773z7quAhYARnGZ-5ZUIkcg",
"94118": "ChIJE8NGljiHhYARnSY8nSgLJkk",
"94121": "ChIJ-6VsK6aHhYARhH00Wvw4WGs",
"94122": "ChIJaZi-WHqHhYARP44d5B8lUhI",
"94123": "ChIJrYrvSdeAhYARtJlHJMWNQzY",
"94124": "ChIJe7_iURF_j4ARlf3H78H0YTk",
"94127": "ChIJK9xbjZR9j4ARBsVPOdGWHs8",
"94129": "ChIJ80PrL8OGhYARiuhtoI_sR0s",
"94130": "ChIJTyCM6zGAhYARWkaPBK60p6Q",
"94131": "ChIJ22y0JQt-j4ARCs3ThPYOWpc",
"94132": "ChIJSbCANq99j4ARVMl6KzrGk9c",
"94133": "ChIJ61hhQeGAhYARo_x_aAlCar8",
"94134": "ChIJJ3mtweZ-j4AR2LF94NUnNKI",
"94158": "ChIJTayi3tN_j4ARIwuQJy7-etE",
};What we need now is a way to use the postal code as a foreign key to join the placeId with the zipIncome (median income in a particular postal code). The code snippet below does exactly that by building a lookup table that can be used by our FeatureStyleFunction.
const incomeByPlaceId = Object.fromEntries(
Object.entries(zipToPlaceId).map(([zip, placeId]) => [placeId, medianIncomeByZip[zip]])
);Here's how it works:
Object.entries(zipToPlaceId) turns the object into an array of [zip, placeId] pairs: [["94102", "ChIJs88q…"], ["94103", "ChIJ09mp…"], …].
.map(([zip, placeId]) => [placeId, medianIncomeByZip[zip]]) transforms each pair: it destructures the ZIP and place ID, looks up that ZIP's income, and returns a new pair with the placeId as the key and the income as the value: [["ChIJs88q…", 55888], …]
Object.fromEntries(...) reassembles those pairs back into an object that looks like this:
{
"ChIJs88qnZmAhYARk8u-7t1Sc2g": 55888, // was 94102
"ChIJ09mpM52AhYARm2WOMfyfxhs": 93143, // was 94103
// ...
}- Map income to color. Next, we map each postal code's median income to a color. A color scale (
PURPLES) takes the income range (roughly $43k–$245k) as its input domain and outputs a fill color, so higher income postal codes render in darker shades of purple.
const BIN_THRESHOLDS = [50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000];
// 9 colors, 8 thresholds → under $50k = PURPLES[0]; $50–75k = PURPLES[1]; … $225k+ = PURPLES[8]
const incomeColorForPlaceId = (placeId) => {
const income = incomeByPlaceId[placeId];
if (income == null) return null;
const i = BIN_THRESHOLDS.findIndex((t) => income < t);
return PURPLES[i === -1 ? PURPLES.length - 1 : i];
};The incomeColorForPlaceId() method uses an explicit array of bin thresholds and a simple lookup to determine the color for a given postal code. The method relies on a singlefindIndex call: it walks the thresholds and returns the index of the first one the income falls below. A postal code at $42,591 falls below $50,000 and gets PURPLES[0]; a postal code at $244,662 doesn't fall below any threshold, so findIndex returns -1, and the ternary catches that case to clamp the result to the darkest shade.

Lower income postal codes get lighter shades; higher income ones get darker. The number of entries in PURPLES controls the granularity: more shades mean finer income distinctions on the map.
- Define the style function. Finally, in our
FeatureStyleFunction, we callincomeColorForPlaceIdand style the postal code boundary polygon accordingly.
const makeHeatmapStyle = (placeId) => ({
strokeColor: incomeColorForPlaceId(placeId),
strokeOpacity: 1,
strokeWeight: 1.5,
fillColor: incomeColorForPlaceId(placeId),
fillOpacity: 0.25,
});The end result is a choropleth of San Francisco built directly on Google Maps postal code boundaries. All 27 SF ZIP codes are shaded along a ten step purple ramp keyed to median household income.

The map is fully interactive (read my previous tutorial to learn how to add interactivity to your map). Hover over a postal code and its outline gets thicker (plus you get a pointer cursor so you know it's clickable). Click one and it darkens to show it's selected, and an info window pops up right where you clicked. The info window shows the postal code's five digit number (retrieved usingfetchPlace()), its Place ID, and the median household income. Click the same ZIP again or close the window, and everything goes back to as it was.
Deploy and run
The full source code for App.jsx and SFMedianHHIncomeHeatmap.jsx (which contains code for the heat map) is provided below. To see the heatmap in action, spin up a simple React app in Vite (follow these instructions from a previous tutorial) and add the App.jsx SFMedianHHIncomeHeatmap.jsx to the project. Run npm install to install the dependencies followed by npm run dev. Point your browser to http://localhost:5173 to open the app.
App.jsx
/*** App.jsx ***/
import {APIProvider} from '@vis.gl/react-google-maps';
import {Map} from '@vis.gl/react-google-maps';
import { useCallback } from 'react';
import SFMedianHHIncomeHeatmap from './SFMedianHHIncomeHeatmap';
function App() {
return (
<APIProvider
apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}
>
<Map
style={{ width: '100vw', height: '100vh' }}
defaultCenter={{ lat: 37.773972, lng: -122.431297 }}
defaultZoom={12}
mapTypeControl={false}
mapId={'6cbfee565110901d8c6f781f'}
>
<SFMedianHHIncomeHeatmap/>
</Map>
</APIProvider>
)
}
export default AppSFMedianHHIncomeHeatmap.jsx
/*** SFMedianHHIncomeHeatmap.jsx ***/
import { useEffect, useRef, useState } from "react";
import { useMap, InfoWindow } from "@vis.gl/react-google-maps";
const SF_ZIP_PLACEIDS = new Set([
"ChIJs88qnZmAhYARk8u-7t1Sc2g",
"ChIJ09mpM52AhYARm2WOMfyfxhs",
"ChIJD6M14YmAhYAR5WVbcn7uWPk",
"ChIJDXK6UmKAhYARfzuOY6DDgeM",
"ChIJg0__2jN-j4AR479OXNRG7O8",
"ChIJx5rJUYyAhYARxagLGBVGeFs",
"ChIJy0ilcOmAhYARCLOo6oZQNxk",
"ChIJjxQcAEF-j4ARegNFVBwq4vg",
"ChIJ49w0El-AhYAR9WVSnuOiWM8",
"ChIJ_wUjuoN-j4ARLYl5YXTu7iQ",
"ChIJ_dA2rgV-j4ARt3snzTZ80Ds",
"ChIJzyP3rbeAhYARTmAfNPJZzeY",
"ChIJddwdf4N9j4ARNjehk8mVCNA",
"ChIJ773z7quAhYARnGZ-5ZUIkcg",
"ChIJE8NGljiHhYARnSY8nSgLJkk",
"ChIJ-6VsK6aHhYARhH00Wvw4WGs",
"ChIJaZi-WHqHhYARP44d5B8lUhI",
"ChIJrYrvSdeAhYARtJlHJMWNQzY",
"ChIJe7_iURF_j4ARlf3H78H0YTk",
"ChIJK9xbjZR9j4ARBsVPOdGWHs8",
"ChIJ80PrL8OGhYARiuhtoI_sR0s",
"ChIJTyCM6zGAhYARWkaPBK60p6Q",
"ChIJ22y0JQt-j4ARCs3ThPYOWpc",
"ChIJSbCANq99j4ARVMl6KzrGk9c",
"ChIJ61hhQeGAhYARo_x_aAlCar8",
"ChIJJ3mtweZ-j4AR2LF94NUnNKI",
"ChIJTayi3tN_j4ARIwuQJy7-etE",
]);
const zipToPlaceId = {
"94102": "ChIJs88qnZmAhYARk8u-7t1Sc2g",
"94103": "ChIJ09mpM52AhYARm2WOMfyfxhs",
"94104": "ChIJD6M14YmAhYAR5WVbcn7uWPk",
"94105": "ChIJDXK6UmKAhYARfzuOY6DDgeM",
"94107": "ChIJg0__2jN-j4AR479OXNRG7O8",
"94108": "ChIJx5rJUYyAhYARxagLGBVGeFs",
"94109": "ChIJy0ilcOmAhYARCLOo6oZQNxk",
"94110": "ChIJjxQcAEF-j4ARegNFVBwq4vg",
"94111": "ChIJ49w0El-AhYAR9WVSnuOiWM8",
"94112": "ChIJ_wUjuoN-j4ARLYl5YXTu7iQ",
"94114": "ChIJ_dA2rgV-j4ARt3snzTZ80Ds",
"94115": "ChIJzyP3rbeAhYARTmAfNPJZzeY",
"94116": "ChIJddwdf4N9j4ARNjehk8mVCNA",
"94117": "ChIJ773z7quAhYARnGZ-5ZUIkcg",
"94118": "ChIJE8NGljiHhYARnSY8nSgLJkk",
"94121": "ChIJ-6VsK6aHhYARhH00Wvw4WGs",
"94122": "ChIJaZi-WHqHhYARP44d5B8lUhI",
"94123": "ChIJrYrvSdeAhYARtJlHJMWNQzY",
"94124": "ChIJe7_iURF_j4ARlf3H78H0YTk",
"94127": "ChIJK9xbjZR9j4ARBsVPOdGWHs8",
"94129": "ChIJ80PrL8OGhYARiuhtoI_sR0s",
"94130": "ChIJTyCM6zGAhYARWkaPBK60p6Q",
"94131": "ChIJ22y0JQt-j4ARCs3ThPYOWpc",
"94132": "ChIJSbCANq99j4ARVMl6KzrGk9c",
"94133": "ChIJ61hhQeGAhYARo_x_aAlCar8",
"94134": "ChIJJ3mtweZ-j4AR2LF94NUnNKI",
"94158": "ChIJTayi3tN_j4ARIwuQJy7-etE",
};
const medianIncomeByZip = {
"94102": 55888,
"94103": 93143,
"94104": 42591,
"94105": 244662,
"94107": 164289,
"94108": 65392,
"94109": 104476,
"94110": 143938,
"94111": 135735,
"94112": 112795,
"94114": 169459,
"94115": 138023,
"94116": 134652,
"94117": 174419,
"94118": 139043,
"94121": 116970,
"94122": 130708,
"94123": 194098,
"94124": 66618,
"94127": 180768,
"94129": 218717,
"94130": 83077,
"94131": 181329,
"94132": 93995,
"94133": 71063,
"94134": 93068,
"94158": 161391
};
const incomeByPlaceId = Object.fromEntries(
Object.entries(zipToPlaceId).map(([zip, placeId]) => [placeId, medianIncomeByZip[zip]])
);
const PURPLES = [
"#fcfbfd",
"#efedf5",
"#dadaeb",
"#bcbddc",
"#9e9ac8",
"#807dba",
"#6a51a3",
"#54278f",
"#3f007d",
];
const makeHeatmapStyle = (placeId) => ({
strokeColor: incomeColorForPlaceId(placeId),
strokeOpacity: 1,
strokeWeight: 1.5,
fillColor: incomeColorForPlaceId(placeId),
fillOpacity: 0.25,
});
const incomeColorForPlaceId = (placeId) => {
// 9 colors, 8 thresholds → under $50k = PURPLES[0]; $50–75k = PURPLES[1]; … $225k+ = PURPLES[8]
const BIN_THRESHOLDS = [50000, 75000, 100000, 125000, 150000, 175000, 200000, 225000];
const income = incomeByPlaceId[placeId];
if (income == null) return null;
const i = BIN_THRESHOLDS.findIndex((t) => income < t);
return PURPLES[i === -1 ? PURPLES.length - 1 : i];
};
const formatCurrency = (value) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
export default function SFMedianHHIncomeHeatmap() {
const map = useMap();
const hoveredIdRef = useRef(null);
const clickedIdRef = useRef(null);
const [selected, setSelected] = useState(null);
useEffect(() => {
if (!map) return;
const layer = map.getFeatureLayer("POSTAL_CODE");
if (!layer) {
console.warn(
"POSTAL_CODE feature layer not available. " +
"Check that your Map ID has the Postal Code layer enabled.",
);
return;
}
const applyStyles = () => {
layer.style = (params) => {
const placeId = params.feature.placeId;
if (!SF_ZIP_PLACEIDS.has(placeId)) return null;
const base = makeHeatmapStyle(placeId);
if (placeId === clickedIdRef.current) return { ...base, fillOpacity: 0.75 };
if (placeId === hoveredIdRef.current) return { ...base, strokeWeight: 2.5 };
return base;
};
};
applyStyles();
const moveListener = layer.addListener("mousemove", (e) => {
const placeId = e.features[0]?.placeId;
const next = SF_ZIP_PLACEIDS.has(placeId) ? placeId : null;
if (next !== hoveredIdRef.current) {
hoveredIdRef.current = next;
applyStyles();
map.getDiv().style.cursor = next ? "pointer" : "";
}
});
const mapMoveListener = map.addListener("mousemove", () => {
if (hoveredIdRef.current !== null) {
hoveredIdRef.current = null;
applyStyles();
map.getDiv().style.cursor = "";
}
});
const clickListener = layer.addListener("click", async (e) => {
const feature = e.features[0];
const placeId = feature?.placeId;
if (!SF_ZIP_PLACEIDS.has(placeId)) return;
const isDeselecting = placeId === clickedIdRef.current;
clickedIdRef.current = isDeselecting ? null : placeId;
applyStyles();
if (isDeselecting) {
setSelected(null);
return;
}
const position = { lat: e.latLng.lat(), lng: e.latLng.lng() };
setSelected({ placeId, position, place: null });
try {
const place = await feature.fetchPlace({
fields: ["displayName", "formattedAddress", "types"],
});
if (clickedIdRef.current !== placeId) return;
setSelected({ placeId, position, place });
} catch (err) {
console.error("fetchPlace failed:", err);
}
});
return () => {
layer.style = null;
moveListener.remove();
mapMoveListener.remove();
clickListener.remove();
map.getDiv().style.cursor = "";
};
}, [map]);
return selected ? (
<InfoWindow
position={selected.position}
onCloseClick={() => {
clickedIdRef.current = null;
setSelected(null);
}}
>
<div
style={{
fontFamily: "system-ui, sans-serif",
fontSize: 13,
lineHeight: 1.5,
minWidth: 300,
minHeight: 100,
}}
>
{selected.place ? (
<>
<div style={{ fontSize: 35, fontWeight: 600, marginBottom: 4 }}>
{selected.place.displayName ?? "Unknown"}
</div>
<div
style={{
marginTop: 6,
color: "#6b7280",
fontSize: 11,
}}
>
<strong>Place ID:</strong> {selected.placeId}
</div>
<div
style={{
marginTop: 6,
color: "#6b7280",
fontSize: 11,
}}
>
<strong>Median Income:</strong> {formatCurrency(incomeByPlaceId[selected.placeId])}
</div>
</>
) : (
<>
<div>
<strong>Place ID:</strong> {selected.placeId}
</div>
<div style={{ marginTop: 4, fontStyle: "italic", color: "#6b7280" }}>
Loading details…
</div>
</>
)}
</div>
</InfoWindow>
) : null;
}What's next in data driven styling
What makes using data driven styling for boundaries to create a heatmap in Google Maps special is that there's no GeoJSON anywhere. The polygons come from Google's own POSTAL_CODE feature layer, so the boundaries are always current and the entire visualization is a style function plus a 27 row lookup table mapping Place IDs to median household income figures. But what if you want to visualize data on boundaries Google doesn't have? That's where data driven styling for datasets comes in.
👋 As always, if you have any questions or suggestions for me, please reach out or say hello on LinkedIn.
Next: Part 6: Upload datasets to the Google Cloud Console using the Maps Datasets API (coming soon!)