Web InnoventixFreeCode

Map Pins List

Original · free

map with a list of pinned places

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

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactElement,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Category = "coffee" | "food" | "shop" | "outdoors" | "music" | "nightlife";

type Place = {
  id: string;
  n: number;
  name: string;
  category: Category;
  blurb: string;
  hours: string;
  walk: number;
  rating: number;
  coords: string;
  x: number;
  y: number;
};

const PLACES: Place[] = [
  {
    id: "ferrous",
    n: 1,
    name: "Ferrous Roasters",
    category: "coffee",
    blurb:
      "Six-bar espresso, a rotating Ethiopian filter, and the only counter on the row that opens before the market does.",
    hours: "06:00 – 16:00 · Daily",
    walk: 4,
    rating: 4.8,
    coords: "51.5241, -0.0834",
    x: 31.3,
    y: 35,
  },
  {
    id: "long-table",
    n: 2,
    name: "The Long Table",
    category: "food",
    blurb:
      "Twelve seats, one menu, no substitutions. The lamb shoulder goes at eight and does not come back.",
    hours: "17:30 – 23:00 · Wed – Sun",
    walk: 7,
    rating: 4.6,
    coords: "51.5218, -0.0791",
    x: 56.9,
    y: 40.8,
  },
  {
    id: "marginalia",
    n: 3,
    name: "Marginalia Books",
    category: "shop",
    blurb:
      "Second-hand stacks two floors deep, with a poetry room that turns into a reading nook every Thursday.",
    hours: "10:00 – 19:00 · Tue – Sun",
    walk: 9,
    rating: 4.9,
    coords: "51.5192, -0.0902",
    x: 18.8,
    y: 60,
  },
  {
    id: "hollis",
    n: 4,
    name: "Hollis Green",
    category: "outdoors",
    blurb:
      "Eleven acres of plane trees, a lido that opens in May, and a bandstand the brass band still uses on Sundays.",
    hours: "Open 24 hours",
    walk: 12,
    rating: 4.7,
    coords: "51.5266, -0.0718",
    x: 86.3,
    y: 16.7,
  },
  {
    id: "basement",
    n: 5,
    name: "Basement 12",
    category: "music",
    blurb:
      "A 180-capacity room under the old print works. Doors at seven, nothing on the floor but the band and the bar.",
    hours: "19:00 – 02:00 · Thu – Sat",
    walk: 6,
    rating: 4.5,
    coords: "51.5183, -0.0762",
    x: 65,
    y: 80,
  },
  {
    id: "nightjar",
    n: 6,
    name: "Nightjar",
    category: "nightlife",
    blurb:
      "Low ceilings, high-proof stirred drinks, and a bartender who will happily talk you out of what you ordered.",
    hours: "18:00 – 01:00 · Tue – Sat",
    walk: 8,
    rating: 4.4,
    coords: "51.5171, -0.0845",
    x: 41.3,
    y: 86.7,
  },
  {
    id: "salt",
    n: 7,
    name: "Salt & Ferment",
    category: "food",
    blurb:
      "A bakery that mills its own rye. Arrive before eleven or make peace with the seeded loaf.",
    hours: "07:30 – 15:00 · Wed – Sun",
    walk: 10,
    rating: 4.8,
    coords: "51.5197, -0.0729",
    x: 80,
    y: 63.3,
  },
  {
    id: "union",
    n: 8,
    name: "Union Print Works",
    category: "shop",
    blurb:
      "Letterpress studio and paper shop. They will set your name in 24pt metal type and hand it over still smelling of ink.",
    hours: "11:00 – 18:00 · Thu – Sat",
    walk: 5,
    rating: 4.6,
    coords: "51.5259, -0.0871",
    x: 26.9,
    y: 17.5,
  },
];

type IconProps = { className?: string };

function CoffeeIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M4 8h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <path
        d="M16 9.5h1.75A2.25 2.25 0 0 1 20 11.75v0A2.25 2.25 0 0 1 17.75 14H16"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <path
        d="M8 2.5v2M12 2.5v2"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

function FoodIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M6 3v7.5a2.5 2.5 0 0 0 5 0V3M8.5 11v10"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M17 3c-1.7 1.3-2.5 3.2-2.5 5.5S15.3 12 17 12.5V21"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function ShopIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M5 8h14l-1 12H6L5 8Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <path
        d="M9 10V7a3 3 0 0 1 6 0v3"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

function TreeIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M12 3 5.5 13h4L5 20h14l-4.5-7h4L12 3Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <path d="M12 20v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
    </svg>
  );
}

function MusicIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M9 18V5.5l10-2V16"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <circle cx="6.5" cy="18" r="2.5" stroke="currentColor" strokeWidth="1.6" />
      <circle cx="16.5" cy="16" r="2.5" stroke="currentColor" strokeWidth="1.6" />
    </svg>
  );
}

function GlassIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="M4 4h16l-8 8-8-8Z"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinejoin="round"
      />
      <path
        d="M12 12v8M8 20h8"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

function StarIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
      <path d="m12 3.6 2.42 4.9 5.41.79-3.92 3.81.93 5.39L12 15.94l-4.84 2.55.93-5.39-3.92-3.81 5.41-.79L12 3.6Z" />
    </svg>
  );
}

function ClockIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="M12 7.5V12l3 2"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function WalkIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <circle cx="13" cy="4.2" r="2" stroke="currentColor" strokeWidth="1.6" />
      <path
        d="M13.5 8.2 10 10.4l-1.3 4M13.5 8.2l3 2.3.8 3.3M13.5 8.2l-.6 5.3 2.4 2.7.9 4.3M12.9 13.5 9.6 16l-1.5 4.5"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function CopyIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <rect
        x="9"
        y="9"
        width="11"
        height="11"
        rx="2.5"
        stroke="currentColor"
        strokeWidth="1.6"
      />
      <path
        d="M15 6.5A2.5 2.5 0 0 0 12.5 4h-6A2.5 2.5 0 0 0 4 6.5v6A2.5 2.5 0 0 0 6.5 15"
        stroke="currentColor"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
    </svg>
  );
}

function CheckIcon({ className }: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
      <path
        d="m5 12.5 4.5 4.5L19 7"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

type CategoryMeta = {
  label: string;
  dot: string;
  chipIcon: string;
  pinFill: string;
  pulse: string;
  Icon: (props: IconProps) => ReactElement;
};

const CATEGORIES: Record<Category, CategoryMeta> = {
  coffee: {
    label: "Coffee",
    dot: "bg-amber-500 dark:bg-amber-400",
    chipIcon: "text-amber-600 dark:text-amber-400",
    pinFill: "fill-amber-500 dark:fill-amber-400",
    pulse: "bg-amber-500/40 dark:bg-amber-400/40",
    Icon: CoffeeIcon,
  },
  food: {
    label: "Food",
    dot: "bg-rose-500 dark:bg-rose-400",
    chipIcon: "text-rose-600 dark:text-rose-400",
    pinFill: "fill-rose-500 dark:fill-rose-400",
    pulse: "bg-rose-500/40 dark:bg-rose-400/40",
    Icon: FoodIcon,
  },
  shop: {
    label: "Shops",
    dot: "bg-sky-500 dark:bg-sky-400",
    chipIcon: "text-sky-600 dark:text-sky-400",
    pinFill: "fill-sky-500 dark:fill-sky-400",
    pulse: "bg-sky-500/40 dark:bg-sky-400/40",
    Icon: ShopIcon,
  },
  outdoors: {
    label: "Outdoors",
    dot: "bg-emerald-500 dark:bg-emerald-400",
    chipIcon: "text-emerald-600 dark:text-emerald-400",
    pinFill: "fill-emerald-500 dark:fill-emerald-400",
    pulse: "bg-emerald-500/40 dark:bg-emerald-400/40",
    Icon: TreeIcon,
  },
  music: {
    label: "Music",
    dot: "bg-violet-500 dark:bg-violet-400",
    chipIcon: "text-violet-600 dark:text-violet-400",
    pinFill: "fill-violet-500 dark:fill-violet-400",
    pulse: "bg-violet-500/40 dark:bg-violet-400/40",
    Icon: MusicIcon,
  },
  nightlife: {
    label: "Nightlife",
    dot: "bg-indigo-500 dark:bg-indigo-400",
    chipIcon: "text-indigo-600 dark:text-indigo-400",
    pinFill: "fill-indigo-500 dark:fill-indigo-400",
    pulse: "bg-indigo-500/40 dark:bg-indigo-400/40",
    Icon: GlassIcon,
  },
};

const FILTERS: Array<{ key: Category | "all"; label: string }> = [
  { key: "all", label: "All stops" },
  { key: "coffee", label: "Coffee" },
  { key: "food", label: "Food" },
  { key: "shop", label: "Shops" },
  { key: "outdoors", label: "Outdoors" },
  { key: "music", label: "Music" },
  { key: "nightlife", label: "Nightlife" },
];

const BLOCKS: Array<{ x: number; y: number; w: number; h: number }> = [
  { x: 34, y: 34, w: 132, h: 102 },
  { x: 194, y: 34, w: 172, h: 102 },
  { x: 394, y: 34, w: 88, h: 102 },
  { x: 496, y: 34, w: 90, h: 102 },
  { x: 34, y: 164, w: 132, h: 122 },
  { x: 194, y: 164, w: 172, h: 122 },
  { x: 394, y: 164, w: 192, h: 122 },
  { x: 614, y: 164, w: 152, h: 122 },
  { x: 34, y: 314, w: 132, h: 122 },
  { x: 194, y: 314, w: 172, h: 56 },
  { x: 194, y: 382, w: 172, h: 54 },
  { x: 394, y: 314, w: 192, h: 122 },
  { x: 614, y: 314, w: 152, h: 122 },
  { x: 194, y: 464, w: 172, h: 102 },
  { x: 394, y: 464, w: 192, h: 102 },
  { x: 614, y: 464, w: 152, h: 102 },
];

export default function MapPinsList() {
  const reduce = useReducedMotion();
  const rawId = useId();
  const uid = rawId.replace(/[^a-zA-Z0-9]/g, "");
  const [filter, setFilter] = useState<Category | "all">("all");
  const [selectedId, setSelectedId] = useState<string>("ferrous");
  const [copied, setCopied] = useState(false);
  const optionRefs = useRef<Record<string, HTMLDivElement | null>>({});
  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

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

  const visible = useMemo(
    () => (filter === "all" ? PLACES : PLACES.filter((p) => p.category === filter)),
    [filter],
  );

  const selected: Place = PLACES.find((p) => p.id === selectedId) ?? PLACES[0];
  const selectedMeta = CATEGORIES[selected.category];

  const applyFilter = (key: Category | "all") => {
    setFilter(key);
    const next = key === "all" ? PLACES : PLACES.filter((p) => p.category === key);
    if (!next.some((p) => p.id === selectedId) && next.length > 0) {
      setSelectedId(next[0].id);
    }
  };

  const selectFromPin = (id: string) => {
    setSelectedId(id);
    const el = optionRefs.current[id];
    if (el) {
      el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
    }
  };

  const onListKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (visible.length === 0) return;
    const idx = visible.findIndex((p) => p.id === selectedId);
    const last = visible.length - 1;
    let target = -1;
    if (e.key === "ArrowDown" || e.key === "ArrowRight") target = idx >= last ? 0 : idx + 1;
    else if (e.key === "ArrowUp" || e.key === "ArrowLeft") target = idx <= 0 ? last : idx - 1;
    else if (e.key === "Home") target = 0;
    else if (e.key === "End") target = last;
    if (target < 0) return;
    e.preventDefault();
    const next = visible[target];
    setSelectedId(next.id);
    optionRefs.current[next.id]?.focus();
  };

  const copyCoords = async () => {
    if (typeof navigator === "undefined" || !navigator.clipboard) return;
    try {
      await navigator.clipboard.writeText(`${selected.name} — ${selected.coords}`);
      setCopied(true);
      if (copyTimer.current) clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 1800);
    } catch {
      setCopied(false);
    }
  };

  return (
    <section className="relative w-full bg-white px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes mplPinPulse-mpl {
          0% { transform: scale(0.65); opacity: 0.5; }
          70% { transform: scale(2.1); opacity: 0; }
          100% { transform: scale(2.1); opacity: 0; }
        }
        @keyframes mplRouteFlow-mpl {
          to { stroke-dashoffset: -36; }
        }
        .mpl-pulse {
          animation: mplPinPulse-mpl 2.4s cubic-bezier(0.2, 0.6, 0.3, 1) infinite;
        }
        .mpl-route {
          stroke-dasharray: 10 8;
          animation: mplRouteFlow-mpl 1.6s linear infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .mpl-pulse, .mpl-route { animation: none !important; }
          .mpl-pulse { opacity: 0.3; transform: scale(1.35); }
        }
      `}</style>

      <div className="mx-auto w-full max-w-6xl">
        <header className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Neighbourhood guide
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
            Eight stops worth the walk in Mill Row
          </h2>
          <p className="mt-4 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Every pin is a place someone on the team has actually been to twice. Pick one on the
            map or work down the list — the two stay in sync, and the whole thing runs on the
            keyboard.
          </p>
        </header>

        <div
          role="group"
          aria-label="Filter places by category"
          className="mt-8 flex flex-wrap gap-2"
        >
          {FILTERS.map((f) => {
            const active = filter === f.key;
            const meta = f.key === "all" ? null : CATEGORIES[f.key];
            return (
              <button
                key={f.key}
                type="button"
                aria-pressed={active}
                onClick={() => applyFilter(f.key)}
                className={[
                  "inline-flex items-center gap-2 rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
                  "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-950",
                  active
                    ? "border-slate-900 bg-slate-900 text-white dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900"
                    : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-800",
                ].join(" ")}
              >
                {meta ? (
                  <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${meta.dot}`} />
                ) : null}
                {f.label}
              </button>
            );
          })}
        </div>

        <div className="mt-6 grid gap-6 lg:grid-cols-5">
          {/* Map */}
          <div className="lg:col-span-3">
            <div className="overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <div className="relative aspect-[4/3] w-full">
                <svg
                  viewBox="0 0 800 600"
                  className="absolute inset-0 h-full w-full"
                  aria-hidden="true"
                >
                  <defs>
                    <pattern
                      id={`${uid}-dots`}
                      width="22"
                      height="22"
                      patternUnits="userSpaceOnUse"
                    >
                      <circle
                        cx="2"
                        cy="2"
                        r="1"
                        className="fill-slate-300/70 dark:fill-slate-700/70"
                      />
                    </pattern>
                  </defs>

                  <rect
                    width="800"
                    height="600"
                    className="fill-slate-100 dark:fill-slate-900"
                  />
                  <rect width="800" height="600" fill={`url(#${uid}-dots)`} />

                  {/* water */}
                  <path
                    d="M0 600 L0 396 C 112 448 184 520 252 600 Z"
                    className="fill-sky-200/90 dark:fill-sky-950"
                  />
                  <path
                    d="M0 396 C 112 448 184 520 252 600"
                    fill="none"
                    strokeWidth="2"
                    className="stroke-sky-300 dark:stroke-sky-900"
                  />

                  {/* park */}
                  <rect
                    x="608"
                    y="28"
                    width="164"
                    height="112"
                    rx="12"
                    className="fill-emerald-100 dark:fill-emerald-950"
                  />
                  <circle cx="640" cy="112" r="9" className="fill-emerald-300/70 dark:fill-emerald-900" />
                  <circle cx="666" cy="122" r="6" className="fill-emerald-300/70 dark:fill-emerald-900" />
                  <circle cx="744" cy="66" r="8" className="fill-emerald-300/70 dark:fill-emerald-900" />

                  {/* blocks */}
                  {BLOCKS.map((b) => (
                    <rect
                      key={`${b.x}-${b.y}-${b.h}`}
                      x={b.x}
                      y={b.y}
                      width={b.w}
                      height={b.h}
                      rx="6"
                      className="fill-white stroke-slate-200 dark:fill-slate-800/70 dark:stroke-slate-700/60"
                      strokeWidth="1"
                    />
                  ))}

                  {/* road casing */}
                  <g
                    fill="none"
                    strokeWidth="18"
                    strokeLinecap="round"
                    className="stroke-slate-200 dark:stroke-slate-700/70"
                  >
                    <path d="M0 150 H800" />
                    <path d="M0 300 H800" />
                    <path d="M0 450 H800" />
                    <path d="M180 0 V600" />
                    <path d="M380 0 V600" />
                    <path d="M600 0 V600" />
                  </g>

                  {/* road surface */}
                  <g
                    fill="none"
                    strokeWidth="13"
                    strokeLinecap="round"
                    className="stroke-white dark:stroke-slate-800"
                  >
                    <path d="M0 150 H800" />
                    <path d="M0 300 H800" />
                    <path d="M0 450 H800" />
                    <path d="M180 0 V600" />
                    <path d="M380 0 V600" />
                    <path d="M600 0 V600" />
                  </g>

                  {/* walking route */}
                  <path
                    d="M24 566 C 176 548 208 380 380 300 C 520 236 640 252 792 188"
                    fill="none"
                    strokeWidth="3"
                    strokeLinecap="round"
                    className="mpl-route stroke-violet-500/70 dark:stroke-violet-400/60"
                  />

                  {/* labels */}
                  <g
                    fontSize="11"
                    fontWeight="700"
                    letterSpacing="1.8"
                    className="fill-slate-400 dark:fill-slate-600"
                  >
                    <text x="42" y="296">MILL ROW</text>
                    <text x="490" y="228" textAnchor="middle">OLD TOWN</text>
                    <text x="280" y="492" textAnchor="middle">PRINT QUARTER</text>
                  </g>
                  <text
                    x="92"
                    y="512"
                    fontSize="11"
                    fontWeight="700"
                    letterSpacing="1.8"
                    transform="rotate(-52 92 512)"
                    className="fill-sky-500/80 dark:fill-sky-700"
                  >
                    THE CUT
                  </text>
                  <text
                    x="690"
                    y="52"
                    fontSize="11"
                    fontWeight="700"
                    letterSpacing="1.8"
                    textAnchor="middle"
                    className="fill-emerald-600/80 dark:fill-emerald-600"
                  >
                    HOLLIS GREEN
                  </text>
                </svg>

                {/* pins */}
                <AnimatePresence initial={false}>
                  {visible.map((p) => {
                    const meta = CATEGORIES[p.category];
                    const isSel = p.id === selected.id;
                    return (
                      <motion.div
                        key={p.id}
                        className="absolute"
                        style={{ left: `${p.x}%`, top: `${p.y}%`, zIndex: isSel ? 30 : 10 }}
                        initial={{ opacity: 0 }}
                        animate={{ opacity: 1 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: reduce ? 0 : 0.18 }}
                      >
                        <div className="relative -translate-x-1/2 -translate-y-full">
                          {isSel ? (
                            <div className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 pb-1.5">
                              <motion.span
                                initial={{ opacity: 0, y: 4 }}
                                animate={{ opacity: 1, y: 0 }}
                                transition={{ duration: reduce ? 0 : 0.2 }}
                                className="block whitespace-nowrap rounded-md bg-slate-900 px-2 py-1 text-[11px] font-semibold text-white shadow-lg dark:bg-slate-100 dark:text-slate-900"
                              >
                                {p.name}
                              </motion.span>
                            </div>
                          ) : null}

                          <motion.button
                            type="button"
                            onClick={() => selectFromPin(p.id)}
                            aria-label={`${p.name}, ${meta.label}. ${
                              isSel ? "Selected stop." : "Select this stop."
                            }`}
                            aria-current={isSel ? "true" : undefined}
                            initial={reduce ? false : { scale: 0.6, y: -10 }}
                            animate={{ scale: isSel ? 1.14 : 1, y: 0 }}
                            whileHover={reduce ? undefined : { scale: isSel ? 1.2 : 1.1, y: -2 }}
                            transition={
                              reduce
                                ? { duration: 0 }
                                : { type: "spring", stiffness: 430, damping: 24 }
                            }
                            className="relative block cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-slate-100 dark:focus-visible:ring-offset-slate-900"
                          >
                            {isSel ? (
                              <span
                                className={`mpl-pulse pointer-events-none absolute left-1/2 top-[13px] -ml-3.5 -mt-3.5 h-7 w-7 rounded-full ${meta.pulse}`}
                              />
                            ) : null}
                            <svg
                              viewBox="0 0 28 38"
                              className="relative block h-9 w-7 drop-shadow-sm"
                              aria-hidden="true"
                            >
                              <path
                                d="M14 37.2C14 37.2 26.4 22.6 26.4 14A12.4 12.4 0 1 0 1.6 14C1.6 22.6 14 37.2 14 37.2Z"
                                strokeWidth="1.6"
                                className={
                                  isSel
                                    ? `${meta.pinFill} stroke-white dark:stroke-slate-900`
                                    : "fill-white stroke-slate-300 dark:fill-slate-800 dark:stroke-slate-600"
                                }
                              />
                              <text
                                x="14"
                                y="18.4"
                                textAnchor="middle"
                                fontSize="12"
                                fontWeight="700"
                                className={
                                  isSel
                                    ? "fill-white dark:fill-slate-950"
                                    : "fill-slate-500 dark:fill-slate-300"
                                }
                              >
                                {p.n}
                              </text>
                            </svg>
                          </motion.button>
                        </div>
                      </motion.div>
                    );
                  })}
                </AnimatePresence>

                {visible.length === 0 ? (
                  <div className="absolute inset-x-0 bottom-4 flex justify-center">
                    <p className="rounded-full bg-slate-900/90 px-3 py-1.5 text-xs font-medium text-white dark:bg-slate-100/90 dark:text-slate-900">
                      No pins in this category.
                    </p>
                  </div>
                ) : null}

                {/* scale bar */}
                <div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-md bg-white/85 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-slate-500 backdrop-blur-sm dark:bg-slate-900/85 dark:text-slate-400">
                  <span className="block h-[3px] w-10 rounded-full bg-slate-400 dark:bg-slate-600" />
                  200 m
                </div>
              </div>

              {/* detail strip */}
              <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-slate-200 bg-white px-4 py-4 dark:border-slate-800 dark:bg-slate-900">
                <div className="flex min-w-0 flex-1 items-center gap-3">
                  <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-bold text-white dark:bg-slate-100 dark:text-slate-900">
                    {selected.n}
                  </span>
                  <div className="min-w-0">
                    <p className="truncate text-sm font-semibold">{selected.name}</p>
                    <p className="mt-0.5 flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
                      <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${selectedMeta.dot}`} />
                      {selectedMeta.label}
                      <span aria-hidden="true">·</span>
                      <span className="truncate">{selected.hours}</span>
                    </p>
                  </div>
                </div>

                <div className="flex items-center gap-3">
                  <code className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                    {selected.coords}
                  </code>
                  <button
                    type="button"
                    onClick={copyCoords}
                    className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-50 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-800 dark:text-slate-200 dark:hover:bg-slate-700 dark:focus-visible:ring-offset-slate-900"
                  >
                    {copied ? (
                      <CheckIcon className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" />
                    ) : (
                      <CopyIcon className="h-3.5 w-3.5" />
                    )}
                    {copied ? "Copied" : "Copy"}
                  </button>
                  <span role="status" aria-live="polite" className="sr-only">
                    {copied ? `Coordinates for ${selected.name} copied to clipboard` : ""}
                  </span>
                </div>
              </div>
            </div>
          </div>

          {/* List */}
          <div className="lg:col-span-2">
            <div className="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <div className="flex items-baseline justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800">
                <h3 className="text-sm font-semibold">Pinned places</h3>
                <p className="text-xs text-slate-500 dark:text-slate-400">
                  {visible.length} of {PLACES.length}
                </p>
              </div>

              <div
                role="listbox"
                aria-label="Pinned places in Mill Row"
                onKeyDown={onListKeyDown}
                className="max-h-[26rem] overflow-y-auto p-2 lg:max-h-[32rem]"
              >
                {visible.map((p) => {
                  const meta = CATEGORIES[p.category];
                  const isSel = p.id === selected.id;
                  const Icon = meta.Icon;
                  return (
                    <div
                      key={p.id}
                      ref={(el) => {
                        optionRefs.current[p.id] = el;
                      }}
                      role="option"
                      aria-selected={isSel}
                      tabIndex={isSel ? 0 : -1}
                      onClick={() => setSelectedId(p.id)}
                      className={[
                        "mb-1.5 cursor-pointer rounded-xl border p-3 transition-colors last:mb-0",
                        "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",
                        isSel
                          ? "border-slate-900 bg-slate-50 dark:border-slate-100 dark:bg-slate-800"
                          : "border-transparent hover:bg-slate-50 dark:hover:bg-slate-800/60",
                      ].join(" ")}
                    >
                      <div className="flex gap-3">
                        <span
                          className={[
                            "mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-bold transition-colors",
                            isSel
                              ? "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
                              : "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
                          ].join(" ")}
                          aria-hidden="true"
                        >
                          {p.n}
                        </span>

                        <div className="min-w-0">
                          <div className="flex items-center gap-2">
                            <p className="truncate text-sm font-semibold">{p.name}</p>
                            <span
                              className={`inline-flex shrink-0 items-center gap-1 text-[11px] font-medium ${meta.chipIcon}`}
                            >
                              <Icon className="h-3.5 w-3.5" />
                              {meta.label}
                            </span>
                          </div>

                          <p className="mt-1 text-xs leading-relaxed text-slate-600 dark:text-slate-400">
                            {p.blurb}
                          </p>

                          <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-slate-500 dark:text-slate-400">
                            <span className="inline-flex items-center gap-1">
                              <WalkIcon className="h-3.5 w-3.5" />
                              {p.walk} min walk
                            </span>
                            <span className="inline-flex items-center gap-1">
                              <ClockIcon className="h-3.5 w-3.5" />
                              {p.hours}
                            </span>
                            <span className="inline-flex items-center gap-1">
                              <StarIcon className="h-3.5 w-3.5 text-amber-500 dark:text-amber-400" />
                              {p.rating.toFixed(1)}
                            </span>
                          </div>
                        </div>
                      </div>
                    </div>
                  );
                })}

                {visible.length === 0 ? (
                  <p className="px-2 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
                    Nothing pinned under this category yet.
                  </p>
                ) : null}
              </div>

              <p className="border-t border-slate-200 px-4 py-3 text-[11px] leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-400">
                Arrow keys move through the list, Home and End jump to the ends, and every pin on
                the map is a real button you can tab to.
              </p>
            </div>
          </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 →