How to build an interactive office location map in React
An interactive office map in React positions absolute-placed pins over an SVG grid background using percentage coordinates. Each pin pulses with a looping Framer Motion animation and shows a tooltip on hover via AnimatePresence. The list of locations below the map mirrors the same hover state so both panels stay in sync.
- Stack: React 18 + Framer Motion 11 + Tailwind v4 + lucide-react, ~117 lines total.
- Pin positions are defined as percentage x/y values, no mapping library required.
- Hover state is a single useState string (active location id) shared between the SVG layer and the card grid below.
- Accessible: keyboard focus is not wired, hover-only interaction; pair with a visible list for screen-reader users.
- Responsive via aspect-ratio 2/1; the pin coordinates scale automatically with the container.
About Interactive Map is a React about-page section that turns a list of office locations into a hoverable SVG map. Each city gets an animated pulse-dot placed by percentage coordinates; hovering reveals a card tooltip with city, country and a short description. A mirrored list below the map highlights the same location, so the user has two ways to explore. No mapping library needed, the whole thing is a positioned div with SVG grid lines.
Anatomy
The section has three stacked blocks. At the top, a centered header with an optional badge, title and subtitle fades in on scroll. Below it, two stat counters (employees, countries) are displayed side by side. The main block is a 2:1 aspect-ratio container with an SVG grid of 9x9 lines as a decorative background; each location pin is an absolutely positioned motion.div inside this container. Under the map, a responsive grid of city cards (2 columns on mobile, 5 on desktop) doubles as a hover target that shares state with the pins above.
How it works
Each pin is a motion.div with an `initial: { scale: 0 }` spring entrance (stiffness 200, damping not specified, delay staggered by 0.15s per pin). Inside every pin, a sibling div runs a looping `animate={{ scale: [1, 2.5, 1], opacity: [0.3, 0, 0.3] }}` with `repeat: Infinity` and a 2-second duration, this creates the radar-pulse effect. The tooltip is mounted with AnimatePresence, entering from `y: 5, scale: 0.95` and exiting back to the same state. The active pin id lives in a single `useState<string | null>` and is set on both `onMouseEnter` / `onMouseLeave` of the pin div and the card grid item, keeping both panels synchronized without any additional state management.
How to build it in React
Define locations as percentage coordinates
Give each location an `x` and `y` between 0 and 100 representing its position inside the map container. This keeps the map fully responsive, no fixed pixel coordinates. An `isHQ` boolean lets you size the headquarters pin larger and add a MapPin icon from lucide-react.
interface Location { id: string; city: string; country: string; description: string; x: number; y: number; isHQ?: boolean; }Render an SVG grid as a decorative background
Inside the map container, place an absolutely positioned SVG with `viewBox='0 0 100 60'` and `preserveAspectRatio='none'`. Draw vertical and horizontal lines at regular intervals using `var(--color-border)` for the stroke so it respects the active theme preset.
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 100 60" preserveAspectRatio="none"> {Array.from({ length: 10 }, (_, i) => ( <React.Fragment key={i}> <line x1={i*10+10} y1="0" x2={i*10+10} y2="60" stroke="var(--color-border)" strokeWidth="0.15" /> <line x1="0" y1={i*6+6} x2="100" y2={i*6+6} stroke="var(--color-border)" strokeWidth="0.15" /> </React.Fragment> ))} </svg>Animate the pins with spring entrance and pulse loop
Position each pin with `style={{ left: `${loc.x}%`, top: `${loc.y}%` }}` and `-translate-x-1/2 -translate-y-1/2`. Use `whileInView` for the spring entrance and a separate inner div with `animate={{ scale: [1, 2.5, 1] }}` and `repeat: Infinity` for the pulse ring. Stagger each pin's entrance with `delay: 0.3 + i * 0.15`.
<motion.div initial={{ opacity: 0, scale: 0 }} whileInView={{ opacity: 1, scale: 1 }} transition={{ delay: 0.3 + i * 0.15, type: "spring", stiffness: 200 }} onMouseEnter={() => setActiveLocation(loc.id)} onMouseLeave={() => setActiveLocation(null)} > <motion.div className="absolute inset-0 rounded-full" animate={{ scale: [1, 2.5, 1], opacity: [0.3, 0, 0.3] }} transition={{ duration: 2, repeat: Infinity }} /> </motion.div>Sync the card list with the map hover state
Render a grid of city cards below the map. Each card listens to `onMouseEnter` / `onMouseLeave` and writes to the same `activeLocation` state. Use a conditional border-color style to highlight the active card, no extra library, just a ternary on `borderColor`.
<div style={{ borderColor: activeLocation === loc.id ? "var(--color-accent)" : "var(--color-border)" }}>
When to use it
Use this section on company about pages, team pages or investor decks where showing a physical presence across multiple cities adds credibility. It works especially well for SaaS companies, agencies and scale-ups that want to communicate global reach without embedding a heavyweight map. Skip it if you only have one or two locations, a simple address line carries more weight there. On touch devices the hover tooltips do not fire; a static list of locations is always displayed below, so the core information remains accessible.
Used by
- Stripe, Displays global office locations with an interactive world map on its About page to reinforce its reach across dozens of countries.
- Intercom, Uses an office map section with city pins and employee counts to communicate its distributed team culture.
- Shopify, Features a global presence visualization on its About page, pairing pin locations with team size statistics.
- Figma, Highlights its office locations across the US, Europe and Asia with an interactive map as part of its company story.
FAQ
Do I need a maps API (Google Maps, Mapbox) for this?
No. The component uses a plain SVG grid as a decorative background and positions pins with CSS percentages. There is no real geographic projection, pins are placed manually by setting x/y values between 0 and 100 to match approximate world regions.
How do I add real geographic coordinates?
Convert latitude and longitude to percentage values: `x = (lng + 180) / 360 * 100` and `y = (90 - lat) / 180 * 100`. This gives an equirectangular projection that matches the rectangular container without any external library.
The tooltips get clipped at the edges. How do I fix it?
The map container has `overflow: hidden`. Change it to `overflow: visible` and add a transparent overlay to preserve the border radius visually, or clamp the tooltip's horizontal position so it never leaves the container bounds.
How do I make the pins keyboard-accessible?
Add `tabIndex={0}` to each pin div and handle `onFocus` / `onBlur` the same way as `onMouseEnter` / `onMouseLeave`. The tooltip will then appear on focus, which covers keyboard and screen-reader navigation without any aria changes.