Web InnoventixFreeCode

Map Mini

Original · free

mini map preview widget

byWeb InnoventixReact + Tailwind
mapminimaps
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-mini.json
map-mini.tsx
"use client";

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

type Layer = "streets" | "transit";

type Spot = {
  id: string;
  name: string;
  kind: string;
  address: string;
  hours: string;
  open: boolean;
  walk: string;
  lat: number;
  lng: number;
  x: number;
  y: number;
};

const MAP_W = 880;
const MAP_H = 560;
const MIN_Z = 1;
const MAX_Z = 4;
const METERS_PER_UNIT = 2.2;

const SPOTS: Spot[] = [
  {
    id: "harbour",
    name: "Harbour Lane Locker",
    kind: "Parcel locker",
    address: "12 Harbour Lane, Dock Quarter",
    hours: "Open 24 hours",
    open: true,
    walk: "4 min walk",
    lat: 53.4712,
    lng: -2.2436,
    x: 196,
    y: 168,
  },
  {
    id: "kiln",
    name: "Kiln Street Pickup",
    kind: "Counter service",
    address: "48 Kiln Street, Old Foundry",
    hours: "Closes 8:00 PM",
    open: true,
    walk: "7 min walk",
    lat: 53.4738,
    lng: -2.2381,
    x: 402,
    y: 122,
  },
  {
    id: "quarry",
    name: "Quarry Park Kiosk",
    kind: "Weekend kiosk",
    address: "Quarry Park, East Gate",
    hours: "Opens Saturday 9:00 AM",
    open: false,
    walk: "11 min walk",
    lat: 53.4691,
    lng: -2.2294,
    x: 654,
    y: 236,
  },
  {
    id: "bridge",
    name: "Iron Bridge Depot",
    kind: "Same-day depot",
    address: "3 Iron Bridge Way, Riverside",
    hours: "Closes 6:30 PM",
    open: true,
    walk: "9 min walk",
    lat: 53.4664,
    lng: -2.2418,
    x: 318,
    y: 388,
  },
  {
    id: "vine",
    name: "Vine Court Lockbox",
    kind: "Parcel locker",
    address: "77 Vine Court, Southbank",
    hours: "Open until 11:00 PM",
    open: true,
    walk: "14 min walk",
    lat: 53.4629,
    lng: -2.2337,
    x: 610,
    y: 452,
  },
];

const BLOCKS: Array<[number, number, number, number]> = [
  [76, 96, 118, 74],
  [232, 84, 108, 86],
  [372, 62, 96, 96],
  [500, 90, 128, 68],
  [700, 108, 112, 84],
  [86, 232, 104, 92],
  [236, 244, 86, 68],
  [452, 216, 116, 74],
  [636, 292, 138, 80],
  [128, 396, 122, 88],
  [292, 444, 96, 66],
  [440, 400, 108, 92],
  [664, 428, 118, 74],
];

function clamp(v: number, lo: number, hi: number) {
  return Math.min(hi, Math.max(lo, v));
}

function clampFocus(x: number, y: number, z: number) {
  const halfW = MAP_W / (2 * z);
  const halfH = MAP_H / (2 * z);
  return {
    x: clamp(x, halfW, MAP_W - halfW),
    y: clamp(y, halfH, MAP_H - halfH),
  };
}

function scaleLabel(z: number) {
  const raw = (72 * METERS_PER_UNIT) / z;
  const steps = [25, 50, 75, 100, 150, 200, 300, 400];
  let best = steps[0];
  for (const s of steps) {
    if (Math.abs(s - raw) < Math.abs(best - raw)) best = s;
  }
  return `${best} m`;
}

export default function MapMini() {
  const reduce = useReducedMotion();
  const rawId = useId();
  const uid = `mapmini${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
  const svgRef = useRef<SVGSVGElement | null>(null);
  const dragRef = useRef<{ id: number; x: number; y: number } | null>(null);

  const [selected, setSelected] = useState<string>("kiln");
  const [focused, setFocused] = useState<string | null>(null);
  const [layer, setLayer] = useState<Layer>("streets");
  const [zoom, setZoom] = useState<number>(1.9);
  const [center, setCenter] = useState<{ x: number; y: number }>(() =>
    clampFocus(402, 122, 1.9),
  );
  const [dragging, setDragging] = useState(false);
  const [copied, setCopied] = useState(false);

  const spot = SPOTS.find((s) => s.id === selected) ?? SPOTS[0];

  const goTo = useCallback((next: Spot) => {
    setSelected(next.id);
    setCopied(false);
    setZoom((z) => {
      const nz = z < 2 ? 2.2 : z;
      setCenter(clampFocus(next.x, next.y, nz));
      return nz;
    });
  }, []);

  const nudgeZoom = useCallback((factor: number) => {
    setZoom((z) => {
      const nz = clamp(Number((z * factor).toFixed(3)), MIN_Z, MAX_Z);
      setCenter((c) => clampFocus(c.x, c.y, nz));
      return nz;
    });
  }, []);

  const pan = useCallback(
    (dx: number, dy: number) => {
      setCenter((c) => clampFocus(c.x + dx, c.y + dy, zoom));
    },
    [zoom],
  );

  const unitsPerPixel = () => {
    const rect = svgRef.current?.getBoundingClientRect();
    if (!rect || rect.width === 0) return 1;
    return MAP_W / rect.width;
  };

  const onPointerDown = (e: ReactPointerEvent<SVGSVGElement>) => {
    if (e.button !== 0) return;
    const target = e.target as Element | null;
    // let a pin own its own press instead of stealing it for a pan
    if (target?.closest?.(".mapmini-pin")) return;
    dragRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY };
    setDragging(true);
    e.currentTarget.setPointerCapture(e.pointerId);
  };

  const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {
    const d = dragRef.current;
    if (!d || d.id !== e.pointerId) return;
    const upp = unitsPerPixel();
    const dx = (e.clientX - d.x) * upp;
    const dy = (e.clientY - d.y) * upp;
    dragRef.current = { id: d.id, x: e.clientX, y: e.clientY };
    pan(-dx / zoom, -dy / zoom);
  };

  const endDrag = (e: ReactPointerEvent<SVGSVGElement>) => {
    if (dragRef.current?.id !== e.pointerId) return;
    dragRef.current = null;
    setDragging(false);
  };

  const onMapKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const step = 70 / zoom;
    switch (e.key) {
      case "ArrowUp":
        pan(0, -step);
        break;
      case "ArrowDown":
        pan(0, step);
        break;
      case "ArrowLeft":
        pan(-step, 0);
        break;
      case "ArrowRight":
        pan(step, 0);
        break;
      case "+":
      case "=":
        nudgeZoom(1.4);
        break;
      case "-":
      case "_":
        nudgeZoom(1 / 1.4);
        break;
      case "0":
        setZoom(1);
        setCenter(clampFocus(MAP_W / 2, MAP_H / 2, 1));
        break;
      default:
        return;
    }
    e.preventDefault();
  };

  const onListKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const i = SPOTS.findIndex((s) => s.id === selected);
    let next = i;
    if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % SPOTS.length;
    else if (e.key === "ArrowUp" || e.key === "ArrowLeft")
      next = (i - 1 + SPOTS.length) % SPOTS.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = SPOTS.length - 1;
    else return;
    e.preventDefault();
    goTo(SPOTS[next]);
    const el = document.getElementById(`${uid}-opt-${SPOTS[next].id}`);
    el?.focus();
  };

  const copyCoords = async () => {
    const text = `${spot.lat.toFixed(4)}, ${spot.lng.toFixed(4)}`;
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      window.setTimeout(() => setCopied(false), 1800);
    } catch {
      setCopied(false);
    }
  };

  const groupTransform = `translate(${MAP_W / 2}px, ${MAP_H / 2}px) scale(${zoom}) translate(${-center.x}px, ${-center.y}px)`;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-20 text-slate-900 sm:px-6 lg:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes mapmini-ping {
          0% { transform: scale(1); opacity: .5; }
          70% { transform: scale(2.8); opacity: 0; }
          100% { transform: scale(2.8); opacity: 0; }
        }
        @keyframes mapmini-dash {
          to { stroke-dashoffset: -36; }
        }
        @keyframes mapmini-breathe {
          0%, 100% { opacity: .35; }
          50% { opacity: .7; }
        }
        .mapmini-ping {
          transform-box: fill-box;
          transform-origin: center;
          animation: mapmini-ping 2.4s cubic-bezier(0, 0, .2, 1) infinite;
        }
        .mapmini-dash { animation: mapmini-dash 1.6s linear infinite; }
        .mapmini-breathe { animation: mapmini-breathe 3.2s ease-in-out infinite; }
        .mapmini-pin:focus-visible {
          outline: 2px solid #6366f1;
          outline-offset: 3px;
          border-radius: 999px;
        }
        @media (prefers-reduced-motion: reduce) {
          .mapmini-ping, .mapmini-dash, .mapmini-breathe { animation: none !important; }
          .mapmini-view { transition: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-5xl">
        <header className="mb-8 flex flex-col gap-3 sm:mb-10">
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="relative flex h-1.5 w-1.5">
              <span className="mapmini-breathe absolute inline-flex h-full w-full rounded-full bg-emerald-500" />
              <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
            </span>
            5 pickup points nearby
          </span>
          <h2 className="text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
            Choose where your parcel waits
          </h2>
          <p className="max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            Drag the preview to explore, or pick a point from the list. Everything shown is
            within a fifteen-minute walk of the Dock Quarter.
          </p>
        </header>

        <div className="grid gap-4 lg:grid-cols-[1.55fr_1fr]">
          {/* Map preview */}
          <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div
              tabIndex={0}
              role="group"
              aria-label="Map preview. Use arrow keys to pan, plus and minus to zoom, zero to reset."
              aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight + - 0"
              onKeyDown={onMapKeyDown}
              className="relative aspect-[11/7] w-full outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-inset dark:focus-visible:ring-indigo-400"
            >
              <svg
                ref={svgRef}
                viewBox={`0 0 ${MAP_W} ${MAP_H}`}
                className={`h-full w-full touch-none select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
                onPointerDown={onPointerDown}
                onPointerMove={onPointerMove}
                onPointerUp={endDrag}
                onPointerCancel={endDrag}
                role="presentation"
              >
                <defs>
                  <clipPath id={`${uid}-clip`}>
                    <rect x="0" y="0" width={MAP_W} height={MAP_H} />
                  </clipPath>
                </defs>

                <g clipPath={`url(#${uid}-clip)`}>
                  <rect
                    x="0"
                    y="0"
                    width={MAP_W}
                    height={MAP_H}
                    className="fill-slate-100 dark:fill-slate-950"
                  />
                  <g
                    className="mapmini-view"
                    style={{
                      transform: groupTransform,
                      transition:
                        dragging || reduce
                          ? "none"
                          : "transform 520ms cubic-bezier(.22,1,.36,1)",
                    }}
                  >
                    {/* water */}
                    <path
                      d="M-40 300 C 140 250, 220 366, 372 356 C 520 346, 596 452, 760 440 C 850 434, 900 470, 940 470 L 940 620 L -40 620 Z"
                      className="fill-sky-200/70 dark:fill-sky-500/15"
                    />
                    <path
                      d="M-40 300 C 140 250, 220 366, 372 356 C 520 346, 596 452, 760 440 C 850 434, 900 470, 940 470"
                      className="fill-none stroke-sky-300 dark:stroke-sky-500/40"
                      strokeWidth="2"
                    />

                    {/* parks */}
                    <path
                      d="M596 168 C 664 142, 760 152, 786 206 C 806 250, 762 292, 690 288 C 618 284, 570 226, 596 168 Z"
                      className="fill-emerald-200/80 dark:fill-emerald-500/15"
                    />
                    <rect
                      x="60"
                      y="440"
                      width="150"
                      height="96"
                      rx="14"
                      className="fill-emerald-200/70 dark:fill-emerald-500/10"
                    />

                    {/* city blocks */}
                    {BLOCKS.map(([x, y, w, h], i) => (
                      <rect
                        key={`${uid}-b${i}`}
                        x={x}
                        y={y}
                        width={w}
                        height={h}
                        rx="6"
                        className="fill-slate-200/90 dark:fill-slate-800/70"
                      />
                    ))}

                    {/* road casings + surfaces */}
                    <g
                      className="stroke-slate-300 dark:stroke-slate-800"
                      strokeLinecap="round"
                      fill="none"
                    >
                      <path d="M0 200 L880 200" strokeWidth="26" />
                      <path d="M0 348 L880 348" strokeWidth="22" />
                      <path d="M210 0 L210 560" strokeWidth="24" />
                      <path d="M480 0 L480 560" strokeWidth="20" />
                      <path d="M60 560 L560 0" strokeWidth="18" />
                    </g>
                    <g
                      className="stroke-white dark:stroke-slate-700"
                      strokeLinecap="round"
                      fill="none"
                    >
                      <path d="M0 200 L880 200" strokeWidth="18" />
                      <path d="M0 348 L880 348" strokeWidth="14" />
                      <path d="M210 0 L210 560" strokeWidth="16" />
                      <path d="M480 0 L480 560" strokeWidth="12" />
                      <path d="M60 560 L560 0" strokeWidth="11" />
                    </g>
                    <g
                      className="stroke-white/90 dark:stroke-slate-700/80"
                      strokeLinecap="round"
                      fill="none"
                      strokeWidth="6"
                    >
                      <path d="M0 92 L880 92" />
                      <path d="M0 470 L360 470" />
                      <path d="M340 0 L340 200" />
                      <path d="M660 200 L660 560" />
                      <path d="M780 0 L780 348" />
                    </g>

                    {/* transit overlay */}
                    {layer === "transit" ? (
                      <g>
                        <path
                          d="M-20 470 C 180 468, 240 300, 400 268 C 560 236, 640 320, 900 292"
                          className="fill-none stroke-violet-500/90 dark:stroke-violet-400/90"
                          strokeWidth="5"
                          strokeLinecap="round"
                          strokeDasharray="14 10"
                        />
                        <path
                          d="M-20 470 C 180 468, 240 300, 400 268 C 560 236, 640 320, 900 292"
                          className="mapmini-dash fill-none stroke-violet-300/70 dark:stroke-violet-300/50"
                          strokeWidth="5"
                          strokeLinecap="round"
                          strokeDasharray="6 30"
                        />
                        <path
                          d="M120 -20 C 200 160, 380 180, 470 300 C 540 394, 700 420, 880 402"
                          className="fill-none stroke-amber-500/80 dark:stroke-amber-400/80"
                          strokeWidth="4"
                          strokeLinecap="round"
                          strokeDasharray="10 12"
                        />
                        {[
                          [252, 322],
                          [400, 268],
                          [470, 300],
                          [652, 300],
                        ].map(([sx, sy], i) => (
                          <circle
                            key={`${uid}-st${i}`}
                            cx={sx}
                            cy={sy}
                            r={5 / zoom + 2}
                            className="fill-white stroke-violet-600 dark:fill-slate-950 dark:stroke-violet-400"
                            strokeWidth={2 / zoom + 0.6}
                          />
                        ))}
                      </g>
                    ) : null}

                    {/* labels */}
                    <g className="fill-slate-500 dark:fill-slate-500" pointerEvents="none">
                      <text
                        x="700"
                        y="222"
                        fontSize={13 / zoom + 4}
                        className="fill-emerald-700 dark:fill-emerald-400/80"
                        fontWeight="600"
                      >
                        Quarry Park
                      </text>
                      <text x="66" y="392" fontSize={11 / zoom + 3} fontWeight="500">
                        Foundry Row
                      </text>
                      <text
                        x="128"
                        y="512"
                        fontSize={11 / zoom + 3}
                        className="fill-sky-700/80 dark:fill-sky-400/70"
                        fontWeight="500"
                      >
                        River Medlock
                      </text>
                    </g>

                    {/* you-are-here */}
                    <g>
                      <circle
                        cx="452"
                        cy="286"
                        r={26}
                        className="fill-indigo-500/15 stroke-indigo-500/40 dark:fill-indigo-400/10 dark:stroke-indigo-400/40"
                        strokeWidth={1.5 / zoom}
                      />
                      <circle
                        cx="452"
                        cy="286"
                        r={6 / zoom + 2}
                        className="fill-indigo-600 stroke-white dark:fill-indigo-400 dark:stroke-slate-950"
                        strokeWidth={2 / zoom + 0.8}
                      />
                    </g>

                    {/* pins */}
                    {SPOTS.map((s) => {
                      const active = s.id === selected;
                      return (
                        <g
                          key={s.id}
                          transform={`translate(${s.x} ${s.y}) scale(${1 / zoom})`}
                        >
                          {active ? (
                            <circle
                              r="13"
                              cy="-22"
                              className="mapmini-ping fill-indigo-500/60 dark:fill-indigo-400/60"
                            />
                          ) : null}
                          {focused === s.id ? (
                            <circle
                              r="21"
                              cy="-22"
                              className="fill-none stroke-indigo-500 dark:stroke-indigo-400"
                              strokeWidth="2.5"
                            />
                          ) : null}
                          <ellipse
                            cx="0"
                            cy="1"
                            rx="7"
                            ry="2.6"
                            className="fill-slate-900/20 dark:fill-slate-950/60"
                          />
                          <g
                            role="button"
                            tabIndex={0}
                            aria-label={`${s.name}, ${s.kind}, ${s.walk}`}
                            className="mapmini-pin cursor-pointer"
                            onFocus={() => setFocused(s.id)}
                            onBlur={() => setFocused(null)}
                            onClick={() => goTo(s)}
                            onKeyDown={(e) => {
                              if (e.key === "Enter" || e.key === " ") {
                                e.preventDefault();
                                goTo(s);
                              }
                            }}
                          >
                            <path
                              d="M0 0 C -9 -12, -14 -17, -14 -25 A 14 14 0 1 1 14 -25 C 14 -17, 9 -12, 0 0 Z"
                              className={
                                active
                                  ? "fill-indigo-600 stroke-white dark:fill-indigo-400 dark:stroke-slate-950"
                                  : s.open
                                    ? "fill-slate-700 stroke-white dark:fill-slate-300 dark:stroke-slate-950"
                                    : "fill-rose-500 stroke-white dark:fill-rose-400 dark:stroke-slate-950"
                              }
                              strokeWidth="2"
                            />
                            <circle
                              cy="-25"
                              r="5"
                              className="fill-white dark:fill-slate-950"
                            />
                          </g>
                        </g>
                      );
                    })}
                  </g>
                </g>
              </svg>

              {/* zoom controls */}
              <div className="pointer-events-none absolute right-3 top-3 flex flex-col gap-2">
                <div className="pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/95">
                  <button
                    type="button"
                    onClick={() => nudgeZoom(1.4)}
                    disabled={zoom >= MAX_Z}
                    aria-label="Zoom in"
                    className="grid h-9 w-9 place-items-center text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:opacity-30 dark:text-slate-200 dark:hover:bg-slate-800"
                  >
                    <svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true">
                      <path
                        d="M10 4v12M4 10h12"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                      />
                    </svg>
                  </button>
                  <div className="h-px bg-slate-200 dark:bg-slate-700" />
                  <button
                    type="button"
                    onClick={() => nudgeZoom(1 / 1.4)}
                    disabled={zoom <= MIN_Z}
                    aria-label="Zoom out"
                    className="grid h-9 w-9 place-items-center text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 disabled:opacity-30 dark:text-slate-200 dark:hover:bg-slate-800"
                  >
                    <svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true">
                      <path
                        d="M4 10h12"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                      />
                    </svg>
                  </button>
                </div>
                <button
                  type="button"
                  onClick={() => goTo(spot)}
                  aria-label={`Recenter on ${spot.name}`}
                  className="pointer-events-auto grid h-9 w-9 place-items-center rounded-xl border border-slate-200 bg-white/95 text-slate-700 shadow-sm backdrop-blur transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-900/95 dark:text-slate-200 dark:hover:bg-slate-800"
                >
                  <svg viewBox="0 0 20 20" className="h-4 w-4" aria-hidden="true">
                    <circle
                      cx="10"
                      cy="10"
                      r="4"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.6"
                    />
                    <path
                      d="M10 1.5v3M10 15.5v3M1.5 10h3M15.5 10h3"
                      stroke="currentColor"
                      strokeWidth="1.6"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </div>

              {/* layer switch */}
              <div
                role="group"
                aria-label="Map layer"
                className="absolute left-3 top-3 flex gap-1 rounded-xl border border-slate-200 bg-white/95 p-1 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-900/95"
              >
                {(["streets", "transit"] as Layer[]).map((l) => (
                  <button
                    key={l}
                    type="button"
                    aria-pressed={layer === l}
                    onClick={() => setLayer(l)}
                    className={`rounded-lg px-2.5 py-1 text-xs font-medium capitalize transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
                      layer === l
                        ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                        : "text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
                    }`}
                  >
                    {l}
                  </button>
                ))}
              </div>

              {/* scale bar */}
              <div className="pointer-events-none absolute bottom-3 left-3 flex items-center gap-2 rounded-lg bg-white/85 px-2 py-1 text-[11px] font-medium text-slate-600 backdrop-blur dark:bg-slate-900/85 dark:text-slate-400">
                <span
                  className="block h-1.5 w-[72px] border-x-2 border-b-2 border-slate-500 dark:border-slate-500"
                  aria-hidden="true"
                />
                {scaleLabel(zoom)}
              </div>

              <div className="pointer-events-none absolute bottom-3 right-3 rounded-lg bg-white/85 px-2 py-1 text-[11px] font-medium tabular-nums text-slate-500 backdrop-blur dark:bg-slate-900/85 dark:text-slate-500">
                {zoom.toFixed(1)}×
              </div>
            </div>

            {/* detail bar */}
            <div className="border-t border-slate-200 p-4 dark:border-slate-800">
              <AnimatePresence mode="wait" initial={false}>
                <motion.div
                  key={spot.id}
                  initial={reduce ? false : { opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduce ? { opacity: 1 } : { opacity: 0, y: -6 }}
                  transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                  className="flex flex-wrap items-start justify-between gap-3"
                >
                  <div className="min-w-0">
                    <div className="flex items-center gap-2">
                      <h3 className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
                        {spot.name}
                      </h3>
                      <span
                        className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${
                          spot.open
                            ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400"
                            : "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-400"
                        }`}
                      >
                        {spot.open ? "Open" : "Closed"}
                      </span>
                    </div>
                    <p className="mt-1 text-xs text-slate-600 dark:text-slate-400">
                      {spot.address} · {spot.hours}
                    </p>
                    <p className="mt-0.5 text-xs tabular-nums text-slate-500 dark:text-slate-500">
                      {spot.lat.toFixed(4)}, {spot.lng.toFixed(4)} · {spot.walk}
                    </p>
                  </div>
                  <button
                    type="button"
                    onClick={copyCoords}
                    className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    {copied ? (
                      <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" aria-hidden="true">
                        <path
                          d="M4 10.5l4 4 8-9"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.8"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    ) : (
                      <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" aria-hidden="true">
                        <rect
                          x="7"
                          y="7"
                          width="9"
                          height="9"
                          rx="2"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.6"
                        />
                        <path
                          d="M13 5.5A1.5 1.5 0 0011.5 4h-6A1.5 1.5 0 004 5.5v6A1.5 1.5 0 005.5 13"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.6"
                          strokeLinecap="round"
                        />
                      </svg>
                    )}
                    <span aria-live="polite">
                      {copied ? "Copied" : "Copy coordinates"}
                    </span>
                  </button>
                </motion.div>
              </AnimatePresence>
            </div>
          </div>

          {/* Locations list */}
          <div className="rounded-2xl border border-slate-200 bg-white p-2 shadow-sm dark:border-slate-800 dark:bg-slate-900">
            <div
              role="radiogroup"
              aria-label="Pickup points"
              onKeyDown={onListKeyDown}
              className="flex flex-col"
            >
              {SPOTS.map((s) => {
                const active = s.id === selected;
                return (
                  <button
                    key={s.id}
                    id={`${uid}-opt-${s.id}`}
                    type="button"
                    role="radio"
                    aria-checked={active}
                    tabIndex={active ? 0 : -1}
                    onClick={() => goTo(s)}
                    className={`group flex items-start gap-3 rounded-xl px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                      active
                        ? "bg-indigo-50 dark:bg-indigo-500/10"
                        : "hover:bg-slate-50 dark:hover:bg-slate-800/60"
                    }`}
                  >
                    <span
                      className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg ${
                        active
                          ? "bg-indigo-600 text-white dark:bg-indigo-500"
                          : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400"
                      }`}
                      aria-hidden="true"
                    >
                      <svg viewBox="0 0 20 20" className="h-3.5 w-3.5">
                        <path
                          d="M10 17.5s5.5-5 5.5-9a5.5 5.5 0 10-11 0c0 4 5.5 9 5.5 9z"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.5"
                          strokeLinejoin="round"
                        />
                        <circle cx="10" cy="8.5" r="1.9" fill="currentColor" />
                      </svg>
                    </span>
                    <span className="min-w-0 flex-1">
                      <span className="flex items-center gap-2">
                        <span
                          className={`truncate text-sm font-medium ${
                            active
                              ? "text-indigo-900 dark:text-indigo-200"
                              : "text-slate-800 dark:text-slate-200"
                          }`}
                        >
                          {s.name}
                        </span>
                        <span
                          className={`h-1.5 w-1.5 shrink-0 rounded-full ${
                            s.open ? "bg-emerald-500" : "bg-rose-500"
                          }`}
                          aria-hidden="true"
                        />
                      </span>
                      <span className="mt-0.5 block truncate text-xs text-slate-500 dark:text-slate-400">
                        {s.kind} · {s.hours}
                      </span>
                    </span>
                    <span className="mt-0.5 shrink-0 text-xs font-medium tabular-nums text-slate-400 dark:text-slate-500">
                      {s.walk.replace(" walk", "")}
                    </span>
                  </button>
                );
              })}
            </div>
            <p className="border-t border-slate-100 px-3 py-3 text-[11px] leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-500">
              Preview only — drag to pan, arrow keys to move, <kbd className="rounded border border-slate-200 px-1 font-sans dark:border-slate-700">+</kbd>{" "}
              and <kbd className="rounded border border-slate-200 px-1 font-sans dark:border-slate-700">−</kbd> to zoom,{" "}
              <kbd className="rounded border border-slate-200 px-1 font-sans dark:border-slate-700">0</kbd> to reset.
            </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 →