Web InnoventixFreeCode

Map Location Picker

Original · free

location picker with pin

byWeb InnoventixReact + Tailwind
maplocationpickermaps
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/map-location-picker.json
map-location-picker.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";

type Pos = { x: number; y: number };
type Rect = { x: number; y: number; w: number; h: number; t: number };
type Place = { id: string; name: string; area: string; code: string; x: number; y: number };

const MAP_W = 1000;
const MAP_H = 625;

/* Geo frame: ~11.9 km east-west across the canvas, anchored on central Bengaluru. */
const LNG_MIN = 77.54;
const LNG_SPAN = 0.11;
const LAT_MAX = 12.9972;
const LAT_SPAN = 0.0672;
const M_PER_UNIT = 11.93;

const H_ROADS = [82, 196, 318, 442, 552];
const V_ROADS = [126, 268, 420, 574, 716, 862];
const MAJOR_H = 318;
const MAJOR_V = 420;

const H_LINES = [-20, ...H_ROADS, MAP_H + 20];
const V_LINES = [-20, ...V_ROADS, MAP_W + 20];

const PLACES: Place[] = [
  { id: "indiranagar", name: "100 Feet Road", area: "Indiranagar", code: "560038", x: 455, y: 244 },
  { id: "koramangala", name: "5th Block", area: "Koramangala", code: "560095", x: 620, y: 470 },
  { id: "hsr", name: "Sector 2", area: "HSR Layout", code: "560102", x: 826, y: 556 },
  { id: "church", name: "Church Street", area: "Ashok Nagar", code: "560001", x: 330, y: 318 },
  { id: "jayanagar", name: "4th Block", area: "Jayanagar", code: "560011", x: 306, y: 508 },
  { id: "whitefield", name: "ITPL Main Road", area: "Whitefield", code: "560066", x: 904, y: 150 },
  { id: "hebbal", name: "Hebbal Flyover", area: "Kempapura", code: "560024", x: 520, y: 70 },
  { id: "malleshwaram", name: "8th Cross", area: "Malleshwaram", code: "560003", x: 150, y: 150 },
];

const PARK = { x: 579, y: 201, w: 132, h: 112 };
const LAKE = { cx: 170, cy: 470, rx: 100, ry: 52 };

function makeRng(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

function edgeGap(line: number, major: number): number {
  return line === major ? 13 : 5;
}

function buildCity(): { blocks: Rect[]; buildings: Rect[] } {
  const rng = makeRng(20260717);
  const blocks: Rect[] = [];
  const buildings: Rect[] = [];
  for (let i = 0; i < V_LINES.length - 1; i++) {
    for (let j = 0; j < H_LINES.length - 1; j++) {
      const x0 = V_LINES[i] + edgeGap(V_LINES[i], MAJOR_V);
      const x1 = V_LINES[i + 1] - edgeGap(V_LINES[i + 1], MAJOR_V);
      const y0 = H_LINES[j] + edgeGap(H_LINES[j], MAJOR_H);
      const y1 = H_LINES[j + 1] - edgeGap(H_LINES[j + 1], MAJOR_H);
      blocks.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0, t: 0 });
      for (let bx = x0 + 7; bx + 30 < x1 - 5; bx += 38) {
        for (let by = y0 + 7; by + 26 < y1 - 5; by += 34) {
          if (rng() < 0.44) continue;
          buildings.push({
            x: bx,
            y: by,
            w: 16 + rng() * 14,
            h: 13 + rng() * 13,
            t: Math.floor(rng() * 3),
          });
        }
      }
    }
  }
  return { blocks, buildings };
}

const CITY = buildCity();

const BUILDING_TONES = [
  "fill-slate-300/80 dark:fill-zinc-800",
  "fill-slate-300/50 dark:fill-zinc-800/70",
  "fill-slate-400/45 dark:fill-zinc-700/50",
];

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function snapToRoad(p: Pos): Pos {
  let bestH = H_ROADS[0];
  let dh = Number.POSITIVE_INFINITY;
  for (const h of H_ROADS) {
    const d = Math.abs(p.y - h);
    if (d < dh) {
      dh = d;
      bestH = h;
    }
  }
  let bestV = V_ROADS[0];
  let dv = Number.POSITIVE_INFINITY;
  for (const v of V_ROADS) {
    const d = Math.abs(p.x - v);
    if (d < dv) {
      dv = d;
      bestV = v;
    }
  }
  return dh <= dv ? { x: p.x, y: bestH } : { x: bestV, y: p.y };
}

function nearest(p: Pos): { place: Place; dist: number } {
  let best = PLACES[0];
  let bestD = Number.POSITIVE_INFINITY;
  for (const pl of PLACES) {
    const d = Math.hypot(pl.x - p.x, pl.y - p.y);
    if (d < bestD) {
      bestD = d;
      best = pl;
    }
  }
  return { place: best, dist: bestD };
}

const COMPASS = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"];

function bearing(dx: number, dy: number): string {
  const deg = (Math.atan2(-dy, dx) * 180) / Math.PI;
  return COMPASS[Math.round(((deg + 360) % 360) / 45) % 8];
}

export default function MapLocationPicker() {
  const reduce = useReducedMotion();
  const controls = useAnimationControls();
  const uid = useId();
  const listId = `${uid}-suggestions`;
  const inputId = `${uid}-search`;
  const hintId = `${uid}-hint`;

  const [pin, setPin] = useState<Pos>({ x: 455, y: 244 });
  const [radiusM, setRadiusM] = useState(600);
  const [snap, setSnap] = useState(false);
  const [query, setQuery] = useState("");
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const [dropSeq, setDropSeq] = useState(0);
  const [confirmed, setConfirmed] = useState(false);
  const [copied, setCopied] = useState(false);
  const [announce, setAnnounce] = useState("");

  const surfaceRef = useRef<HTMLDivElement | null>(null);
  const pinRef = useRef<HTMLButtonElement | null>(null);
  const draggingRef = useRef(false);
  const grabRef = useRef<Pos>({ x: 0, y: 0 });
  const copyTimer = useRef<number | null>(null);

  const suggestions = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (q === "") return PLACES;
    return PLACES.filter((p) =>
      `${p.name} ${p.area} ${p.code}`.toLowerCase().includes(q),
    );
  }, [query]);

  const near = useMemo(() => nearest(pin), [pin]);
  const lat = LAT_MAX - (pin.y / MAP_H) * LAT_SPAN;
  const lng = LNG_MIN + (pin.x / MAP_W) * LNG_SPAN;
  const latText = lat.toFixed(5);
  const lngText = lng.toFixed(5);
  const offsetM = Math.round(near.dist * M_PER_UNIT);
  const dir = bearing(pin.x - near.place.x, pin.y - near.place.y);
  const addressLine =
    offsetM < 45
      ? `${near.place.name}, ${near.place.area}`
      : `${offsetM} m ${dir} of ${near.place.name}, ${near.place.area}`;
  const radiusUnits = radiusM / M_PER_UNIT;

  const movePin = useCallback(
    (next: Pos, snapping: boolean) => {
      const raw = {
        x: clamp(next.x, 0, MAP_W),
        y: clamp(next.y, 0, MAP_H),
      };
      setPin(snapping ? snapToRoad(raw) : raw);
      setConfirmed(false);
    },
    [],
  );

  useEffect(() => {
    const t = window.setTimeout(() => {
      setAnnounce(`${addressLine}. Latitude ${latText}, longitude ${lngText}.`);
    }, 700);
    return () => window.clearTimeout(t);
  }, [addressLine, latText, lngText]);

  useEffect(() => {
    if (dropSeq === 0 || reduce) return;
    void controls.start(
      { y: [-22, 0], scale: [0.85, 1.06, 1] },
      { duration: 0.45, ease: [0.16, 1, 0.3, 1] },
    );
  }, [dropSeq, reduce, controls]);

  useEffect(() => {
    return () => {
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
    };
  }, []);

  function toLocal(clientX: number, clientY: number): Pos | null {
    const el = surfaceRef.current;
    if (!el) return null;
    const r = el.getBoundingClientRect();
    if (r.width === 0 || r.height === 0) return null;
    return {
      x: ((clientX - r.left) / r.width) * MAP_W,
      y: ((clientY - r.top) / r.height) * MAP_H,
    };
  }

  function beginDrag(e: ReactPointerEvent<HTMLElement>, fromPin: boolean) {
    if (e.button !== 0 && e.pointerType === "mouse") return;
    const p = toLocal(e.clientX, e.clientY);
    if (!p) return;
    e.preventDefault();
    const el = surfaceRef.current;
    if (el) el.setPointerCapture(e.pointerId);
    draggingRef.current = true;
    grabRef.current = fromPin ? { x: pin.x - p.x, y: pin.y - p.y } : { x: 0, y: 0 };
    if (!fromPin) movePin(p, snap);
    pinRef.current?.focus();
  }

  function onSurfaceMove(e: ReactPointerEvent<HTMLDivElement>) {
    if (!draggingRef.current) return;
    const p = toLocal(e.clientX, e.clientY);
    if (!p) return;
    movePin({ x: p.x + grabRef.current.x, y: p.y + grabRef.current.y }, snap);
  }

  function endDrag(e: ReactPointerEvent<HTMLDivElement>) {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    const el = surfaceRef.current;
    if (el?.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
  }

  function onPinKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>) {
    const step = e.shiftKey ? 28 : 7;
    let dx = 0;
    let dy = 0;
    if (e.key === "ArrowLeft") dx = -step;
    else if (e.key === "ArrowRight") dx = step;
    else if (e.key === "ArrowUp") dy = -step;
    else if (e.key === "ArrowDown") dy = step;
    else return;
    e.preventDefault();
    movePin({ x: pin.x + dx, y: pin.y + dy }, snap);
  }

  function choose(place: Place) {
    movePin({ x: place.x, y: place.y }, snap);
    setQuery(`${place.name}, ${place.area}`);
    setOpen(false);
    setActive(0);
    setDropSeq((n) => n + 1);
  }

  function onSearchKeyDown(e: ReactKeyboardEvent<HTMLInputElement>) {
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        setActive(0);
        return;
      }
      if (suggestions.length === 0) return;
      const delta = e.key === "ArrowDown" ? 1 : -1;
      setActive((i) => (i + delta + suggestions.length) % suggestions.length);
      return;
    }
    if (e.key === "Enter" && open) {
      const picked = suggestions[active];
      if (!picked) return;
      e.preventDefault();
      choose(picked);
      return;
    }
    if (e.key === "Escape") {
      e.preventDefault();
      if (open) setOpen(false);
      else setQuery("");
    }
  }

  function toggleSnap() {
    const next = !snap;
    setSnap(next);
    if (next) {
      setPin((p) => snapToRoad(p));
      setConfirmed(false);
    }
  }

  async function copyCoords() {
    try {
      await navigator.clipboard.writeText(`${latText}, ${lngText}`);
      setCopied(true);
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
      copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
    } catch {
      setCopied(false);
    }
  }

  const scaleBarUnits = 500 / M_PER_UNIT;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
@keyframes mlp-ping {
  0% { transform: scale(0.4); opacity: 0.5; }
  70% { transform: scale(1); opacity: 0; }
  100% { transform: scale(1); opacity: 0; }
}
@keyframes mlp-dash { to { stroke-dashoffset: -48; } }
.mlp-ping { animation: mlp-ping 2.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
.mlp-dash { animation: mlp-dash 3.2s linear infinite; }
@media (prefers-reduced-motion: reduce) {
  .mlp-ping, .mlp-dash { animation: none !important; }
}
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <div className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Coverage · Bengaluru
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
            Drop a pin on your pickup point
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-zinc-400">
            Riders route to the pin, not the postcode. Drag the marker, search a landmark, or nudge
            it with the arrow keys until it sits on the right gate.
          </p>
        </div>

        <div className="mt-10 grid gap-6 lg:grid-cols-[minmax(0,1.65fr)_minmax(0,1fr)]">
          {/* ---------------- Map ---------------- */}
          <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
            <div
              ref={surfaceRef}
              onPointerDown={(e) => beginDrag(e, false)}
              onPointerMove={onSurfaceMove}
              onPointerUp={endDrag}
              onPointerCancel={endDrag}
              className="relative aspect-[16/10] w-full cursor-crosshair touch-none select-none"
            >
              <svg
                viewBox={`0 0 ${MAP_W} ${MAP_H}`}
                className="absolute inset-0 h-full w-full"
                aria-hidden="true"
                focusable="false"
              >
                <rect
                  x={0}
                  y={0}
                  width={MAP_W}
                  height={MAP_H}
                  className="fill-white dark:fill-zinc-700"
                />

                {CITY.blocks.map((b, i) => (
                  <rect
                    key={`b${i}`}
                    x={b.x}
                    y={b.y}
                    width={b.w}
                    height={b.h}
                    rx={3}
                    className="fill-slate-100 dark:fill-zinc-900"
                  />
                ))}

                {CITY.buildings.map((b, i) => (
                  <rect
                    key={`h${i}`}
                    x={b.x}
                    y={b.y}
                    width={b.w}
                    height={b.h}
                    rx={1.5}
                    className={BUILDING_TONES[b.t]}
                  />
                ))}

                {/* Park */}
                <rect
                  x={PARK.x}
                  y={PARK.y}
                  width={PARK.w}
                  height={PARK.h}
                  rx={10}
                  className="fill-emerald-200 stroke-emerald-300 dark:fill-emerald-950 dark:stroke-emerald-900"
                  strokeWidth={1.5}
                />
                {[
                  { x: 600, y: 226 },
                  { x: 634, y: 292 },
                  { x: 688, y: 232 },
                  { x: 664, y: 296 },
                  { x: 700, y: 276 },
                ].map((t, i) => (
                  <circle
                    key={`t${i}`}
                    cx={t.x}
                    cy={t.y}
                    r={7}
                    className="fill-emerald-400/70 dark:fill-emerald-800"
                  />
                ))}
                <text
                  x={PARK.x + PARK.w / 2}
                  y={PARK.y + 62}
                  textAnchor="middle"
                  className="fill-emerald-700 text-[11px] font-medium dark:fill-emerald-400"
                >
                  Defence Colony Park
                </text>

                {/* Lake */}
                <ellipse
                  cx={LAKE.cx}
                  cy={LAKE.cy}
                  rx={LAKE.rx}
                  ry={LAKE.ry}
                  className="fill-sky-200 stroke-sky-300 dark:fill-sky-950 dark:stroke-sky-900"
                  strokeWidth={1.5}
                />
                <text
                  x={LAKE.cx}
                  y={LAKE.cy + 4}
                  textAnchor="middle"
                  className="fill-sky-700 text-[11px] font-medium dark:fill-sky-400"
                >
                  Ulsoor Lake
                </text>

                {/* Major-road centre lines */}
                <line
                  x1={0}
                  y1={MAJOR_H}
                  x2={MAP_W}
                  y2={MAJOR_H}
                  strokeWidth={1.5}
                  strokeDasharray="12 10"
                  className="mlp-dash stroke-amber-400/70 dark:stroke-amber-500/40"
                />
                <line
                  x1={MAJOR_V}
                  y1={0}
                  x2={MAJOR_V}
                  y2={MAP_H}
                  strokeWidth={1.5}
                  strokeDasharray="12 10"
                  className="mlp-dash stroke-amber-400/70 dark:stroke-amber-500/40"
                />
                <text
                  x={772}
                  y={MAJOR_H + 4}
                  className="fill-slate-500 text-[10px] font-medium tracking-wide dark:fill-zinc-400"
                >
                  100 Feet Rd
                </text>
                <text
                  x={MAJOR_V + 4}
                  y={598}
                  transform={`rotate(-90 ${MAJOR_V + 4} 598)`}
                  className="fill-slate-500 text-[10px] font-medium tracking-wide dark:fill-zinc-400"
                >
                  Old Airport Rd
                </text>

                {/* Coverage radius */}
                <circle
                  cx={pin.x}
                  cy={pin.y}
                  r={radiusUnits}
                  className="fill-indigo-500/12 stroke-indigo-500/70 dark:fill-indigo-400/12 dark:stroke-indigo-400/70"
                  strokeWidth={1.75}
                  strokeDasharray="7 6"
                />

                {/* Landmarks */}
                {PLACES.map((p) => {
                  const isNear = p.id === near.place.id;
                  const flip = p.x > 800;
                  return (
                    <g key={p.id} className="pointer-events-none">
                      <circle
                        cx={p.x}
                        cy={p.y}
                        r={isNear ? 5 : 3.5}
                        className={
                          isNear
                            ? "fill-indigo-600 dark:fill-indigo-400"
                            : "fill-slate-400 dark:fill-zinc-500"
                        }
                      />
                      <text
                        x={flip ? p.x - 9 : p.x + 9}
                        y={p.y + 3.5}
                        textAnchor={flip ? "end" : "start"}
                        className={
                          isNear
                            ? "fill-indigo-700 text-[10px] font-semibold dark:fill-indigo-300"
                            : "fill-slate-500 text-[10px] dark:fill-zinc-400"
                        }
                      >
                        {p.area}
                      </text>
                    </g>
                  );
                })}

                {/* Scale bar */}
                <g className="pointer-events-none">
                  <line
                    x1={28}
                    y1={598}
                    x2={28 + scaleBarUnits}
                    y2={598}
                    strokeWidth={2}
                    className="stroke-slate-600 dark:stroke-zinc-300"
                  />
                  <line x1={28} y1={592} x2={28} y2={604} strokeWidth={2} className="stroke-slate-600 dark:stroke-zinc-300" />
                  <line
                    x1={28 + scaleBarUnits}
                    y1={592}
                    x2={28 + scaleBarUnits}
                    y2={604}
                    strokeWidth={2}
                    className="stroke-slate-600 dark:stroke-zinc-300"
                  />
                  <text x={34 + scaleBarUnits} y={602} className="fill-slate-600 text-[10px] dark:fill-zinc-300">
                    500 m
                  </text>
                </g>

                {/* Compass */}
                <g className="pointer-events-none">
                  <circle cx={956} cy={44} r={17} className="fill-white/85 stroke-slate-300 dark:fill-zinc-900/85 dark:stroke-zinc-700" strokeWidth={1} />
                  <path d="M956 32 L961 46 L956 43 L951 46 Z" className="fill-rose-500 dark:fill-rose-400" />
                  <text x={956} y={58} textAnchor="middle" className="fill-slate-600 text-[9px] font-semibold dark:fill-zinc-300">
                    N
                  </text>
                </g>
              </svg>

              {/* Pulse under the pin */}
              <div
                className="pointer-events-none absolute h-16 w-16 -translate-x-1/2 -translate-y-1/2"
                style={{ left: `${(pin.x / MAP_W) * 100}%`, top: `${(pin.y / MAP_H) * 100}%` }}
              >
                <span className="mlp-ping absolute inset-0 rounded-full bg-rose-500/60 dark:bg-rose-400/50" />
              </div>

              {/* Pin */}
              <div
                className="absolute"
                style={{
                  left: `${(pin.x / MAP_W) * 100}%`,
                  top: `${(pin.y / MAP_H) * 100}%`,
                  transform: "translate(-50%, -100%)",
                }}
              >
                <motion.div animate={controls} style={{ originX: 0.5, originY: 1 }}>
                  <button
                    ref={pinRef}
                    type="button"
                    onPointerDown={(e) => {
                      e.stopPropagation();
                      beginDrag(e, true);
                    }}
                    onKeyDown={onPinKeyDown}
                    aria-describedby={hintId}
                    aria-label={`Location pin at ${addressLine}, latitude ${latText}, longitude ${lngText}`}
                    className="block h-11 w-11 cursor-grab rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:cursor-grabbing dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
                  >
                    <svg viewBox="0 0 44 44" className="h-11 w-11 drop-shadow-md" aria-hidden="true">
                      <path
                        d="M22 44C22 44 36 29.6 36 21A14 14 0 1 0 8 21C8 29.6 22 44 22 44Z"
                        className="fill-rose-600 stroke-white dark:fill-rose-500 dark:stroke-zinc-900"
                        strokeWidth={2}
                      />
                      <circle cx={22} cy={21} r={5.2} className="fill-white dark:fill-zinc-900" />
                    </svg>
                  </button>
                </motion.div>
              </div>

              <p id={hintId} className="sr-only">
                Drag the pin, or press the arrow keys to move it in seven-unit steps. Hold Shift for
                larger steps. Clicking anywhere on the map also moves the pin.
              </p>
            </div>

            <div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 px-4 py-3 text-xs text-slate-500 dark:border-zinc-800 dark:text-zinc-400">
              <span>Tap the map or drag the pin to reposition</span>
              <span className="font-mono tabular-nums">
                {latText}, {lngText}
              </span>
            </div>
          </div>

          {/* ---------------- Panel ---------------- */}
          <div className="flex flex-col gap-5 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
            <div className="relative">
              <label
                htmlFor={inputId}
                className="block text-sm font-medium text-slate-700 dark:text-zinc-300"
              >
                Search a landmark or PIN code
              </label>
              <div className="relative mt-2">
                <svg
                  viewBox="0 0 20 20"
                  className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-zinc-500"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={1.8}
                  aria-hidden="true"
                >
                  <circle cx={8.5} cy={8.5} r={5.5} />
                  <path d="M12.7 12.7 17 17" strokeLinecap="round" />
                </svg>
                <input
                  id={inputId}
                  type="text"
                  role="combobox"
                  autoComplete="off"
                  aria-expanded={open}
                  aria-controls={listId}
                  aria-autocomplete="list"
                  aria-activedescendant={
                    open && suggestions[active] ? `${listId}-opt-${active}` : undefined
                  }
                  value={query}
                  placeholder="Koramangala, 560095…"
                  onChange={(e) => {
                    setQuery(e.target.value);
                    setOpen(true);
                    setActive(0);
                  }}
                  onFocus={() => setOpen(true)}
                  onBlur={() => setOpen(false)}
                  onKeyDown={onSearchKeyDown}
                  className="w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-indigo-400 dark:focus-visible:ring-indigo-400"
                />
              </div>

              {open && (
                <motion.ul
                  id={listId}
                  role="listbox"
                  aria-label="Location suggestions"
                  initial={reduce ? false : { opacity: 0, y: -4 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.16 }}
                  className="absolute z-20 mt-2 max-h-64 w-full overflow-auto rounded-lg border border-slate-200 bg-white p-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
                >
                  {suggestions.length === 0 && (
                    <li className="px-3 py-2 text-sm text-slate-500 dark:text-zinc-400">
                      No match. Drop the pin manually instead.
                    </li>
                  )}
                  {suggestions.map((p, i) => (
                    <li
                      key={p.id}
                      id={`${listId}-opt-${i}`}
                      role="option"
                      aria-selected={i === active}
                      onPointerDown={(e) => {
                        e.preventDefault();
                        choose(p);
                      }}
                      onMouseEnter={() => setActive(i)}
                      className={`flex cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-sm ${
                        i === active
                          ? "bg-indigo-50 text-indigo-900 dark:bg-indigo-500/15 dark:text-indigo-200"
                          : "text-slate-700 dark:text-zinc-300"
                      }`}
                    >
                      <span className="min-w-0">
                        <span className="block truncate font-medium">{p.name}</span>
                        <span className="block truncate text-xs text-slate-500 dark:text-zinc-400">
                          {p.area} · Bengaluru {p.code}
                        </span>
                      </span>
                      <span className="shrink-0 font-mono text-[10px] text-slate-400 tabular-nums dark:text-zinc-500">
                        {(LAT_MAX - (p.y / MAP_H) * LAT_SPAN).toFixed(3)}
                      </span>
                    </li>
                  ))}
                </motion.ul>
              )}
            </div>

            <div className="flex items-start justify-between gap-4 rounded-lg border border-slate-200 p-3 dark:border-zinc-800">
              <span className="text-sm">
                <span className="block font-medium text-slate-800 dark:text-zinc-200">
                  Snap to nearest road
                </span>
                <span className="mt-0.5 block text-xs text-slate-500 dark:text-zinc-400">
                  Keeps the pin off rooftops so drivers get a reachable kerb.
                </span>
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={snap}
                aria-label="Snap the pin to the nearest road"
                onClick={toggleSnap}
                className={`relative mt-0.5 h-6 w-11 shrink-0 rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900 ${
                  snap ? "bg-indigo-600 dark:bg-indigo-500" : "bg-slate-300 dark:bg-zinc-700"
                }`}
              >
                <span
                  className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform dark:bg-zinc-100 ${
                    snap ? "translate-x-[22px]" : "translate-x-0.5"
                  }`}
                />
              </button>
            </div>

            <div>
              <div className="flex items-baseline justify-between">
                <label
                  htmlFor={`${uid}-radius`}
                  className="text-sm font-medium text-slate-700 dark:text-zinc-300"
                >
                  Pickup radius
                </label>
                <span className="font-mono text-sm tabular-nums text-slate-600 dark:text-zinc-400">
                  {radiusM >= 1000 ? `${(radiusM / 1000).toFixed(1)} km` : `${radiusM} m`}
                </span>
              </div>
              <input
                id={`${uid}-radius`}
                type="range"
                min={100}
                max={3000}
                step={50}
                value={radiusM}
                onChange={(e) => {
                  setRadiusM(Number(e.target.value));
                  setConfirmed(false);
                }}
                className="mt-3 h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-zinc-800 dark:accent-indigo-400 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
              />
              <div className="mt-1 flex justify-between text-[11px] text-slate-400 dark:text-zinc-500">
                <span>100 m</span>
                <span>3 km</span>
              </div>
            </div>

            <div className="rounded-lg bg-slate-50 p-4 dark:bg-zinc-950">
              <p className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-zinc-400">
                Selected point
              </p>
              <p className="mt-2 text-sm font-medium leading-snug text-slate-900 dark:text-zinc-100">
                {addressLine}
              </p>
              <p className="mt-0.5 text-xs text-slate-500 dark:text-zinc-400">
                Bengaluru {near.place.code} · {snap ? "snapped to road" : "free placement"}
              </p>
              <dl className="mt-3 grid grid-cols-2 gap-2 border-t border-slate-200 pt-3 text-xs dark:border-zinc-800">
                <div>
                  <dt className="text-slate-500 dark:text-zinc-500">Latitude</dt>
                  <dd className="font-mono tabular-nums text-slate-800 dark:text-zinc-200">{latText}</dd>
                </div>
                <div>
                  <dt className="text-slate-500 dark:text-zinc-500">Longitude</dt>
                  <dd className="font-mono tabular-nums text-slate-800 dark:text-zinc-200">{lngText}</dd>
                </div>
              </dl>
            </div>

            <div className="flex gap-2">
              <button
                type="button"
                onClick={() => setConfirmed(true)}
                className="flex-1 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
              >
                Confirm pickup point
              </button>
              <button
                type="button"
                onClick={() => void copyCoords()}
                aria-label="Copy coordinates to clipboard"
                className="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 px-3 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
              >
                {copied ? (
                  <svg viewBox="0 0 20 20" className="h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
                    <path d="m4 10.5 4 4 8-9" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                ) : (
                  <svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
                    <rect x={7} y={7} width={9} height={9} rx={2} />
                    <path d="M13 5.5A1.5 1.5 0 0 0 11.5 4H5.5A1.5 1.5 0 0 0 4 5.5v6A1.5 1.5 0 0 0 5.5 13" strokeLinecap="round" />
                  </svg>
                )}
                {copied ? "Copied" : "Copy"}
              </button>
            </div>

            <p role="status" className="min-h-[1.25rem] text-xs text-emerald-700 dark:text-emerald-400">
              {confirmed
                ? `Saved. Riders will be routed to ${addressLine} within a ${
                    radiusM >= 1000 ? `${(radiusM / 1000).toFixed(1)} km` : `${radiusM} m`
                  } pickup radius.`
                : ""}
            </p>

            <p aria-live="polite" className="sr-only">
              {announce}
            </p>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →