Cómo crear un mapa interactivo de empleo en React
Un mapa de empleo en React coloca botones en posición absoluta en coordenadas basadas en porcentajes sobre un contenedor estilizado, anima cada pin al hacer scroll con whileInView de Framer Motion, e intercambia el contenido de la sidebar entre ubicaciones mediante AnimatePresence con mode='wait', de modo que el panel saliente se desvanece antes de que aparezca el nuevo.
- Stack: React 18, Framer Motion 11, Lucide React, ~149 líneas, sin dependencias adicionales.
- Los pines se animan de forma secuencial: cada uno se retrasa 0,08s × su índice, produciendo un efecto de entrada escalonada.
- Accesible: cada botón-pin tiene un aria-label con el nombre de la ciudad y el número de puestos abiertos; el botón de cierre también está etiquetado.
- El layout usa un CSS grid (1fr 320px), que se reduce a una sola columna en viewports estrechos.
- Salvedad en móvil: el área del mapa es un contenedor plano con ratio 16/9, sin proyección geográfica real, por lo que las coordenadas de los pines se definen manualmente como porcentajes.
Careers Interactive Map convierte una lista estática de oficinas en un mapamundi explorable. Reclutadores y candidatos llegan a una cuadrícula visual, hacen clic en el pin de una ciudad y una sidebar se despliega con todos los puestos abiertos en esa ubicación. El patrón funciona en cualquier empresa con dos o más oficinas y sustituye el habitual muro de filtros desplegables por una interfaz espacial, legible de un vistazo.
Anatomía
La sección tiene dos columnas en un CSS grid: un área de mapa a la izquierda y una sidebar a la derecha. El mapa es un div en posición relativa con un ratio 16/9 y una sutil superposición de cuadrícula de puntos creada con un fondo en linear-gradient CSS. Cada pin de ubicación es un botón en posición absoluta, colocado en porcentajes left/top que coinciden con la posición de la ciudad en la imagen. A la derecha, la sidebar alterna entre un estado vacío (borde discontinuo, icono Briefcase) y un panel de lista de puestos, según si hay un pin seleccionado o no.
Cómo funciona
Tres primitivas de Framer Motion sostienen la interacción. Primero, el título de la sección y el contenedor del mapa usan whileInView para aparecer en fundido y deslizamiento al hacer scroll. Después, los pines aparecen de forma escalonada: cada motion.button parte de opacity 0, scale 0, y entra con un delay = 0,2 + índice × 0,08 segundos. Por último, el cambio de contenido de la sidebar usa AnimatePresence con mode='wait'. Cuando se hace clic en un pin, el panel actual sale (opacity 0, y -10) antes de que el nuevo se monte (opacity 0, y 10 y luego opacity 1, y 0). Volver a hacer clic en el mismo pin lo deselecciona, devolviendo la sidebar a su estado vacío.
Cómo crearlo en React
Definir la estructura de datos y configurar el estado
Crea una interface Location con city, country, coordenadas x/y basadas en porcentajes, un contador openPositions y un array jobs. Declara un único useState<Location | null> para rastrear qué pin está activo. Es el único estado necesario.
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);Construir el contenedor del mapa con una superposición de cuadrícula de puntos
Usa position:relative y aspectRatio:'16/9' en el div del mapa. Dentro, renderiza un div superpuesto en posición absoluta con un fondo en linear-gradient CSS para simular una cuadrícula. Mantén su opacity baja (alrededor de 0,15) para que se lea como una textura, no como ruido.
<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>Renderizar los pines escalonados como botones absolutos
Itera sobre el array de ubicaciones. Cada pin es un motion.button con scale:0 inicial y whileInView scale:1, con un delay de 0,2 + i * 0,08. Posiciónalo con los valores de porcentaje left y top procedentes de los datos. Al hacer clic, selecciona la ubicación o limpia la selección si ya está activa.
{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> ))}Intercambiar la sidebar con AnimatePresence mode='wait'
Envuelve el contenido de la sidebar en AnimatePresence con mode='wait'. Renderiza el panel de lista de puestos (con key selected.city) o el panel de estado vacío (con key 'empty'). Cada panel entra desde y:10 y sale hacia y:-10. Como mode='wait' obliga al panel antiguo a terminar de salir antes de que entre el nuevo, la transición se lee como un intercambio limpio en lugar de un solapamiento.
<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>
Cuándo usarlo
Esta sección encaja en empresas con oficinas en tres o más ciudades que quieren una alternativa espacial a un tablón de empleo corriente. Funciona bien en una página de empleo dedicada, entre una sección de cultura y una de beneficios. Evítala en empresas de una sola ubicación (el mapa no aporta nada) o en empresas con más de 30 pines (la interfaz se satura rápido). En móvil, la cuadrícula se reduce a una sola columna; el mapa se encoge a una pequeña miniatura, por lo que la sidebar hace la mayor parte del trabajo. Si el tráfico es mayoritariamente móvil, considera mejor una lista selectora de ciudades.
Usado por
- Stripe, La página de empleo lista las oficinas globales con filtros de ubicación; el enfoque espacial refleja cómo se presentan los equipos distribuidos.
- Shopify, Usa un layout por ubicación de oficina en su sitio de empleo para presentar puestos remotos y presenciales por región.
- Airbnb, El hub de empleo organiza los puestos abiertos por ubicación de oficina, un paralelo directo al patrón pin-y-sidebar.
- Notion, Lista los equipos por ciudad en su página de empleo, haciendo de la geografía de las oficinas un eje principal de navegación.
FAQ
¿Cómo defino las coordenadas x/y correctas para cada pin?
El contenedor del mapa es un div simple sin proyección geográfica real. Mide la posición en píxeles de cada ciudad sobre tu imagen de fondo y divídela por el ancho y el alto de la imagen para obtener porcentajes. Un método rápido: abre la imagen en cualquier editor, pasa el cursor sobre el punto de la ciudad y anota las coordenadas en píxeles.
¿Puedo usar un mapamundi SVG real en lugar del fondo de cuadrícula?
Sí. Sustituye el div de cuadrícula por un elemento SVG o img. La lógica de posicionamiento de los pines no cambia, los porcentajes solo deben coincidir con las posiciones reales de las ciudades en la nueva imagen.
¿Por qué la sidebar usa mode='wait' y no el AnimatePresence por defecto?
El modo por defecto ejecuta las animaciones de salida y de entrada de forma simultánea. Con dos paneles intercambiándose, eso produce un solapamiento en el que ambos son brevemente visibles. mode='wait' los secuencia: el panel actual termina su salida antes de que el siguiente comience su entrada, dando una transición limpia y sin ambigüedad.
¿Funciona este componente sin JavaScript?
No, es un componente cliente ('use client') que depende por completo del estado de React y de Framer Motion. Sin JS, no se renderiza nada. Para una mejora progresiva, considera renderizar la lista de puestos como fallback en el servidor y superponer el mapa interactivo encima.