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 the @vis.gl/react-google-maps component library to display a map. SFMedianHHIncomeHeatmap.jsx contains all the heat map code. The best way to follow along is to clone the data_driven_styling_heatmap repository (coming soon!), run the project, and read through the code snippets below to understand what each section does.
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 incomeColorForPlaceId = (placeId) => {
const incomes = Object.values(medianIncomeByZip);
const minIncome = Math.min(...incomes); // $42,591
const maxIncome = Math.max(...incomes); // $244,662
const zipIncome = incomeByPlaceId[placeId];
const t = Math.max(0, Math.min(1, (zipIncome - minIncome) / (maxIncome - minIncome)));
const i = Math.round(t * (PURPLES.length - 1));
return PURPLES[i];
};The incomeColorForPlaceId() function maps each postal code's median income to one of the shades in PURPLES. First, it normalizes the income to a value t between 0 and 1, where 0 is the city's lowest median income ($42,591) and 1 is its highest ($244,662). It then multiplies t by the number of colors in PURPLES and rounds down to get an index, i. Finally, it uses i to select the matching shade from the array.

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) can be found in the data_driven_styling_heatmap (coming soon!) GitHub repository. To see the heatmap in action, fork and clone the repository and run npm install to install the dependencies followed by npm run dev. Point your browser to http://localhost:5173 to run the app.
What's next in data driven styling
What makes using data driven styling 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 supply? That's where data-driven styling for datasets comes in.
Next: Part 6: Upload datasets to the Google Cloud Console using the Maps Datasets API (coming soon!)