9 Map UI Design Patterns — Click-to-Drop Pickers
Map UI design is the layer that turns "where should this go?" into one point a person can hand back to you, and these nine click-to-drop pickers do it
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Click-to-drop pin
- 02 Count-badge cluster burst
- 03 Two-way card-and-pin highlight
- 04 Radius-circle distance slider
- 05 Address autocomplete dropdown
- 06 Current-location crosshair scan
- 07 Self-drawing route line
- 08 Favorite pins list
- 09 Press-to-expand mini map
The order is not popularity; it is the path someone walks while settling on a spot. The first three put a point on the board: tap the map for one (01), split a stack of overlapping markers when there are too many (02), and tie the result list to those markers so the eye can move between them (03). The next three narrow what counts: cut by distance (04), jump somewhere by typing an address (05), and anchor the search on the device itself (06). The last three happen after the choice is made: draw the route to it (07), keep it for next time (08), and glance at the whole city while the big map stays zoomed in (09). The board underneath all nine is built the same way — two crossed repeating-linear-gradient layers for roads, plus a park circle and a river band, painted once and never touched again while pins, rings, and cards move on top of it.
01Click-to-drop pin
The pin enters 62px above the point you clicked, falls, bounces once, and settles while its shadow spreads on impact and tightens again as it rebounds. Click coordinates are divided by the map box and clamped into a percentage, so the same tap resolves to the same building whether the frame is 480px or 320px wide. Replaying that fall on the same node needs a forced reflow between removing and adding the class, or the browser collapses both writes into one frame and nothing moves.
function drop(nx, ny) {
root.classList.remove('is-demo');
x = Math.max(8, Math.min(92, nx));
y = Math.max(14, Math.min(90, ny));
parts.forEach(function (el) {
el.style.left = x.toFixed(1) + '%';
el.style.top = y.toFixed(1) + '%';
el.classList.remove('is-drop');
void el.offsetWidth; // forcing a reflow is what lets the same animation run again
el.classList.add('is-drop');
});
val.textContent = names[Math.floor((x + y) / 38) % names.length];
coord.textContent = (37.52 + y / 400).toFixed(4) + ', ' + (127.03 + x / 400).toFixed(4);
}
02Count-badge cluster burst
A badge holding the count sits where the markers overlap, and pressing it fans six real pins out with their labels until you press it again to fold them back into one badge. Each pin differs only by two direction coefficients, --kx and --ky, while the radius pair — --rx at 92px and --ry at 40px — is shared by all six. Shrinking those two numbers is the entire responsive fix, because nothing else in the burst knows how wide the board is.
@keyframes cb-burst {
0% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
10% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
32% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
68% { opacity: 1; transform: translate(-50%, -100%) translate(calc(var(--kx) * var(--rx)), calc(var(--ky) * var(--ry))) scale(1); }
86%, 100% { opacity: 0; transform: translate(-50%, -100%) translate(0, 0) scale(.3); }
}
03Two-way card-and-pin highlight
Hovering or tabbing to a result card scales its marker to 1.42 and starts a ring spreading out from under it; pointing at the marker lights up the card border instead. The only thing joining the two halves is a matching data-id, so cards and pins can sit in any DOM order. The ring runs as an infinite animation rather than a transition because both of its ends are transparent, and a transition between two invisible states shows nothing at all.
.cl__pin.is-on, .cl__pin:hover { color: $color; transform: translate(-50%, -100%) scale(1.42); }
.cl__ring {
position: absolute;
left: 50%; top: 100%;
width: 34px; height: 34px;
border: 2px solid $color;
border-radius: 50%;
transform: translate(-50%, -50%) scale(.4);
opacity: 0;
pointer-events: none;
}
.cl__pin.is-on .cl__ring, .cl__pin:hover .cl__ring {
animation-name: cl-ring-pulse;
animation-duration: $dur-enter;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes cl-ring-pulse {
0% { opacity: .6; transform: translate(-50%, -50%) scale(.55); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.9); }
}
04Radius-circle distance slider
Dragging the slider grows the radius circle, walks the label up to 3.2km, and leaves only the pins inside it at full strength. That circle is a square built from aspect-ratio: 1 against the map height, and it changes size through scale(var(--k)) alone, so the board never reflows while the radius breathes. Membership is a plain comparison of each pin's data-dist against the raw slider value.
function rsPaint() {
var v = Number(rsRange.value);
var t = (v / 100 * RS_MAX_KM).toFixed(1) + 'km';
rsRing.style.setProperty('--k', v / 100);
rsKm.textContent = t;
rsRange.setAttribute('aria-valuetext', t);
rsPins.forEach(function (p) {
p.classList.toggle('is-in', Number(p.getAttribute('data-dist')) <= v);
});
}
05Address autocomplete dropdown
Typing opens four candidates that slide up into place above the field, the arrow keys walk them, and Enter actually moves the map pin to that address. Each option carries its destination as data-x and data-y percentages, so picking one costs two style writes and nothing more. Focus never leaves the input — aria-activedescendant is what tells a screen reader which row is current.
function acPick(i) {
var o = acOpts[i];
acOpts.forEach(function (x, k) { x.setAttribute('aria-selected', String(k === i)); });
acInput.value = o.lastElementChild.textContent;
acPin.style.left = o.getAttribute('data-x');
acPin.style.top = o.getAttribute('data-y');
acOpen(false);
acInput.focus({ preventScroll: true });
}
06Current-location crosshair scan
Three rings ripple outward under the crosshair for 1.4 seconds; then the scan locks and a location dot starts pulsing where it stopped. The stagger comes from negative delays of -667ms and -1334ms on the second and third rings, which start them mid-cycle instead of queuing them one after another. The shorthand is avoided on purpose here, since writing animation on one line would reset that delay back to zero.
.lc.is-demo .lc__ring,
.lc.is-scanning .lc__ring {
animation-name: lc-scan;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: 0ms;
}
.lc.is-demo .lc__ring--2,
.lc.is-scanning .lc__ring--2 { animation-delay: -667ms; }
.lc.is-demo .lc__ring--3,
.lc.is-scanning .lc__ring--3 { animation-delay: -1334ms; }
07Self-drawing route line
The line fills from the start pin to the destination flag like a brush stroke, and a travel-time badge pops out once it lands. Setting pathLength="100" on the path normalizes its length, so the numbers handed to stroke-dasharray and stroke-dashoffset are already progress percentages. Moving the route coordinates later changes nothing about the dash math.
.rd__line { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.rd__line--base { stroke: rgba(23, 20, 26, .13); stroke-width: 7; }
.rd__line--draw {
stroke: $color;
stroke-width: 5;
stroke-dasharray: 100; // pathLength="100" makes the dash number a percentage
stroke-dashoffset: 100;
}
// under user control the line is drawn with a transition instead of the loop
.rd:not(.is-demo) .rd__line--draw { transition: stroke-dashoffset 900ms $ease-out; }
.rd.is-drawn .rd__line--draw { stroke-dashoffset: 0; }
08Favorite pins list
Pressing a pin's heart flips aria-pressed, bounces the icon, and pushes that store into the chip row under the map. Chips are never removed from the DOM: they wait at max-width: 0 with padding and margin collapsed, and switching one on restores all of those values together. The row itself keeps gap: 0 for the same reason, since a flex gap would still reserve space around a chip folded down to nothing.
.fp__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 24px;
max-width: 0;
padding: 0;
margin: 0;
overflow: hidden;
white-space: nowrap;
opacity: 0;
transform: scale(.6);
transition: max-width $duration $easing, padding $duration $easing, margin $duration $easing,
opacity $duration $easing, transform $duration $easing;
}
.fp__chip.is-on {
max-width: 160px;
padding-left: $sp-2;
padding-right: $sp-2;
margin-right: $sp-2;
opacity: 1;
transform: scale(1);
}
09Press-to-expand mini map
A rectangle inside the corner map tracks the area currently in view, and pressing that mini map grows a larger overlay panel out of the same corner. transform-origin: 100% 100% anchors the growth to the bottom right, which keeps the panel from spilling past the edge of the board as it scales from .3 to 1. Escape folds it back and returns focus to the button.
.mm__panel {
position: absolute;
right: $sp-2;
bottom: $sp-2;
z-index: 1;
box-sizing: border-box;
width: 66%;
height: 78%;
padding: $sp-2;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
transform-origin: 100% 100%;
transform: scale(.3);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.mm.is-open .mm__panel { transform: scale(1); opacity: 1; pointer-events: auto; }
Where it breaks — the trap
Almost every coordinate in a map UI is a percentage, because "82% across, 38% down" has to mean the same doorway at any width. The trap is that feeding those percentages straight into left and top makes the browser recompute layout on every step, and once a pin is animating, that cost repeats every frame. The obvious swap fails in a less obvious way: a percentage inside transform: translate() resolves against the moving element itself rather than the board, so translate(82%) on a 20px pin shifts it by 16px and lands nowhere near the right place. What these nine do instead is store the coordinate in percent but never animate it — position writes happen only at the instant a person picks something, and everything that runs continuously (the fall in 01, the burst in 02, the scale in 03, the circle in 04, the overlay in 09) is expressed as transform and opacity.
The second wall is vertical room. Each demo is embedded at 480×300 and shrinks to 320×200 on a phone, which leaves 174px of height once the stage padding comes off. That is why every board's height is capped — clamp(104px, 34vw, 160px) in five of the nine, a tighter clamp or a fixed aspect-ratio in the rest; measured at 320px wide, the nine demos land between 127px and 155px, and widening the list spacing by another 4px is already enough to clip the bottom row. The password for the archive holding these nine SCSS files and their React ports is 7ajdcn4a, and it opens the copies that carry exactly these numbers. The third break came out of 02 itself: six pins fanned at one radius with single-line labels still collide at 480px, and they only become readable after the arc spreads across angles from 170 degrees down to 10 with the labels alternating over two rows.
Accessibility (reduced-motion)
All nine drop their idle loop under prefers-reduced-motion: reduce while keeping the state that loop was showing. The pin stands on its spot without falling, the radius circle resizes with no transition, the scan collapses to one static ring around the locked dot, and the route line sits fully drawn. Pointer input is never the only way in, either.
| Pattern | Pointer | Keyboard | Announced as |
|---|---|---|---|
| 01 Pin drop | click the board | arrows to move, Enter to re-drop | place name in role="status" |
| 03 Card and pin | hover | Tab, then arrows for the next entry | aria-pressed, role="status" |
| 04 Radius | drag the slider | arrows, Home, End | distance in aria-valuetext |
| 05 Address | click | up, down, Enter, Escape | aria-activedescendant |
| 06 Locate | press the button | Enter, Space | aria-live="polite" |
| 09 Mini map | click | Enter, Space, Escape | aria-expanded |
The mechanism behind 05, where a visual cursor walks a list while real focus stays in the text field, is specified on MDN's combobox role page. Both 05 and 09 hand focus back with focus({ preventScroll: true }), which matters because these boards sit in an iframe inside an article: without that option the reader's page yanks itself down to the demo the moment a choice is made. Other components that return a chosen value are gathered under commerce, and the ones that answer a press are under click.
FAQ
Can these run on a real map instead of the CSS board?
The pins, rings, cards and slider carry over unchanged. Every pattern treats the board as a single position: relative box with a background and reads its coordinates as percentages of that box. Swapping in a real map means replacing the background with the library's canvas and routing those percentages through its projection function, which is two places rather than nine.
When should markers start clustering into a count badge?
The trigger is distance on screen, not a total: cluster as soon as the gap between two markers drops below the width of the marker icon. That is why the same three pins cluster at one zoom level and separate at the next. The demo merges six because six is roughly where fanned labels stop being readable at 480px.
How does the radius slider decide what is inside the circle?
Each pin stores a value from 0 to 100 in data-dist and the code compares it against the raw slider position. A real service would compute the distance between the center point and each pin, then map the result onto that same 0 to 100 scale. Nothing downstream of the conversion has to change.