How to build an interactive careers map in React
A React careers map places absolute-positioned buttons at percentage-based coordinates over a styled container, animates each pin in on scroll with Framer Motion whileInView, and swaps the sidebar content between locations using AnimatePresence with mode='wait', so the outgoing panel fades out before the new one fades in.
- Stack: React 18, Framer Motion 11, Lucide React, ~149 lines, no extra dependencies.
- Pins animate in sequentially: each one delays 0.08s × its index, producing a staggered entrance effect.
- Accessible: every pin button has an aria-label with city name and open position count; the close button is labelled.
- Layout uses a CSS grid (1fr 320px), which collapses to a single column on narrow viewports.
- Mobile caveat: the map area is a flat 16/9 aspect-ratio container, no real geo-projection, so pin coordinates are set manually as percentages.
Careers Interactive Map turns a static office list into an explorable world map. Recruiters and candidates land on a visual grid, click a city pin, and a sidebar slides in with every open role at that location. The pattern works at any company with two or more offices and replaces the usual wall of dropdown filters with a spatial, immediately legible interface.
Anatomy
The section has two columns in a CSS grid: a map area on the left and a sidebar on the right. The map is a relative-positioned div with a 16/9 aspect ratio and a subtle dot-grid overlay made with a CSS linear-gradient background. Each location pin is an absolutely positioned button, placed at left/top percentages matching the city's position on the image. To the right, the sidebar switches between an empty state (dashed border, Briefcase icon) and a job list panel, depending on whether a pin is selected.
How it works
Three Framer Motion primitives carry the interaction. First, the section heading and the map container each use whileInView to fade and slide in once on scroll. Second, pins appear with a staggered entrance: each motion.button starts at opacity 0, scale 0, and transitions in with delay = 0.2 + index × 0.08 seconds. Third, the sidebar content swap uses AnimatePresence with mode='wait'. When a pin is clicked, the current panel exits (opacity 0, y -10) before the incoming panel mounts (opacity 0, y 10 then opacity 1, y 0). Clicking the same pin again deselects it, reverting the sidebar to the empty state.
How to build it in React
Define the data shape and set up state
Create a Location interface with city, country, percentage-based x/y coordinates, openPositions count, and a jobs array. Declare a single useState<Location | null> to track which pin is active. This is the only piece of state needed.
interface Location { city: string; country: string; x: number; // 0-100, percent from left y: number; // 0-100, percent from top openPositions: number; jobs: { title: string; department: string; type: string }[]; } const [selected, setSelected] = useState<Location | null>(null);Build the map container with a dot-grid overlay
Use position:relative and aspectRatio:'16/9' on the map div. Inside it, render an absolutely-positioned overlay div with a CSS linear-gradient background to simulate a grid. Keep its opacity low (around 0.15) so it reads as texture, not noise.
<div style={{ position: "relative", aspectRatio: "16/9", borderRadius: "var(--radius-xl)", background: "var(--color-background-alt)", overflow: "hidden", }}> <div style={{ position: "absolute", inset: 0, opacity: 0.15, backgroundImage: "linear-gradient(var(--color-border) 1px, transparent 1px), linear-gradient(90deg, var(--color-border) 1px, transparent 1px)", backgroundSize: "40px 40px", }} /> {/* pins go here */} </div>Render staggered pins as absolute buttons
Map over the locations array. Each pin is a motion.button with initial scale:0 and whileInView scale:1, with a delay of 0.2 + i * 0.08. Position it using left and top percentage values from the data. On click, either select the location or clear selection if it is already active.
{locations.map((loc, i) => ( <motion.button key={loc.city} initial={{ opacity: 0, scale: 0 }} whileInView={{ opacity: 1, scale: 1 }} viewport={{ once: true }} transition={{ duration: 0.4, delay: 0.2 + i * 0.08 }} onClick={() => setSelected(selected?.city === loc.city ? null : loc)} style={{ position: "absolute", left: `${loc.x}%`, top: `${loc.y}%`, transform: "translate(-50%, -50%)" }} > <MapPin /> <span>{loc.city}</span> </motion.button> ))}Swap the sidebar with AnimatePresence mode='wait'
Wrap the sidebar content in AnimatePresence with mode='wait'. Render either the job list panel (keyed by selected.city) or the empty-state panel (keyed 'empty'). Each panel enters from y:10, exits to y:-10. Because mode='wait' forces the old panel to finish exiting before the new one enters, the transition reads as a clean swap rather than an overlap.
<AnimatePresence mode="wait"> {selected ? ( <motion.div key={selected.city} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.3 }} > {/* job list */} </motion.div> ) : ( <motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> {/* empty state */} </motion.div> )} </AnimatePresence>
When to use it
This section fits companies with offices in three or more cities who want a spatial alternative to a plain job board. It works well on a dedicated careers page, between a culture section and a perks section. Skip it for single-location companies (the map adds nothing) or for companies with more than 30 pins (the interface gets cluttered fast). On mobile, the grid collapses to a single column; the map shrinks to a small thumbnail, so the sidebar does most of the work. If traffic is predominantly mobile, consider a city-picker list instead.
Used by
- Stripe, Careers page lists global offices with location filters; the spatial approach mirrors how distributed teams are surfaced.
- Shopify, Uses an office-by-location layout on its careers site to surface remote and on-site roles by region.
- Airbnb, Careers hub organises open roles by office location, a direct parallel to the pin-and-sidebar pattern.
- Notion, Lists teams by city on its careers page, making office geography a primary navigation axis.
FAQ
How do I set the correct x/y coordinates for each pin?
The map container is a plain div with no real geo-projection. Measure the pixel position of each city on your background image, then divide by the image's width and height to get percentages. A quick way: open the image in any editor, hover over the city dot, and note the pixel coordinates.
Can I use a real SVG world map instead of the grid background?
Yes. Replace the grid overlay div with an SVG or an img element. The pin positioning logic stays the same, the percentages just need to match the new image's actual city positions.
Why does the sidebar use mode='wait' and not the default AnimatePresence?
The default mode runs exit and enter animations simultaneously. With two panels swapping, that produces an overlap where both panels are briefly visible. mode='wait' sequences them: the current panel finishes its exit before the next one starts its entrance, giving a clean, unambiguous transition.
Does this component work without JavaScript?
No, it is a client component ('use client') that relies entirely on React state and Framer Motion. Without JS, nothing renders. For progressive enhancement, consider rendering the job list as a server-side fallback and layering the interactive map on top.