Web InnoventixFreeCode

Map Card

Original · free

location card with a hand-drawn SVG map

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

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

const VB_W = 400;
const VB_H = 300;

type PoiKind = "studio" | "transit" | "spot";

type Poi = {
  id: string;
  x: number;
  y: number;
  kind: PoiKind;
  label: string;
  note: string;
};

type MapLabel = {
  x: number;
  y: number;
  rotate: number;
  text: string;
};

type Place = {
  id: string;
  tab: string;
  name: string;
  city: string;
  country: string;
  address: string[];
  hours: string;
  coords: string;
  travel: string;
  mapsUrl: string;
  water: string;
  parks: string[];
  blocks: string[];
  streets: string[];
  rails: string[];
  bridges: string[];
  route: string;
  labels: MapLabel[];
  pois: Poi[];
};

const PLACES: Place[] = [
  {
    id: "lisbon",
    tab: "Lisbon",
    name: "Bica Studio",
    city: "Lisbon",
    country: "Portugal",
    address: ["Rua da Bica de Duarte Belo 42", "1200-054 Lisboa, Portugal"],
    hours: "Mon–Fri · 09:00 – 18:00 WET",
    coords: "38.7100° N, 9.1450° W",
    travel: "7 min uphill walk from Cais do Sodré",
    mapsUrl: "https://www.google.com/maps/search/?api=1&query=38.7100,-9.1450",
    water: "M 0 244 C 110 238 270 231 400 226 L 400 300 L 0 300 Z",
    parks: ["M 34 38 L 118 30 L 126 88 L 42 96 Z"],
    blocks: [
      "M 98 70 L 174 64 L 178 120 L 102 126 Z",
      "M 206 62 L 288 56 L 292 112 L 210 118 Z",
      "M 110 140 L 180 134 L 184 188 L 114 194 Z",
      "M 214 132 L 292 126 L 298 182 L 218 188 Z",
      "M 320 54 L 392 48 L 398 174 L 326 180 Z",
    ],
    streets: [
      "M 0 58 L 400 44",
      "M 0 128 L 400 116",
      "M 0 200 L 400 188",
      "M 88 0 L 104 240",
      "M 190 0 L 204 236",
      "M 302 0 L 314 230",
      "M 12 236 L 190 58",
    ],
    rails: ["M 0 224 L 400 212"],
    bridges: [],
    route: "M 128 224 L 152 198 L 196 192 L 200 132",
    labels: [
      { x: 300, y: 272, rotate: -3, text: "RIO TEJO" },
      { x: 62, y: 68, rotate: -5, text: "JARDIM" },
    ],
    pois: [
      {
        id: "studio",
        x: 200,
        y: 128,
        kind: "studio",
        label: "Bica Studio",
        note: "Third floor, blue-tiled façade, buzzer marked 42-3E. The door is easy to miss — it sits between a tasca and a hardware shop.",
      },
      {
        id: "lift",
        x: 104,
        y: 170,
        kind: "spot",
        label: "Elevador da Bica",
        note: "The 1892 funicular still runs every 15 minutes. Cheaper than the hill on a hot afternoon, and it stops one street below us.",
      },
      {
        id: "view",
        x: 62,
        y: 66,
        kind: "spot",
        label: "Miradouro de Santa Catarina",
        note: "Where the team takes visiting clients at 18:00. Bring a coffee; the terrace has no service after five.",
      },
      {
        id: "station",
        x: 128,
        y: 224,
        kind: "transit",
        label: "Cais do Sodré station",
        note: "Green line metro, the Cascais train, and the Cacilhas ferry all land here. Exit toward Rua do Alecrim, then walk uphill.",
      },
    ],
  },
  {
    id: "copenhagen",
    tab: "Copenhagen",
    name: "Brygge Workshop",
    city: "Copenhagen",
    country: "Denmark",
    address: ["Njalsgade 21C, second floor", "2300 København S, Denmark"],
    hours: "Mon–Thu · 08:30 – 17:00 CET",
    coords: "55.6620° N, 12.5860° E",
    travel: "4 min walk from Islands Brygge Metro",
    mapsUrl: "https://www.google.com/maps/search/?api=1&query=55.6620,12.5860",
    water: "M 0 196 L 400 128 L 400 176 L 0 244 Z",
    parks: ["M 252 24 L 340 18 L 348 76 L 260 84 Z"],
    blocks: [
      "M 40 60 L 118 54 L 122 112 L 44 118 Z",
      "M 146 54 L 224 48 L 228 106 L 150 112 Z",
      "M 40 140 L 116 134 L 120 176 L 44 184 Z",
      "M 148 132 L 226 126 L 230 168 L 152 176 Z",
      "M 292 190 L 372 176 L 380 226 L 300 240 Z",
    ],
    streets: [
      "M 0 42 L 400 30",
      "M 0 124 L 400 108",
      "M 60 0 L 74 182",
      "M 160 0 L 172 168",
      "M 264 0 L 274 150",
      "M 356 0 L 364 136",
      "M 0 262 L 400 208",
      "M 240 300 L 262 176",
    ],
    rails: ["M 0 16 L 400 6"],
    bridges: ["M 170 166 L 194 256"],
    route: "M 264 96 L 260 118 L 190 124 L 174 118",
    labels: [
      { x: 92, y: 226, rotate: -9, text: "HAVNELØBET" },
      { x: 292, y: 52, rotate: -4, text: "PARK" },
    ],
    pois: [
      {
        id: "studio",
        x: 172,
        y: 116,
        kind: "studio",
        label: "Brygge Workshop",
        note: "Second floor of the old brewery block. Ring the intercom for 21C — the courtyard gate closes automatically at 18:00.",
      },
      {
        id: "metro",
        x: 264,
        y: 96,
        kind: "transit",
        label: "Islands Brygge Metro",
        note: "M1 line, four minutes from Kongens Nytorv. Take the north exit and follow the canal signs westward.",
      },
      {
        id: "bridge",
        x: 182,
        y: 202,
        kind: "spot",
        label: "Bryggebroen",
        note: "The cycle-and-foot bridge over the harbour. It is the fastest route in from Vesterbro and it is never gridlocked.",
      },
      {
        id: "bath",
        x: 96,
        y: 232,
        kind: "spot",
        label: "Havnebadet",
        note: "Open-water harbour bath, free entry, lifeguarded from June. Half the studio swims here before standup in summer.",
      },
    ],
  },
  {
    id: "kyoto",
    tab: "Kyoto",
    name: "Kamo Atelier",
    city: "Kyoto",
    country: "Japan",
    address: ["Kiyamachi-dori 3F, Nakagyo-ku", "604-8004 Kyoto, Japan"],
    hours: "Tue–Sat · 10:00 – 19:00 JST",
    coords: "35.0090° N, 135.7710° E",
    travel: "3 min walk from Sanjo Station",
    mapsUrl: "https://www.google.com/maps/search/?api=1&query=35.0090,135.7710",
    water: "M 262 0 L 292 0 L 274 300 L 244 300 Z",
    parks: ["M 306 46 L 386 40 L 392 118 L 312 124 Z"],
    blocks: [
      "M 34 40 L 104 40 L 104 104 L 34 104 Z",
      "M 132 40 L 206 40 L 206 104 L 132 104 Z",
      "M 34 132 L 104 132 L 104 196 L 34 196 Z",
      "M 132 132 L 206 132 L 206 196 L 132 196 Z",
      "M 34 226 L 104 226 L 104 282 L 34 282 Z",
      "M 132 226 L 206 226 L 206 282 L 132 282 Z",
      "M 330 156 L 388 156 L 388 226 L 330 226 Z",
    ],
    streets: [
      "M 0 24 L 400 22",
      "M 0 118 L 250 118",
      "M 284 116 L 400 114",
      "M 0 210 L 248 210",
      "M 282 210 L 400 208",
      "M 26 0 L 26 300",
      "M 118 0 L 118 300",
      "M 218 0 L 218 300",
      "M 320 0 L 320 300",
      "M 372 0 L 372 300",
    ],
    rails: ["M 0 292 L 400 286"],
    bridges: ["M 240 24 L 296 24"],
    route: "M 320 40 L 288 30 L 250 34 L 224 62 L 218 118",
    labels: [
      { x: 268, y: 258, rotate: 87, text: "KAMO-GAWA" },
      { x: 348, y: 84, rotate: -2, text: "SHRINE" },
    ],
    pois: [
      {
        id: "studio",
        x: 218,
        y: 118,
        kind: "studio",
        label: "Kamo Atelier",
        note: "Third floor above a coffee stand on Kiyamachi. The stair entrance is on the alley side, not the canal side.",
      },
      {
        id: "station",
        x: 320,
        y: 40,
        kind: "transit",
        label: "Sanjo Station",
        note: "Keihan line. Use exit 6, cross Sanjo Ohashi, then turn left before the canal. Roughly 300 metres in total.",
      },
      {
        id: "market",
        x: 118,
        y: 118,
        kind: "spot",
        label: "Nishiki Market",
        note: "Four hundred metres of covered food stalls. Our standing lunch order comes from the tamagoyaki counter near the west end.",
      },
      {
        id: "river",
        x: 268,
        y: 190,
        kind: "spot",
        label: "Kamo riverbank",
        note: "The stepping stones and the grass terrace. Most of our roadmap arguments have been settled sitting on this bank.",
      },
    ],
  },
];

const PIN_STYLE: Record<PoiKind, { body: string; ring: string; chip: string }> = {
  studio: {
    body: "fill-rose-500 stroke-rose-700 dark:fill-rose-400 dark:stroke-rose-200",
    ring: "bg-rose-500/40 dark:bg-rose-400/40",
    chip: "bg-rose-600 text-white dark:bg-rose-400 dark:text-rose-950",
  },
  transit: {
    body: "fill-indigo-500 stroke-indigo-700 dark:fill-indigo-400 dark:stroke-indigo-200",
    ring: "bg-indigo-500/40 dark:bg-indigo-400/40",
    chip: "bg-indigo-600 text-white dark:bg-indigo-400 dark:text-indigo-950",
  },
  spot: {
    body: "fill-violet-500 stroke-violet-700 dark:fill-violet-400 dark:stroke-violet-200",
    ring: "bg-violet-500/40 dark:bg-violet-400/40",
    chip: "bg-violet-600 text-white dark:bg-violet-400 dark:text-violet-950",
  },
};

const KIND_LABEL: Record<PoiKind, string> = {
  studio: "Studio",
  transit: "Transit",
  spot: "Nearby",
};

function MapArt({ place }: { place: Place }) {
  return (
    <g fill="none" strokeLinecap="round" strokeLinejoin="round">
      <path
        d={place.water}
        className="fill-sky-500/25 stroke-sky-600/40 dark:fill-sky-400/15 dark:stroke-sky-300/35"
        strokeWidth={1.4}
      />
      {place.parks.map((d) => (
        <path
          key={d}
          d={d}
          className="fill-emerald-500/20 stroke-emerald-600/50 dark:fill-emerald-400/15 dark:stroke-emerald-400/40"
          strokeWidth={1.4}
        />
      ))}
      {place.blocks.map((d) => (
        <path
          key={d}
          d={d}
          className="fill-zinc-900/[0.07] stroke-zinc-600/45 dark:fill-zinc-100/[0.06] dark:stroke-zinc-400/35"
          strokeWidth={1.2}
        />
      ))}
      {place.streets.map((d) => (
        <path
          key={d}
          d={d}
          className="stroke-zinc-700/70 dark:stroke-zinc-300/55"
          strokeWidth={1.7}
        />
      ))}
      {place.bridges.map((d) => (
        <path
          key={d}
          d={d}
          className="stroke-zinc-800/85 dark:stroke-zinc-100/70"
          strokeWidth={3.4}
        />
      ))}
      {place.rails.map((d) => (
        <path
          key={d}
          d={d}
          className="stroke-zinc-500/70 dark:stroke-zinc-400/50"
          strokeWidth={4}
          strokeDasharray="1 6"
          strokeLinecap="butt"
        />
      ))}
      {place.labels.map((l) => (
        <text
          key={l.text}
          x={l.x}
          y={l.y}
          textAnchor="middle"
          transform={`rotate(${l.rotate} ${l.x} ${l.y})`}
          className="fill-zinc-500/80 dark:fill-zinc-400/70"
          style={{ fontSize: 7.5, letterSpacing: "0.22em", fontWeight: 600 }}
        >
          {l.text}
        </text>
      ))}
    </g>
  );
}

export default function MapCard() {
  const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced ?? false;

  const [placeIndex, setPlaceIndex] = useState(0);
  const [poiId, setPoiId] = useState<string>(PLACES[0].pois[0].id);
  const [showRoute, setShowRoute] = useState(true);
  const [copied, setCopied] = useState(false);

  const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  const place = PLACES[placeIndex];
  const poi = place.pois.find((p) => p.id === poiId) ?? place.pois[0];

  function selectPlace(next: number) {
    const i = (next + PLACES.length) % PLACES.length;
    setPlaceIndex(i);
    setPoiId(PLACES[i].pois[0].id);
    setCopied(false);
    tabRefs.current[i]?.focus();
  }

  function onTabKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
    if (event.key === "ArrowRight" || event.key === "ArrowDown") {
      event.preventDefault();
      selectPlace(placeIndex + 1);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
      event.preventDefault();
      selectPlace(placeIndex - 1);
    } else if (event.key === "Home") {
      event.preventDefault();
      selectPlace(0);
    } else if (event.key === "End") {
      event.preventDefault();
      selectPlace(PLACES.length - 1);
    }
  }

  async function copyAddress() {
    const text = `${place.name}, ${place.address.join(", ")}`;
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      if (copyTimer.current) clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 2200);
    } catch {
      setCopied(false);
    }
  }

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-50 dark:focus-visible:ring-offset-zinc-900";

  return (
    <section className="relative w-full bg-zinc-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-zinc-950">
      <style>{`
        @keyframes mapcard-boil {
          0%, 33.32% { opacity: 1; }
          33.33%, 100% { opacity: 0; }
        }
        @keyframes mapcard-march {
          from { stroke-dashoffset: 0; }
          to { stroke-dashoffset: -26; }
        }
        @keyframes mapcard-drop {
          0% { transform: translateY(-12px) scale(0.8); opacity: 0; }
          62% { transform: translateY(2px) scale(1.05); opacity: 1; }
          100% { transform: translateY(0) scale(1); opacity: 1; }
        }
        @keyframes mapcard-ping {
          0% { transform: scale(0.55); opacity: 0.6; }
          70%, 100% { transform: scale(2.4); opacity: 0; }
        }
        .mapcard-boil { animation: mapcard-boil 0.66s linear infinite; }
        .mapcard-march { animation: mapcard-march 1.1s linear infinite; }
        .mapcard-drop { animation: mapcard-drop 0.5s cubic-bezier(0.22, 1.2, 0.36, 1) backwards; }
        .mapcard-ping { animation: mapcard-ping 2.4s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .mapcard-boil, .mapcard-march, .mapcard-drop, .mapcard-ping {
            animation: none !important;
          }
          .mapcard-layer-alt { display: none !important; }
          .mapcard-ping { opacity: 0 !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <div className="max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-600 dark:text-indigo-400">
            Where we work
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
            Three studios, drawn by hand
          </h2>
          <p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            We gave up on satellite tiles. Every location gets a map someone actually
            sketched — the stairwell that is hard to find, the bridge that is faster than
            the road, the bench where the argument gets settled.
          </p>
        </div>

        <div
          role="tablist"
          aria-label="Studio locations"
          className="mt-8 inline-flex gap-1 rounded-full border border-zinc-900/10 bg-white p-1 dark:border-zinc-100/10 dark:bg-zinc-900"
        >
          {PLACES.map((p, i) => {
            const active = i === placeIndex;
            return (
              <button
                key={p.id}
                ref={(el) => {
                  tabRefs.current[i] = el;
                }}
                type="button"
                role="tab"
                id={`${uid}-tab-${p.id}`}
                aria-selected={active}
                aria-controls={`${uid}-panel-${p.id}`}
                tabIndex={active ? 0 : -1}
                onClick={() => selectPlace(i)}
                onKeyDown={onTabKeyDown}
                className={`rounded-full px-4 py-2 text-sm font-medium transition-colors 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-zinc-900 ${
                  active
                    ? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
                    : "text-zinc-600 hover:bg-zinc-900/5 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-100/10 dark:hover:text-zinc-100"
                }`}
              >
                {p.tab}
              </button>
            );
          })}
        </div>

        <div
          role="tabpanel"
          id={`${uid}-panel-${place.id}`}
          aria-labelledby={`${uid}-tab-${place.id}`}
          tabIndex={-1}
          className="mt-5 overflow-hidden rounded-3xl border border-zinc-900/10 bg-amber-50 shadow-xl shadow-zinc-900/[0.06] focus-visible:outline-none dark:border-zinc-100/10 dark:bg-zinc-900 dark:shadow-black/40"
        >
          <div className="grid lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
            {/* Map */}
            <div className="p-4 sm:p-6">
              <div className="relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-dashed border-zinc-900/25 bg-amber-100/70 dark:border-zinc-100/20 dark:bg-zinc-950">
                <svg
                  viewBox={`0 0 ${VB_W} ${VB_H}`}
                  className="absolute inset-0 h-full w-full"
                  role="img"
                  aria-label={`Hand-drawn map of ${place.city} showing ${place.name} and nearby landmarks`}
                >
                  <defs>
                    {[0, 1, 2].map((i) => (
                      <filter
                        key={i}
                        id={`${uid}-rough-${i}`}
                        x="-6%"
                        y="-6%"
                        width="112%"
                        height="112%"
                        filterUnits="objectBoundingBox"
                      >
                        <feTurbulence
                          type="fractalNoise"
                          baseFrequency="0.035"
                          numOctaves={2}
                          seed={3 + i * 13}
                          result={`n${i}`}
                        />
                        <feDisplacementMap
                          in="SourceGraphic"
                          in2={`n${i}`}
                          scale={3.2}
                          xChannelSelector="R"
                          yChannelSelector="G"
                        />
                      </filter>
                    ))}
                    <filter id={`${uid}-grain`} x="0" y="0" width="100%" height="100%">
                      <feTurbulence
                        type="fractalNoise"
                        baseFrequency="0.85"
                        numOctaves={3}
                        seed={9}
                        result="grain"
                      />
                      <feColorMatrix in="grain" type="saturate" values="0" />
                    </filter>
                  </defs>

                  {[0, 1, 2].map((i) => (
                    <g
                      key={`${place.id}-layer-${i}`}
                      filter={`url(#${uid}-rough-${i})`}
                      className={
                        reduce
                          ? i === 0
                            ? undefined
                            : "hidden"
                          : `mapcard-boil${i > 0 ? " mapcard-layer-alt" : ""}`
                      }
                      style={reduce ? undefined : { animationDelay: `${i * 0.22}s` }}
                    >
                      <MapArt place={place} />
                    </g>
                  ))}

                  <g filter={`url(#${uid}-rough-0)`} fill="none" strokeLinecap="round">
                    {showRoute ? (
                      <path
                        d={place.route}
                        className="stroke-rose-600 dark:stroke-rose-400"
                        strokeWidth={2.6}
                        strokeDasharray="7 6"
                        opacity={0.95}
                      />
                    ) : null}
                    {showRoute ? (
                      <path
                        d={place.route}
                        className="mapcard-march stroke-amber-50 dark:stroke-zinc-950"
                        strokeWidth={2.6}
                        strokeDasharray="7 6"
                        strokeDashoffset={0}
                        opacity={0.55}
                      />
                    ) : null}

                    {/* Compass */}
                    <g className="stroke-zinc-600/70 dark:stroke-zinc-400/60" strokeWidth={1.3}>
                      <circle cx={368} cy={266} r={15} />
                      <path d="M 368 256 L 372 268 L 368 265 L 364 268 Z" className="fill-zinc-700/80 stroke-none dark:fill-zinc-300/70" />
                      <path d="M 368 266 L 368 278" />
                    </g>
                    <text
                      x={368}
                      y={251}
                      textAnchor="middle"
                      className="fill-zinc-600/90 stroke-none dark:fill-zinc-400/80"
                      style={{ fontSize: 8, fontWeight: 700, letterSpacing: "0.1em" }}
                    >
                      N
                    </text>

                    {/* Scale bar */}
                    <g className="stroke-zinc-600/70 dark:stroke-zinc-400/60" strokeWidth={1.3}>
                      <path d="M 20 282 L 80 282" />
                      <path d="M 20 277 L 20 287" />
                      <path d="M 50 279 L 50 285" />
                      <path d="M 80 277 L 80 287" />
                    </g>
                    <text
                      x={20}
                      y={273}
                      className="fill-zinc-600/90 stroke-none dark:fill-zinc-400/80"
                      style={{ fontSize: 7.5, letterSpacing: "0.14em", fontWeight: 600 }}
                    >
                      200 m
                    </text>
                  </g>

                  <rect
                    x="0"
                    y="0"
                    width={VB_W}
                    height={VB_H}
                    filter={`url(#${uid}-grain)`}
                    className="opacity-[0.13] mix-blend-multiply dark:opacity-[0.09] dark:mix-blend-screen"
                    style={{ pointerEvents: "none" }}
                  />
                </svg>

                {place.pois.map((p, i) => {
                  const selected = p.id === poi.id;
                  const style = PIN_STYLE[p.kind];
                  return (
                    <button
                      key={`${place.id}-${p.id}`}
                      type="button"
                      aria-pressed={selected}
                      onClick={() => setPoiId(p.id)}
                      className={`group absolute flex h-9 w-9 -translate-x-1/2 -translate-y-full items-end justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-100 dark:focus-visible:ring-offset-zinc-950 ${
                        selected ? "z-20" : "z-10"
                      }`}
                      style={{
                        left: `${(p.x / VB_W) * 100}%`,
                        top: `${(p.y / VB_H) * 100}%`,
                      }}
                    >
                      <span
                        className={`pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded-full px-2 py-0.5 text-[10px] font-semibold shadow-sm transition-opacity duration-150 ${style.chip} ${
                          selected
                            ? "opacity-100"
                            : "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
                        }`}
                      >
                        {p.label}
                      </span>
                      {selected ? (
                        <span
                          aria-hidden="true"
                          className={`mapcard-ping pointer-events-none absolute bottom-1 left-1/2 h-6 w-6 -translate-x-1/2 rounded-full ${style.ring}`}
                        />
                      ) : null}
                      <svg
                        viewBox="0 0 24 32"
                        className={`mapcard-drop relative h-7 w-[21px] transition-transform duration-150 group-hover:-translate-y-0.5 ${
                          selected ? "-translate-y-0.5" : ""
                        }`}
                        style={{ animationDelay: `${i * 80}ms` }}
                        aria-hidden="true"
                      >
                        <path
                          d="M12 30.5C12 30.5 21.5 19.4 21.5 11.6C21.5 6 17.2 1.5 12 1.5C6.8 1.5 2.5 6 2.5 11.6C2.5 19.4 12 30.5 12 30.5Z"
                          className={style.body}
                          strokeWidth={1.6}
                          strokeLinejoin="round"
                        />
                        <circle
                          cx="12"
                          cy="11.4"
                          r="3.5"
                          className="fill-amber-50 dark:fill-zinc-900"
                        />
                      </svg>
                    </button>
                  );
                })}
              </div>

              <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
                <label className="group inline-flex cursor-pointer select-none items-center gap-2.5">
                  <input
                    type="checkbox"
                    className="peer sr-only"
                    checked={showRoute}
                    onChange={(e) => setShowRoute(e.target.checked)}
                  />
                  <span
                    className={`relative h-5 w-9 rounded-full transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-amber-50 dark:peer-focus-visible:ring-offset-zinc-900 ${
                      showRoute
                        ? "bg-rose-600 dark:bg-rose-500"
                        : "bg-zinc-900/20 dark:bg-zinc-100/20"
                    }`}
                  >
                    <span
                      className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform dark:bg-zinc-950 ${
                        showRoute ? "translate-x-4" : "translate-x-0"
                      }`}
                    />
                  </span>
                  <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
                    Walking route from transit
                  </span>
                </label>
                <p className="text-xs text-zinc-500 dark:text-zinc-500">
                  Tap a pin for directions detail
                </p>
              </div>
            </div>

            {/* Details */}
            <div className="border-t border-zinc-900/10 bg-white/60 p-6 sm:p-8 lg:border-l lg:border-t-0 dark:border-zinc-100/10 dark:bg-zinc-950/40">
              <h3 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
                {place.name}
              </h3>
              <p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
                {place.city}, {place.country}
              </p>

              <address className="mt-5 space-y-0.5 text-sm not-italic leading-relaxed text-zinc-700 dark:text-zinc-300">
                {place.address.map((line) => (
                  <p key={line}>{line}</p>
                ))}
              </address>

              <dl className="mt-6 space-y-3 border-t border-zinc-900/10 pt-5 text-sm dark:border-zinc-100/10">
                <div className="flex gap-4">
                  <dt className="w-24 shrink-0 text-zinc-500 dark:text-zinc-500">Hours</dt>
                  <dd className="text-zinc-800 dark:text-zinc-200">{place.hours}</dd>
                </div>
                <div className="flex gap-4">
                  <dt className="w-24 shrink-0 text-zinc-500 dark:text-zinc-500">Getting in</dt>
                  <dd className="text-zinc-800 dark:text-zinc-200">{place.travel}</dd>
                </div>
                <div className="flex gap-4">
                  <dt className="w-24 shrink-0 text-zinc-500 dark:text-zinc-500">Coordinates</dt>
                  <dd className="font-mono text-xs text-zinc-800 dark:text-zinc-200">
                    {place.coords}
                  </dd>
                </div>
              </dl>

              <div className="mt-6 min-h-[9.5rem] rounded-2xl border border-dashed border-zinc-900/20 bg-amber-100/50 p-4 dark:border-zinc-100/15 dark:bg-zinc-900/60">
                <AnimatePresence mode="wait" initial={false}>
                  <motion.div
                    key={`${place.id}-${poi.id}`}
                    initial={reduce ? false : { opacity: 0, y: 6 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={reduce ? { opacity: 1 } : { opacity: 0, y: -6 }}
                    transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
                  >
                    <div className="flex items-center gap-2">
                      <span
                        aria-hidden="true"
                        className={`h-2 w-2 rounded-full ${
                          poi.kind === "studio"
                            ? "bg-rose-500"
                            : poi.kind === "transit"
                              ? "bg-indigo-500"
                              : "bg-violet-500"
                        }`}
                      />
                      <span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-zinc-500 dark:text-zinc-500">
                        {KIND_LABEL[poi.kind]}
                      </span>
                    </div>
                    <p className="mt-2 text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                      {poi.label}
                    </p>
                    <p className="mt-1.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
                      {poi.note}
                    </p>
                  </motion.div>
                </AnimatePresence>
              </div>

              <p role="status" aria-live="polite" className="sr-only">
                {copied ? `Address for ${place.name} copied to clipboard` : ""}
              </p>

              <div className="mt-6 flex flex-wrap gap-2">
                <button
                  type="button"
                  onClick={copyAddress}
                  className={`inline-flex items-center gap-2 rounded-full bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 ${focusRing}`}
                >
                  {copied ? (
                    <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
                      <path d="M4 10.5 8 14.5 16 5.5" />
                    </svg>
                  ) : (
                    <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
                      <rect x="7" y="7" width="10" height="11" rx="2" />
                      <path d="M13 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2" />
                    </svg>
                  )}
                  {copied ? "Copied" : "Copy address"}
                </button>
                <a
                  href={place.mapsUrl}
                  target="_blank"
                  rel="noopener"
                  className={`inline-flex items-center gap-2 rounded-full border border-zinc-900/15 px-4 py-2 text-sm font-medium text-zinc-800 transition-colors hover:bg-zinc-900/5 dark:border-zinc-100/15 dark:text-zinc-200 dark:hover:bg-zinc-100/10 ${focusRing}`}
                >
                  Open directions
                  <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
                    <path d="M7 13 13 7" />
                    <path d="M8 7h5v5" />
                  </svg>
                  <span className="sr-only">(opens in a new tab)</span>
                </a>
              </div>
            </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 →