A two-part card for showing people or places near the visitor. On the left is a street map drawn entirely in SVG, with blocks, roads, a river, a park and a dotted route. Teardrop pins with initials fall onto it one after another, squash slightly as they land and settle. On the right is a matching list: choosing a name highlights its pin, and choosing a pin highlights its row and updates the booking button. The drop replays every few seconds and from a button on the map. On phones the map sits on top at a fixed height and the list follows.
// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/map.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { Clock, RotateCw } from "lucide-react";
// `x` and `y` are percentages of the map area. `delay` staggers the drop.
const riders = [
{ name: "Marta Lindqvist", initials: "ML", eta: "4 min", color: "#0f766e", x: 22, y: 34, delay: 0.1 },
{ name: "Dev Raman", initials: "DR", eta: "6 min", color: "#b45309", x: 47, y: 58, delay: 0.3 },
{ name: "Aoife Brennan", initials: "AB", eta: "7 min", color: "#be123c", x: 70, y: 30, delay: 0.22 },
{ name: "Tomas Vela", initials: "TV", eta: "9 min", color: "#4338ca", x: 63, y: 72, delay: 0.46 },
{ name: "Noor Haddad", initials: "NH", eta: "12 min", color: "#0369a1", x: 33, y: 76, delay: 0.6 },
];
/** A made-up street map drawn in SVG: blocks, roads, a river and a park. */
function StreetMap() {
return (
<svg viewBox="0 0 600 420" preserveAspectRatio="xMidYMid slice" className="absolute inset-0 h-full w-full" aria-hidden="true">
<rect width="600" height="420" fill="#f1ece2" />
<g fill="#e6dfd1">
{[0, 1, 2, 3, 4, 5].map((c) => [0, 1, 2, 3].map((r) => <rect key={`${c}-${r}`} x={18 + c * 98} y={14 + r * 96} width="74" height="72" rx="6" />))}
</g>
<path d="M -20 300 C 120 250 180 360 320 330 S 520 250 640 290 V 440 H -20 Z" fill="#bfdbe8" />
<path d="M 380 40 h 150 a 16 16 0 0 1 16 16 v 90 a 16 16 0 0 1 -16 16 h -150 a 16 16 0 0 1 -16 -16 v -90 a 16 16 0 0 1 16 -16 z" fill="#cfe3c2" />
<circle cx="430" cy="92" r="16" fill="#b4d3a2" />
<circle cx="486" cy="120" r="22" fill="#b4d3a2" />
<g stroke="#fffdf8" strokeLinecap="round" fill="none">
<path d="M -10 196 H 610" strokeWidth="16" />
<path d="M 300 -10 V 430" strokeWidth="16" />
<path d="M -10 60 L 610 380" strokeWidth="12" />
<path d="M 104 -10 V 430 M 496 -10 V 430 M -10 100 H 610 M -10 292 H 610" strokeWidth="7" />
</g>
<path d="M -10 196 H 610" stroke="#e8c97a" strokeWidth="2" strokeDasharray="10 10" />
<path d="M 132 144 L 282 222 L 300 300 L 378 302" stroke="#0f766e" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" strokeDasharray="2 9" fill="none" />
</svg>
);
}
export default function MapCardWithAvatarPinsThatDropIn() {
const reduce = useReducedMotion();
const [round, setRound] = useState(0);
const [picked, setPicked] = useState(0);
// Replay the drop every few seconds so the preview keeps moving.
useEffect(() => {
if (reduce) return;
const id = setInterval(() => setRound((r) => r + 1), 7000);
return () => clearInterval(id);
}, [reduce]);
return (
<section className="bg-stone-900 px-4 py-16 sm:px-6 sm:py-24">
<div className="mx-auto grid max-w-5xl overflow-hidden rounded-3xl bg-white shadow-2xl shadow-black/40 lg:grid-cols-[1.25fr_1fr]">
<div className="relative h-[340px] overflow-hidden sm:h-[420px] lg:h-auto lg:min-h-[500px]">
<StreetMap />
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-white/70 to-transparent" aria-hidden="true" />
{/* Your own location, with a slow radar ring. */}
<div className="absolute left-[50%] top-[47%] -translate-x-1/2 -translate-y-1/2" aria-hidden="true">
<span className="absolute -inset-5 animate-ping rounded-full bg-teal-600/25 [animation-duration:2.4s] motion-reduce:animate-none" />
<span className="relative block h-4 w-4 rounded-full border-[3px] border-white bg-teal-700 shadow-md" />
</div>
<div key={round}>
{riders.map((r, i) => (
<motion.button
key={r.name}
type="button"
onClick={() => setPicked(i)}
aria-pressed={picked === i}
aria-label={`${r.name}, ${r.eta} away`}
initial={reduce ? false : { y: -46, scale: 0.6, opacity: 0 }}
animate={{ y: [null, 0, 5, 0], scale: [null, 1, 0.94, 1], opacity: 1 }}
transition={{ duration: 0.55, delay: r.delay, times: [0, 0.55, 0.78, 1], ease: "easeOut" }}
whileHover={{ y: -4 }}
style={{ left: `${r.x}%`, top: `${r.y}%`, zIndex: picked === i ? 20 : 10 }}
className="group absolute -ml-[22px] -mt-[52px] h-[52px] w-11 rounded-full focus:outline-none"
>
<svg viewBox="0 0 38 44" className="absolute inset-0 h-full w-full drop-shadow-[0_4px_3px_rgba(0,0,0,0.25)]" aria-hidden="true">
<path d="m28.9 25.7-9.9 9.9-9.9-9.9C3.63 20.23 3.63 11.37 9.1 5.9c5.47-5.47 14.33-5.47 19.8 0 5.47 5.47 5.47 14.33 0 19.8Z" fill="#fff" />
<ellipse cx="19" cy="41" rx="6" ry="2" fill="rgba(0,0,0,0.22)" />
</svg>
<span
className="absolute left-1/2 top-[5px] flex h-[30px] w-[30px] -translate-x-1/2 items-center justify-center rounded-full text-[11px] font-bold text-white ring-offset-2 transition-shadow group-focus-visible:ring-2 group-focus-visible:ring-stone-900"
style={{ backgroundColor: r.color, boxShadow: picked === i ? `0 0 0 3px ${r.color}55` : undefined }}
>
{r.initials}
</span>
</motion.button>
))}
</div>
<button
type="button"
onClick={() => setRound((r) => r + 1)}
className="absolute bottom-4 left-4 inline-flex h-10 items-center gap-2 rounded-full bg-white/95 px-4 text-sm font-semibold text-stone-800 shadow-md transition-colors hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-700"
>
<RotateCw className="h-4 w-4" aria-hidden="true" /> Drop pins again
</button>
</div>
<div className="p-6 sm:p-9">
<p className="text-sm font-semibold uppercase tracking-widest text-teal-700">Pedalpost couriers</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-stone-900">Five riders are within twelve minutes of you</h2>
<p className="mt-3 text-base leading-relaxed text-stone-600">Pick one to collect your parcel. Tap a pin or a name.</p>
<ul className="mt-6 space-y-1.5">
{riders.map((r, i) => (
<li key={r.name}>
<button
type="button"
onClick={() => setPicked(i)}
aria-pressed={picked === i}
className={`flex min-h-[52px] w-full items-center gap-3 rounded-xl px-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-700 ${picked === i ? "bg-teal-50 ring-1 ring-teal-700/30" : "hover:bg-stone-100"}`}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white" style={{ backgroundColor: r.color }} aria-hidden="true">
{r.initials}
</span>
<span className="flex-1 font-medium text-stone-900">{r.name}</span>
<span className="inline-flex items-center gap-1.5 text-sm tabular-nums text-stone-600">
<Clock className="h-3.5 w-3.5" aria-hidden="true" /> {r.eta}
</span>
</button>
</li>
))}
</ul>
<a href="#" className="mt-6 inline-flex h-12 w-full items-center justify-center rounded-xl bg-teal-700 px-6 text-sm font-semibold text-white transition-colors hover:bg-teal-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-700 focus-visible:ring-offset-2">
Book {riders[picked].name.split(" ")[0]} for a pickup
</a>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/map-card-with-avatar-pins-that-drop-in.json
No. It is an illustration made of SVG shapes, which is why it loads instantly and needs no key or tile server. For real geography, keep the pin and list code and place the pins over your map provider's element using its coordinate-to-pixel function.
Each pin is a framer-motion element with the same keyframes and its own `delay`. It starts above its spot, small and transparent, falls to position, dips five pixels past and returns, which reads as a soft landing.
The pins are wrapped in an element whose React `key` is a counter. Increasing the counter remounts them, so their entrance animation plays again. The timer and the button both just add one to it.
They can if two share similar coordinates. The selected pin is raised above the others with a higher `zIndex`, so the one someone picked is always fully visible.
Replace the coloured circle inside the pin with an `img` of the same 30px size and `rounded-full`. Keep the `aria-label` on the button, since the image alone does not name the person.
Adapted from Map in Eldora UI, used under its open-source licence. Licences and credits
Related: Concentric Ripple Rings Background with Floating Avatars, Spotlight Cards with Pointer Light, Animated Notification Feed with Looping Arrivals.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.