Créer une carte de postes interactive en React
Une carte de recrutement React place des boutons en position absolue à des coordonnées en pourcentage sur un conteneur stylisé, anime chaque pin à l'apparition avec whileInView de Framer Motion, et change le contenu de la sidebar entre localisations via AnimatePresence en mode='wait', de sorte que le panneau sortant disparaît avant que le nouveau apparaisse.
- Stack : React 18, Framer Motion 11, Lucide React, ~149 lignes, zéro dépendance supplémentaire.
- Les pins s'animent en séquence : chaque pin a un délai de 0,08s × son index, produisant un effet d'entrée en cascade.
- Accessible : chaque bouton-pin a un aria-label avec le nom de la ville et le nombre de postes ouverts ; le bouton de fermeture est aussi labellisé.
- Le layout utilise un CSS grid (1fr 320px), qui passe à une seule colonne sur les viewports étroits.
- Limitation mobile : la zone de carte est un simple conteneur en ratio 16/9 sans projection géographique réelle, les coordonnées des pins sont définies manuellement en pourcentage.
Careers Interactive Map transforme une liste de bureaux statique en une carte du monde à explorer. Les recruteurs et les candidats arrivent sur une grille visuelle, cliquent sur un pin de ville, et une sidebar glisse avec tous les postes ouverts à cet endroit. Ce pattern fonctionne pour toute entreprise ayant deux bureaux ou plus et remplace le traditionnel mur de filtres déroulants par une interface spatiale, lisible d'un coup d'oeil.
Anatomie
La section contient deux colonnes dans un CSS grid : une zone de carte à gauche et une sidebar à droite. La carte est un div en position relative avec un ratio 16/9 et une superposition subtile de grille de points créée avec un fond en linear-gradient CSS. Chaque pin de localisation est un bouton en position absolue, placé aux pourcentages left/top correspondant à la position de la ville sur l'image. À droite, la sidebar alterne entre un état vide (bordure en pointillés, icône Briefcase) et un panneau de liste de postes, selon qu'un pin est sélectionné ou non.
Comment ça marche
Trois primitives Framer Motion portent l'interaction. D'abord, le titre de section et le conteneur de carte utilisent chacun whileInView pour s'afficher en fondu et en glissement lors du scroll. Ensuite, les pins apparaissent en cascade : chaque motion.button démarre à opacity 0, scale 0, et s'anime avec un délai = 0,2 + index × 0,08 seconde. Enfin, le changement de contenu dans la sidebar utilise AnimatePresence en mode='wait'. Quand un pin est cliqué, le panneau actuel sort (opacity 0, y -10) avant que le nouveau monte (opacity 0, y 10 puis opacity 1, y 0). Cliquer de nouveau sur le même pin le désélectionne, ramenant la sidebar à son état vide.
Comment le coder en React
Définir la structure de données et initialiser l'état
Crée une interface Location avec city, country, des coordonnées x/y en pourcentage, un compteur openPositions et un tableau jobs. Déclare un unique useState<Location | null> pour suivre quel pin est actif. C'est le seul état nécessaire.
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);Construire le conteneur de carte avec superposition de grille
Utilise position:relative et aspectRatio:'16/9' sur le div de carte. À l'intérieur, rends un div superposé en position absolue avec un fond en linear-gradient CSS pour simuler une grille. Garde son opacity faible (environ 0,15) pour qu'il se lise comme une texture, pas comme du bruit.
<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>Afficher les pins en cascade comme des boutons absolus
Itère sur le tableau des localisations. Chaque pin est un motion.button avec scale:0 initial et whileInView scale:1, avec un délai de 0,2 + i * 0,08. Positionne-le avec les valeurs de pourcentage left et top issues des données. Au clic, sélectionne la localisation ou réinitialise la sélection si elle est déjà 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> ))}Changer la sidebar avec AnimatePresence mode='wait'
Enveloppe le contenu de la sidebar dans AnimatePresence avec mode='wait'. Rends soit le panneau de liste de postes (keyed par selected.city) soit le panneau d'état vide (keyed 'empty'). Chaque panneau entre depuis y:10 et sort vers y:-10. Comme mode='wait' oblige l'ancien panneau à finir de sortir avant que le nouveau entre, la transition ressemble à un échange propre plutôt qu'à un chevauchement.
<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>
Quand l'utiliser
Cette section convient aux entreprises avec des bureaux dans trois villes ou plus qui veulent une alternative spatiale à un tableau d'offres classique. Elle s'intègre bien sur une page recrutement dédiée, entre une section culture et une section avantages. À éviter pour les entreprises en un seul lieu (la carte n'apporte rien) ou celles avec plus de 30 pins (l'interface se surcharge rapidement). Sur mobile, la grille passe à une seule colonne ; la carte se réduit à une petite vignette, laissant la sidebar faire la majorité du travail. Si le trafic est majoritairement mobile, préfère une liste déroulante de villes.
Utilisé par
- Stripe, La page recrutement liste les bureaux mondiaux avec des filtres de localisation ; l'approche spatiale reflète la manière dont les équipes distribuées sont mises en avant.
- Shopify, Utilise un layout par localisation de bureau sur son site recrutement pour mettre en avant les postes à distance et sur site par région.
- Airbnb, Le hub recrutement organise les postes ouverts par localisation de bureau, parallèle direct au pattern pin-et-sidebar.
- Notion, Liste les équipes par ville sur sa page recrutement, faisant de la géographie des bureaux un axe de navigation principal.
FAQ
Comment définir les bonnes coordonnées x/y pour chaque pin ?
Le conteneur de carte est un div simple sans projection géographique réelle. Mesure la position en pixels de chaque ville sur ton image de fond, puis divise par la largeur et la hauteur de l'image pour obtenir des pourcentages. Méthode rapide : ouvre l'image dans n'importe quel éditeur, survole le point de la ville et note les coordonnées en pixels.
Peut-on utiliser une vraie carte SVG du monde à la place du fond quadrillé ?
Oui. Remplace le div de grille par un élément SVG ou img. La logique de positionnement des pins reste identique, les pourcentages doivent simplement correspondre aux positions réelles des villes sur la nouvelle image.
Pourquoi la sidebar utilise mode='wait' et non l'AnimatePresence par défaut ?
Le mode par défaut lance les animations de sortie et d'entrée simultanément. Avec deux panneaux qui s'échangent, cela produit un chevauchement où les deux sont brièvement visibles. mode='wait' les séquence : le panneau actuel finit sa sortie avant que le suivant commence son entrée, donnant une transition propre et sans ambiguïté.
Ce composant fonctionne-t-il sans JavaScript ?
Non, c'est un composant client ('use client') qui repose entièrement sur l'état React et Framer Motion. Sans JS, rien ne s'affiche. Pour l'amélioration progressive, pense à rendre la liste de postes côté serveur en fallback et à superposer la carte interactive par-dessus.