Pin drop
import { useId, useState } from 'react';
// A rough world map, one character per dot (equirectangular, 24 by 10).
const MAP = [
'.......####...######....',
'..#######....#########..',
'..######....##########..',
'...#####...##########...',
'....###....########.#...',
'.....###....#####.......',
'.....####...####...##...',
'......###....##...####..',
'......##..........###...',
'.......#................',
];
const DOTS = MAP.flatMap((row, y) => [...row].flatMap((c, x) => (c === '#' ? [{ x, y }] : [])));
const CITIES = [
{ name: 'Lisbon', country: 'Portugal', x: 11, y: 3 },
{ name: 'Oslo', country: 'Norway', x: 13, y: 1 },
{ name: 'Cairo', country: 'Egypt', x: 14, y: 4 },
{ name: 'Tokyo', country: 'Japan', x: 21, y: 3 },
{ name: 'New York', country: 'United States', x: 6, y: 3 },
{ name: 'Lima', country: 'Peru', x: 7, y: 7 },
{ name: 'Sydney', country: 'Australia', x: 20, y: 8 },
{ name: 'Nairobi', country: 'Kenya', x: 14, y: 6 },
];
const STEP = 10;
const css = `
@keyframes field-pin-drop-fall { 0% { transform: translateY(-28px) scale(0.8); opacity: 0; } 60% { transform: translateY(2px) scale(1.06, 0.9); opacity: 1; } 100% { transform: none; opacity: 1; } }
@keyframes field-pin-drop-ring { from { transform: scale(0.3); opacity: 0.9; } to { transform: scale(2.6); opacity: 0; } }
.field-pin-drop { display: grid; gap: 10px; width: 250px; }
.field-pin-drop__map { position: relative; width: ${24 * STEP}px; height: ${10 * STEP}px; margin: 0 auto; }
.field-pin-drop__dot { position: absolute; width: 3px; height: 3px; margin: -1.5px; border-radius: 50%; background: light-dark(#cbced3, #393c41); }
.field-pin-drop__pin { position: absolute; width: 0; height: 0; }
.field-pin-drop__pin svg { position: absolute; left: -7px; bottom: 0; width: 14px; height: 20px; overflow: visible; filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45))); animation: field-pin-drop-fall 0.5s cubic-bezier(0.5, 0, 0.5, 1.4) both; }
.field-pin-drop__pin svg path { fill: light-dark(#373a40, #f1f2f3); }
.field-pin-drop__pin svg circle { fill: light-dark(#f3f4f7, #1a1c1f); }
.field-pin-drop__ring { position: absolute; left: -6px; top: -4px; width: 12px; height: 8px; border-radius: 50%; border: 1.5px solid light-dark(#42454c, #e5e6e9); animation: field-pin-drop-ring 0.9s ease-out 0.35s both; }
.field-pin-drop__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-pin-drop__box { display: flex; align-items: center; height: 40px; padding: 0 12px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-pin-drop__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2), 0 0 0 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent); }
.field-pin-drop__input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 14px; }
.field-pin-drop__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-pin-drop__where { color: light-dark(#5f626a, #a7abb2); font-size: 12px; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.field-pin-drop__pin svg, .field-pin-drop__ring { animation: none !important; }
.field-pin-drop__ring { display: none; }
}
`;
export function PinDropInput() {
const id = useId();
const [q, setQ] = useState('');
const t = q.trim().toLowerCase();
// A city counts once what you typed can only be that one.
const hits = t.length >= 2 ? CITIES.filter((c) => c.name.toLowerCase().startsWith(t)) : [];
const city = hits.length === 1 ? hits[0] : CITIES.find((c) => c.name.toLowerCase() === t);
return (
<>
<style>
{css}
</style>
<div className="field-pin-drop">
<div className="field-pin-drop__map" aria-hidden="true">
{DOTS.map((d) => (
<span key={`${d.x}-${d.y}`} className="field-pin-drop__dot" style={{ left: d.x * STEP + 5, top: d.y * STEP + 5 }} />
))}
{city && (
<span key={city.name} className="field-pin-drop__pin" style={{ left: city.x * STEP + 5, top: city.y * STEP + 5 }}>
<span className="field-pin-drop__ring" />
<svg viewBox="0 0 14 20">
<path d="M7 20C3 14 0 10.5 0 7a7 7 0 0 1 14 0c0 3.5-3 7-7 13Z" />
<circle cx="7" cy="7" r="2.6" />
</svg>
</span>
)}
</div>
<div>
<label className="field-pin-drop__label" htmlFor={id}>
Meet me in
</label>
<div className="field-pin-drop__box">
<input id={id} className="field-pin-drop__input" value={q} placeholder="Lisbon, Tokyo, Lima…" autoComplete="off" onChange={(e) => setQ(e.target.value)} />
<span className="field-pin-drop__where" aria-live="polite">
{city ? city.country : ''}
</span>
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldPinDrop(el) on the mounted element instead.
<div class="field-pin-drop">
<div class="field-pin-drop__map" aria-hidden="true">
<span class="field-pin-drop__dot" style="left:75px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:85px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:95px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:105px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:165px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:175px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:5px"></span>
<span class="field-pin-drop__dot" style="left:25px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:35px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:45px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:55px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:85px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:165px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:175px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:215px;top:15px"></span>
<span class="field-pin-drop__dot" style="left:25px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:35px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:45px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:55px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:125px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:165px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:175px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:215px;top:25px"></span>
<span class="field-pin-drop__dot" style="left:35px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:45px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:55px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:115px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:125px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:165px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:175px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:35px"></span>
<span class="field-pin-drop__dot" style="left:45px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:55px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:115px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:125px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:165px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:175px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:45px"></span>
<span class="field-pin-drop__dot" style="left:55px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:125px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:165px;top:55px"></span>
<span class="field-pin-drop__dot" style="left:55px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:85px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:125px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:155px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:65px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:85px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:135px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:145px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:215px;top:75px"></span>
<span class="field-pin-drop__dot" style="left:65px;top:85px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:85px"></span>
<span class="field-pin-drop__dot" style="left:185px;top:85px"></span>
<span class="field-pin-drop__dot" style="left:195px;top:85px"></span>
<span class="field-pin-drop__dot" style="left:205px;top:85px"></span>
<span class="field-pin-drop__dot" style="left:75px;top:95px"></span>
</div>
<div>
<label class="field-pin-drop__label" for="field-pin-drop">Meet me in</label>
<div class="field-pin-drop__box">
<input id="field-pin-drop" class="field-pin-drop__input" placeholder="Lisbon, Tokyo, Lima…" autoComplete="off" value=""/>
<span class="field-pin-drop__where" aria-live="polite"></span>
</div>
</div>
</div>@keyframes field-pin-drop-fall { 0% { transform: translateY(-28px) scale(0.8); opacity: 0; } 60% { transform: translateY(2px) scale(1.06, 0.9); opacity: 1; } 100% { transform: none; opacity: 1; } }
@keyframes field-pin-drop-ring { from { transform: scale(0.3); opacity: 0.9; } to { transform: scale(2.6); opacity: 0; } }
.field-pin-drop { display: grid; gap: 10px; width: 250px; }
.field-pin-drop__map { position: relative; width: 240px; height: 100px; margin: 0 auto; }
.field-pin-drop__dot { position: absolute; width: 3px; height: 3px; margin: -1.5px; border-radius: 50%; background: light-dark(#cbced3, #393c41); }
.field-pin-drop__pin { position: absolute; width: 0; height: 0; }
.field-pin-drop__pin svg { position: absolute; left: -7px; bottom: 0; width: 14px; height: 20px; overflow: visible; filter: drop-shadow(0 2px 2px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45))); animation: field-pin-drop-fall 0.5s cubic-bezier(0.5, 0, 0.5, 1.4) both; }
.field-pin-drop__pin svg path { fill: light-dark(#373a40, #f1f2f3); }
.field-pin-drop__pin svg circle { fill: light-dark(#f3f4f7, #1a1c1f); }
.field-pin-drop__ring { position: absolute; left: -6px; top: -4px; width: 12px; height: 8px; border-radius: 50%; border: 1.5px solid light-dark(#42454c, #e5e6e9); animation: field-pin-drop-ring 0.9s ease-out 0.35s both; }
.field-pin-drop__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-pin-drop__box { display: flex; align-items: center; height: 40px; padding: 0 12px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-pin-drop__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2), 0 0 0 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 10%, transparent); }
.field-pin-drop__input { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; font-size: 14px; }
.field-pin-drop__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-pin-drop__where { color: light-dark(#5f626a, #a7abb2); font-size: 12px; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.field-pin-drop__pin svg, .field-pin-drop__ring { animation: none !important; }
.field-pin-drop__ring { display: none; }
}function initFieldPinDrop(root) {
var STEP = 10;
var CITIES = [
{ name: 'Lisbon', country: 'Portugal', x: 11, y: 3 },
{ name: 'Oslo', country: 'Norway', x: 13, y: 1 },
{ name: 'Cairo', country: 'Egypt', x: 14, y: 4 },
{ name: 'Tokyo', country: 'Japan', x: 21, y: 3 },
{ name: 'New York', country: 'United States', x: 6, y: 3 },
{ name: 'Lima', country: 'Peru', x: 7, y: 7 },
{ name: 'Sydney', country: 'Australia', x: 20, y: 8 },
{ name: 'Nairobi', country: 'Kenya', x: 14, y: 6 },
];
var input = root.querySelector('.field-pin-drop__input');
var map = root.querySelector('.field-pin-drop__map');
var where = root.querySelector('.field-pin-drop__where');
var current = null;
function update() {
var t = input.value.trim().toLowerCase();
// A city counts once what you typed can only be that one.
var hits = t.length >= 2 ? CITIES.filter(function (c) { return c.name.toLowerCase().indexOf(t) === 0; }) : [];
var city = hits.length === 1 ? hits[0] : CITIES.find(function (c) { return c.name.toLowerCase() === t; }) || null;
where.textContent = city ? city.country : '';
if (city === current) return;
current = city;
var old = map.querySelector('.field-pin-drop__pin');
if (old) old.remove();
if (!city) return;
// A new pin each time, so it drops in and rings out from the start.
var pin = document.createElement('span');
pin.className = 'field-pin-drop__pin';
pin.style.left = city.x * STEP + 5 + 'px';
pin.style.top = city.y * STEP + 5 + 'px';
pin.innerHTML = '<span class="field-pin-drop__ring"></span><svg viewBox="0 0 14 20"><path d="M7 20C3 14 0 10.5 0 7a7 7 0 0 1 14 0c0 3.5-3 7-7 13Z"></path><circle cx="7" cy="7" r="2.6"></circle></svg>';
map.appendChild(pin);
}
input.addEventListener('input', update);
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.field-pin-drop').forEach(initFieldPinDrop);Goal: Create a "Pin drop" input.
Visual behavior: A meeting place field: type a city and once it can only be one place, a pin drops onto a dotted world map where it is, a ripple rings out and the country appears in the field.
Technique:
- The map is a grid of dots from a small character map; each city has a cell on it.
- What you typed is matched as a prefix against the cities, and a pin is placed when only one is left; a new pin each time, so it falls in with a squash and its ring ripples out from the start.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The matched country is aria-live="polite". Under prefers-reduced-motion the pin appears in place without falling or rippling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Pin drop" input.
Visual behavior: A meeting place field: type a city and once it can only be one place, a pin drops onto a dotted world map where it is, a ripple rings out and the country appears in the field.
Technique:
- The map is a grid of dots from a small character map; each city has a cell on it.
- What you typed is matched as a prefix against the cities, and a pin is placed when only one is left; a new pin each time, so it falls in with a squash and its ring ripples out from the start.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The matched country is aria-live="polite". Under prefers-reduced-motion the pin appears in place without falling or rippling.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A meeting place field: type a city and once it can only be one place, a pin drops onto a dotted world map where it is, a ripple rings out and the country appears in the field.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, state change, spring
- Techniques
- keyframes, state machine, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The matched country is aria-live="polite". Under prefers-reduced-motion the pin appears in place without falling or rippling.