I was curious about making markers on Google Maps like Airbnb.
These markers pack much more functionalities than the default markers from Google Maps. Besides custom styling, each marker displays dynamic pricing and states. When you interact with them, you'll see custom React components rendered with data.
I find it very fun and useful so let's unpack it together!
We'll be building a proof of concept to create customer markers using any React component.
Let's go.
The demo is available on GitHub. Feel free to fork it🚀
I integrated the Google Maps API in a Next.js project by following the official tutorial. The tutorial explains in detail how to render Google Maps and markers in React applications.
There are a few more integrations included in the project:
The default marker from Google Maps looks like this:
Google Maps API offers an OverlayView class to place DOM elements on the map. We can use the OverlayView to place the container of a React Portal on the map and render a custom react component into the container.
Following the official tutorial to create a custom Overlay class, we'll implement the three basic methods:
Overlay.ts
export class Overlay extends google.maps.OverlayView {
container: HTMLElement
pane: keyof google.maps.MapPanes
position: google.maps.LatLng | google.maps.LatLngLiteral
constructor(
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) {
super()
this.container = container
this.pane = pane
this.position = position
}
onAdd(): void {
const pane = this.getPanes()?.[this.pane]
pane?.appendChild(this.container)
}
draw(): void {
const projection = this.getProjection()
const point = projection.fromLatLngToDivPixel(this.position)
if (point === null) {
return
}
this.container.style.transform = `translate(${point.x}px, ${point.y}px)`
}
onRemove(): void {
if (this.container.parentNode !== null) {
this.container.parentNode.removeChild(this.container)
}
}
}
Now that we have our custom Overlay class, we can create a React component to:
OverlayView.tsx
import { PropsWithChildren, useEffect, useMemo } from 'react'
import { createPortal } from 'react-dom'
import { Overlay } from './Overlay'
type OverlayProps = PropsWithChildren<{
position: google.maps.LatLng | google.maps.LatLngLiteral
pane?: keyof google.maps.MapPanes
map: google.maps.Map
zIndex?: number
}>
export default function OverlayView({
position,
pane = 'floatPane',
map,
zIndex,
children,
}: OverlayProps) {
const container = useMemo(() => {
const div = document.createElement('div')
div.style.position = 'absolute'
return div
}, [])
const overlay = useMemo(() => {
return new Overlay(container, pane, position)
}, [container, pane, position])
useEffect(() => {
overlay?.setMap(map)
return () => overlay?.setMap(null)
}, [map, overlay])
// to move the container to the foreground and background
useEffect(() => {
container.style.zIndex = `${zIndex}`
}, [zIndex, container])
return createPortal(children, container)
}
Let's create a marker using the OverlayView component and a button.
CustomMarker.tsx
import { useMemo } from "react";
import { Hotel } from "../../types/hotel";
import OverlayView from "../OverlayView";
interface CustomMarkerProps {
hotel: Hotel;
map?: google.maps.Map;
}
export default function CustomMarker({
hotel,
map,
}: CustomMarkerProps) {
const price = useMemo(() => {
return `$ ${hotel.ratesSummary.minPrice.replace(/\.(.*?\d*)/g, '')}`
}, [hotel])
return (
<>
{map && (
<OverlayView
position={{
lat: hotel.location.latitude as number,
lng: hotel.location.longitude as number,
}}
map={map}
styles={{
backgorundColor: 'DarkGray',
color: 'white',
}}
>
{/* use a button as the marker */}
<button onClick={handleClick}>{price}</button>
</OverlayView>
)}
</>
)
}
If you've started your dev server or tried to build the project, you might see a Reference Error saying "google is not defined":
This is because Webpack doesn't recognize "google" at build time. At build time, Google Maps JavaScript API is not yet loaded so Webpack is not able to evaluate the Overlay class at the module level. What we can do is to wrap the class in a factory function to resolve the build error.
Overlay.ts
export function createOverlay(
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) {
class Overlay extends google.maps.OverlayView {
container: HTMLElement
pane: keyof google.maps.MapPanes
position: google.maps.LatLng | google.maps.LatLngLiteral
constructor(
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) {
super()
this.container = container
this.pane = pane
this.position = position
}
onAdd(): void {
const pane = this.getPanes()?.[this.pane]
pane?.appendChild(this.container)
}
draw(): void {
const projection = this.getProjection()
const point = projection.fromLatLngToDivPixel(this.position)
if (point === null) {
return
}
this.container.style.transform = `translate(${point.x}px, ${point.y}px)`
}
onRemove(): void {
if (this.container.parentNode !== null) {
this.container.parentNode.removeChild(this.container)
}
}
}
return new Overlay(container, pane, position)
}
Let's refactor the OverlayView component accordingly:
OverlayView.tsx
- import { Overlay } from './Overlay'
+ import { createOverlay } from './Overlay'
export default function OverlayView({
position,
pane = 'floatPane',
map,
zIndex,
children,
}: OverlayProps) {
const overlay = useMemo(() => {
- return new Overlay(container, pane, position)
+ return createOverlay(container, pane, position)
}, [container, pane, position])
}
Now we should be able to see our map again and our custom markers without error!
Since our custom markers are React components, we can define the user experience and use the available tooling in the React ecosystem. Let's implement:
CustomMarker.tsx
import { useCallback, useMemo } from 'react'
import { Hotel } from '../../types/hotel'
import OverlayView from '../OverlayView'
import { motion } from 'framer-motion'
interface CustomMarkerProps {
hotel: Hotel
map?: google.maps.Map
onClick: (payload: Hotel) => void
highlight?: boolean
}
export default function CustomMarker({
hotel,
map,
onClick,
highlight,
}: CustomMarkerProps) {
const price = useMemo(() => {
return `$ ${hotel.ratesSummary.minPrice.replace(/\.(.*?\d*)/g, '')}`
}, [hotel])
const handleClick = useCallback(() => {
onClick(hotel)
}, [onClick, hotel])
return (
<>
{map && (
<OverlayView
position={{
lat: hotel.location.latitude as number,
lng: hotel.location.longitude as number,
}}
map={map}
// when users select it, move the marker to the foreground
zIndex={highlight ? 99 : 0}
>
{/* appearance transition */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
type: 'spring',
stiffness: 400,
damping: 20,
delay: Math.random() * 0.3,
}}
>
<button
onClick={handleClick}
// button state toggle
styles={{
backgorundColor: highlight ? 'white' : 'DarkGray',
color: highlight ? 'black' : 'white',
}}
>
{price}
</button>
</motion.div>
</OverlayView>
)}
</>
)
}
The animation looks like this:
The button state toggle and the card toggle are also in place:
It looks like a lot of code to create a custom marker but the mental model is actually straightforward:
This post is originally posted on Daw-Chih’s website.