Web InnoventixFreeCode

Map Contact

Original · free

contact section with map and details

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

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

type ModeId = "metro" | "car" | "bike" | "walk";

type Rect = { x: number; y: number; w: number; h: number };
type Point = readonly [number, number];

type Mode = {
  id: ModeId;
  label: string;
  eta: string;
  detail: string;
  poi: { x: number; y: number; name: string };
  points: readonly Point[];
  steps: readonly string[];
};

const VB_W = 840;
const VB_H = 560;

const STUDIO: Point = [470, 168];

const COLS_R: readonly Point[] = [
  [0, 120],
  [120, 160],
  [280, 160],
  [440, 160],
  [600, 140],
  [740, 100],
];

const ROWS_R: readonly Point[] = [
  [0, 90],
  [90, 110],
  [200, 120],
  [320, 120],
];

const PARK: Rect = { x: 128, y: 208, w: 144, h: 104 };

const BLOCKS: Rect[] = [];
for (const [x, w] of COLS_R) {
  for (const [y, h] of ROWS_R) {
    if (x === 120 && y === 200) continue;
    BLOCKS.push({ x: x + 8, y: y + 8, w: w - 16, h: h - 16 });
  }
}

function noise(seed: number): number {
  const s = Math.sin(seed * 12.9898) * 43758.5453;
  return s - Math.floor(s);
}

const BUILDINGS: Rect[] = [];
BLOCKS.forEach((b, i) => {
  for (let k = 0; k < 3; k++) {
    const a = noise(i * 7 + k * 13 + 1);
    const c = noise(i * 11 + k * 5 + 2);
    const w = b.w * (0.16 + a * 0.2);
    const h = b.h * (0.16 + c * 0.22);
    BUILDINGS.push({
      x: b.x + 5 + c * (b.w - w - 10),
      y: b.y + 5 + a * (b.h - h - 10),
      w,
      h,
    });
  }
});

const TREES: Point[] = [];
for (let i = 0; i < 14; i++) {
  TREES.push([
    PARK.x + 14 + noise(i * 3 + 5) * (PARK.w - 28),
    PARK.y + 14 + noise(i * 9 + 7) * (PARK.h - 28),
  ]);
}

const MODE_ORDER: readonly ModeId[] = ["metro", "car", "bike", "walk"];

const MODE_MAP: Record<ModeId, Mode> = {
  metro: {
    id: "metro",
    label: "Metro",
    eta: "6 min",
    detail: "Green line · 300 m walk",
    poi: { x: 120, y: 440, name: "Cais do Sodré" },
    points: [
      [120, 440],
      [120, 200],
      [470, 200],
      [470, 168],
    ],
    steps: [
      "Ride the green line to Cais do Sodré — it is the last stop, so you cannot overshoot it.",
      "Leave by the Rua do Alecrim exit and cross at the lights, not the tram tracks.",
      "Climb Alecrim for two blocks, then turn right into Rua da Boavista. No. 87 is 40 m along on your left.",
    ],
  },
  car: {
    id: "car",
    label: "Car",
    eta: "9 min",
    detail: "Paid garage · €1.60 per hour",
    poi: { x: 740, y: 320, name: "Garagem da Ribeira" },
    points: [
      [740, 320],
      [740, 200],
      [470, 200],
      [470, 168],
    ],
    steps: [
      "Boavista is one-way heading west, so approach from Rua Nova do Carvalho rather than the avenue.",
      "There is no street parking. Use Garagem da Ribeira — the ramp is beside the fruit shop, level −2 is usually free.",
      "Walk two blocks west along Rua de São Paulo, then right at the tile shop.",
    ],
  },
  bike: {
    id: "bike",
    label: "Bike",
    eta: "4 min",
    detail: "12 stands · downhill on the way back",
    poi: { x: 280, y: 90, name: "Dock 214 · Chiado" },
    points: [
      [280, 90],
      [280, 200],
      [470, 200],
      [470, 168],
    ],
    steps: [
      "Dock at stand 214 on Largo do Chiado — twelve stands, rarely full before 10:00.",
      "Roll down Rua do Alecrim. The cobbles are polished, so cross the tram tracks at an angle.",
      "If you would rather bring the bike up, we keep two hooks in the entrance hall. Ask at the buzzer for the code.",
    ],
  },
  walk: {
    id: "walk",
    label: "On foot",
    eta: "5 min",
    detail: "Flat · 400 m · cobbled",
    poi: { x: 600, y: 440, name: "Praça de São Paulo" },
    points: [
      [600, 440],
      [600, 320],
      [440, 320],
      [440, 200],
      [470, 200],
      [470, 168],
    ],
    steps: [
      "Start at Praça de São Paulo, by the church steps where the flower stall sets up.",
      "Head west on Rua de São Paulo — flat and shaded the whole way, but cobbled, so leave the heels at home.",
      "Turn right at Rua do Ferragial, then left onto Boavista. Blue door, buzzer 3, second floor.",
    ],
  },
};

const ZOOM_LEVELS: readonly number[] = [1, 1.3, 1.7, 2.2];

const ADDRESS_LINES: readonly string[] = ["Northbeam Studio", "Rua da Boavista 87, 2.º", "1200-068 Lisboa, Portugal"];
const HOURS: readonly (readonly [string, string])[] = [
  ["Mon – Thu", "09:00 – 18:00"],
  ["Friday", "09:00 – 15:00"],
  ["Sat – Sun", "By arrangement"],
];
const ADDRESS_ONE_LINE = "Northbeam Studio, Rua da Boavista 87, 2.º, 1200-068 Lisboa, Portugal";
const OSM_URL = "https://www.openstreetmap.org/?mlat=38.7093&mlon=-9.1447#map=18/38.7093/-9.1447";

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

function toPath(points: readonly Point[]): string {
  return points.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x},${y}`).join(" ");
}

function focusOf(points: readonly Point[]): { x: number; y: number } {
  let minX = Number.POSITIVE_INFINITY;
  let minY = Number.POSITIVE_INFINITY;
  let maxX = Number.NEGATIVE_INFINITY;
  let maxY = Number.NEGATIVE_INFINITY;
  for (const [x, y] of points) {
    minX = Math.min(minX, x);
    minY = Math.min(minY, y);
    maxX = Math.max(maxX, x);
    maxY = Math.max(maxY, y);
  }
  return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
}

function ModeIcon({ id, className }: { id: ModeId; className?: string }) {
  const common = {
    className,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.6,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    "aria-hidden": true,
  };
  if (id === "metro") {
    return (
      <svg {...common}>
        <path d="M6 5.5A2.5 2.5 0 0 1 8.5 3h7A2.5 2.5 0 0 1 18 5.5v9a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 6 14.5z" />
        <path d="M6 10h12" />
        <path d="M9.5 13.6h.01M14.5 13.6h.01" />
        <path d="M8.5 17 6.5 21M15.5 17l2 4" />
      </svg>
    );
  }
  if (id === "car") {
    return (
      <svg {...common}>
        <path d="M3.5 16.5v-3.6c0-.3.07-.6.2-.87l2.05-4.1A2 2 0 0 1 7.54 6.8h8.92a2 2 0 0 1 1.79 1.1l2.05 4.13c.13.27.2.57.2.87v3.6a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z" />
        <path d="M5.6 12.4h12.8" />
        <path d="M6.6 14.6h1.4M16 14.6h1.4" />
        <path d="M5.5 17.5V19M18.5 17.5V19" />
      </svg>
    );
  }
  if (id === "bike") {
    return (
      <svg {...common}>
        <circle cx="5.6" cy="16.4" r="3.6" />
        <circle cx="18.4" cy="16.4" r="3.6" />
        <path d="M5.6 16.4 9.8 8.6h3.8l3.4 7.8" />
        <path d="M9.2 6.6h3.2M12.6 12.9h-4" />
      </svg>
    );
  }
  return (
    <svg {...common}>
      <circle cx="13.4" cy="4.4" r="1.7" />
      <path d="M11.4 21.2l1.3-5.9-2.8-2.4V9.2l3.6-1.6 2.3 3.1 2.6 1" />
      <path d="M9.7 12.1 8 15.5l-2.6 1.8" />
      <path d="M12.7 15.3l2.9 2.6.8 3.3" />
    </svg>
  );
}

export default function MapContact() {
  const reduce = useReducedMotion();
  const [activeId, setActiveId] = useState<ModeId>("metro");
  const [zoomIndex, setZoomIndex] = useState<number>(0);
  const [copied, setCopied] = useState<boolean>(false);
  const copyTimer = useRef<number | null>(null);
  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);

  const active = MODE_MAP[activeId];
  const zoom = ZOOM_LEVELS[zoomIndex] ?? 1;

  const view = useMemo(() => {
    const f = focusOf(active.points);
    const tx = clamp(VB_W / 2 - f.x * zoom, VB_W * (1 - zoom), 0);
    const ty = clamp(VB_H / 2 - f.y * zoom, VB_H * (1 - zoom), 0);
    return { tx, ty };
  }, [active, zoom]);

  const routeD = useMemo(() => toPath(active.points), [active]);

  const transition = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 110, damping: 20, mass: 0.9 };

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

  const copyAddress = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(ADDRESS_ONE_LINE);
      setCopied(true);
      if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
      copyTimer.current = window.setTimeout(() => setCopied(false), 2200);
    } catch {
      setCopied(false);
    }
  }, []);

  const onOptionKeyDown = useCallback(
    (event: KeyboardEvent<HTMLButtonElement>) => {
      const current = MODE_ORDER.indexOf(activeId);
      let next = -1;
      if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (current + 1) % MODE_ORDER.length;
      else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (current - 1 + MODE_ORDER.length) % MODE_ORDER.length;
      else if (event.key === "Home") next = 0;
      else if (event.key === "End") next = MODE_ORDER.length - 1;
      if (next === -1) return;
      event.preventDefault();
      const id = MODE_ORDER[next] ?? "metro";
      setActiveId(id);
      optionRefs.current[next]?.focus();
    },
    [activeId],
  );

  const ring =
    "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-950";

  return (
    <section className="relative w-full bg-white px-4 py-20 text-zinc-900 sm:px-6 sm:py-28 lg:px-8 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
        @keyframes mcnt-pulse {
          0%   { transform: translate(-50%, -50%) scale(1);   opacity: 0.55; }
          70%  { transform: translate(-50%, -50%) scale(3.6); opacity: 0; }
          100% { transform: translate(-50%, -50%) scale(3.6); opacity: 0; }
        }
        @keyframes mcnt-flow {
          to { stroke-dashoffset: -48; }
        }
        .mcnt-pulse { animation: mcnt-pulse 2.6s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
        .mcnt-flow  { stroke-dasharray: 10 14; animation: mcnt-flow 1.4s linear infinite; }
        @media (prefers-reduced-motion: reduce) {
          .mcnt-pulse { animation: none; opacity: 0.4; }
          .mcnt-flow  { animation: none; }
        }
      `}</style>

      <div className="mx-auto 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">
            Find us
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
            Rua da Boavista 87 — the blue door beside the tile shop
          </h2>
          <p className="mt-4 text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
            Northbeam is six people on a second floor in Cais do Sodré, above a tile shop that has been trading since
            1948. Buzzer 3. Pick how you are travelling and we will tell you the part the map app leaves out.
          </p>
        </header>

        <div className="mt-12 grid gap-8 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)] lg:items-start">
          <div>
            <div
              role="radiogroup"
              aria-label="How are you getting here?"
              className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-2"
            >
              {MODE_ORDER.map((id, index) => {
                const mode = MODE_MAP[id];
                const selected = id === activeId;
                return (
                  <button
                    key={id}
                    ref={(el) => {
                      optionRefs.current[index] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setActiveId(id)}
                    onKeyDown={onOptionKeyDown}
                    className={`${ring} flex flex-col items-start gap-2 rounded-xl border p-3 text-left transition-colors ${
                      selected
                        ? "border-indigo-500 bg-indigo-50 text-indigo-950 dark:border-indigo-400/70 dark:bg-indigo-500/10 dark:text-indigo-100"
                        : "border-zinc-200 bg-white text-zinc-700 hover:border-zinc-300 hover:bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900/60 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-900"
                    }`}
                  >
                    <ModeIcon
                      id={id}
                      className={`h-5 w-5 ${selected ? "text-indigo-600 dark:text-indigo-300" : "text-zinc-400 dark:text-zinc-500"}`}
                    />
                    <span className="text-sm font-medium">{mode.label}</span>
                    <span
                      className={`text-xs tabular-nums ${selected ? "text-indigo-700/80 dark:text-indigo-300/80" : "text-zinc-500 dark:text-zinc-500"}`}
                    >
                      {mode.eta} to the door
                    </span>
                  </button>
                );
              })}
            </div>

            <div
              aria-live="polite"
              className="mt-6 rounded-2xl border border-zinc-200 bg-zinc-50/70 p-5 dark:border-zinc-800 dark:bg-zinc-900/50"
            >
              <div className="flex items-center gap-2">
                <span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-white">
                  <ModeIcon id={active.id} className="h-3.5 w-3.5" />
                </span>
                <p className="text-sm font-semibold">From {active.poi.name}</p>
              </div>
              <p className="mt-1.5 text-xs text-zinc-500 dark:text-zinc-500">{active.detail}</p>
              <ol className="mt-4 space-y-3">
                {active.steps.map((step, i) => (
                  <li key={step} className="flex gap-3">
                    <span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-zinc-300 text-[11px] font-semibold tabular-nums text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
                      {i + 1}
                    </span>
                    <span className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{step}</span>
                  </li>
                ))}
              </ol>
            </div>
          </div>

          <div className="relative aspect-[3/2] w-full overflow-hidden rounded-2xl border border-zinc-200 bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-900">
            <motion.div
              className="absolute inset-0"
              style={{ transformOrigin: "0px 0px" }}
              animate={{ x: `${(view.tx / VB_W) * 100}%`, y: `${(view.ty / VB_H) * 100}%`, scale: zoom }}
              transition={transition}
            >
              <svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="absolute inset-0 h-full w-full" aria-hidden="true">
                <rect width={VB_W} height={VB_H} className="fill-zinc-200 dark:fill-zinc-800" />

                {BLOCKS.map((b) => (
                  <rect
                    key={`b-${b.x}-${b.y}`}
                    x={b.x}
                    y={b.y}
                    width={b.w}
                    height={b.h}
                    rx="4"
                    className="fill-zinc-50 dark:fill-zinc-900"
                  />
                ))}

                {BUILDINGS.map((b, i) => (
                  <rect
                    key={`h-${i}`}
                    x={b.x}
                    y={b.y}
                    width={b.w}
                    height={b.h}
                    rx="2"
                    className="fill-zinc-200/70 dark:fill-zinc-800/80"
                  />
                ))}

                <rect
                  x={PARK.x}
                  y={PARK.y}
                  width={PARK.w}
                  height={PARK.h}
                  rx="8"
                  className="fill-emerald-100 dark:fill-emerald-950"
                />
                {TREES.map(([tx, ty], i) => (
                  <circle key={`t-${i}`} cx={tx} cy={ty} r="4.5" className="fill-emerald-300/80 dark:fill-emerald-800" />
                ))}
                <text
                  x={PARK.x + PARK.w / 2}
                  y={PARK.y + PARK.h / 2 + 4}
                  textAnchor="middle"
                  className="fill-emerald-700/80 text-[10px] font-medium dark:fill-emerald-500/70"
                >
                  Jardim do Príncipe
                </text>

                <path
                  d="M0,468 C 140,452 260,486 400,470 C 540,454 680,484 840,466 L840,560 L0,560 Z"
                  className="fill-sky-100 dark:fill-sky-950"
                />
                <path
                  d="M0,468 C 140,452 260,486 400,470 C 540,454 680,484 840,466"
                  fill="none"
                  strokeWidth="2"
                  className="stroke-sky-200 dark:stroke-sky-900"
                />
                <rect x="560" y="468" width="26" height="46" rx="3" className="fill-zinc-300 dark:fill-zinc-700" />
                <rect x="660" y="472" width="18" height="34" rx="3" className="fill-zinc-300 dark:fill-zinc-700" />
                <text x="770" y="530" textAnchor="end" className="fill-sky-500/80 text-[11px] font-medium tracking-wide dark:fill-sky-700">
                  Rio Tejo
                </text>

                <text x="300" y="86" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
                  R. Garrett
                </text>
                <text x="500" y="196" className="fill-zinc-500 text-[9px] font-semibold dark:fill-zinc-500">
                  R. da Boavista
                </text>
                <text x="300" y="316" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
                  R. de São Paulo
                </text>
                <text x="300" y="460" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
                  Av. 24 de Julho
                </text>
                <text x="118" y="60" transform="rotate(-90 118 60)" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
                  R. do Alecrim
                </text>
                <text x="438" y="410" transform="rotate(-90 438 410)" className="fill-zinc-400 text-[9px] font-medium dark:fill-zinc-600">
                  R. do Ferragial
                </text>

                <motion.path
                  key={`casing-${active.id}`}
                  d={routeD}
                  fill="none"
                  strokeWidth="9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="stroke-indigo-500/30 dark:stroke-indigo-400/30"
                  initial={{ pathLength: reduce ? 1 : 0 }}
                  animate={{ pathLength: 1 }}
                  transition={reduce ? { duration: 0 } : { duration: 0.75, ease: "easeInOut" }}
                />
                <path
                  d={routeD}
                  fill="none"
                  strokeWidth="3"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  className="mcnt-flow stroke-indigo-600 dark:stroke-indigo-400"
                />
              </svg>

              <div
                className="absolute"
                style={{ left: `${(STUDIO[0] / VB_W) * 100}%`, top: `${(STUDIO[1] / VB_H) * 100}%` }}
              >
                <motion.div
                  className="absolute left-0 top-0"
                  style={{ transformOrigin: "0px 0px" }}
                  animate={{ scale: 1 / zoom }}
                  transition={transition}
                >
                  <span
                    className="mcnt-pulse absolute left-0 top-0 block h-3.5 w-3.5 rounded-full bg-indigo-500/50"
                    style={{ transform: "translate(-50%, -50%)" }}
                    aria-hidden="true"
                  />
                  <span
                    className="absolute left-0 top-0 block h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-indigo-600 dark:border-zinc-900"
                    aria-hidden="true"
                  />
                  <div className="relative -translate-x-1/2 -translate-y-full pb-2.5">
                    <span
                      className="absolute bottom-0 left-1/2 h-2.5 w-px -translate-x-1/2 bg-indigo-500"
                      aria-hidden="true"
                    />
                    <p className="whitespace-nowrap rounded-full bg-indigo-600 px-2.5 py-1 text-[11px] font-semibold text-white shadow-sm">
                      Northbeam Studio · No. 87
                    </p>
                  </div>
                </motion.div>
              </div>

              {MODE_ORDER.map((id) => {
                const mode = MODE_MAP[id];
                const selected = id === activeId;
                return (
                  <div
                    key={id}
                    className="absolute"
                    style={{ left: `${(mode.poi.x / VB_W) * 100}%`, top: `${(mode.poi.y / VB_H) * 100}%` }}
                  >
                    <motion.div
                      className="absolute left-0 top-0"
                      style={{ transformOrigin: "0px 0px" }}
                      animate={{ scale: 1 / zoom }}
                      transition={transition}
                    >
                      <span
                        className={`absolute left-0 top-0 block h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white dark:border-zinc-900 ${
                          selected ? "bg-indigo-600" : "bg-zinc-500 dark:bg-zinc-400"
                        }`}
                        aria-hidden="true"
                      />
                      <div className="relative -translate-x-1/2 -translate-y-full pb-2">
                        <span
                          className={`absolute bottom-0 left-1/2 h-2 w-px -translate-x-1/2 ${
                            selected ? "bg-indigo-500" : "bg-zinc-400 dark:bg-zinc-500"
                          }`}
                          aria-hidden="true"
                        />
                        <button
                          type="button"
                          aria-pressed={selected}
                          onClick={() => setActiveId(id)}
                          className={`${ring} flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-sm transition-colors ${
                            selected
                              ? "border-indigo-600 bg-indigo-600 text-white"
                              : "border-zinc-200 bg-white/95 text-zinc-700 hover:border-zinc-300 hover:bg-white dark:border-zinc-700 dark:bg-zinc-900/95 dark:text-zinc-200 dark:hover:bg-zinc-900"
                          }`}
                        >
                          <ModeIcon id={id} className="h-3.5 w-3.5" />
                          <span>{mode.poi.name}</span>
                          <span className="sr-only">— show the {mode.label.toLowerCase()} route to the studio</span>
                        </button>
                      </div>
                    </motion.div>
                  </div>
                );
              })}
            </motion.div>

            <p className="pointer-events-none absolute left-3 top-3 rounded-full border border-zinc-200 bg-white/90 px-2.5 py-1 text-[11px] font-medium text-zinc-600 backdrop-blur-sm dark:border-zinc-700 dark:bg-zinc-900/90 dark:text-zinc-300">
              Cais do Sodré · Lisboa
            </p>

            <div className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full border border-zinc-200 bg-white/90 p-1 backdrop-blur-sm dark:border-zinc-700 dark:bg-zinc-900/90">
              <button
                type="button"
                aria-label="Zoom out"
                disabled={zoomIndex === 0}
                onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
                className={`${ring} inline-flex h-7 w-7 items-center justify-center rounded-full text-zinc-600 transition-colors hover:bg-zinc-100 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800`}
              >
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
                  <path d="M6 12h12" />
                </svg>
              </button>
              <span className="w-9 text-center text-[11px] font-semibold tabular-nums text-zinc-600 dark:text-zinc-300">
                {zoom.toFixed(1)}×
              </span>
              <button
                type="button"
                aria-label="Zoom in"
                disabled={zoomIndex === ZOOM_LEVELS.length - 1}
                onClick={() => setZoomIndex((i) => Math.min(ZOOM_LEVELS.length - 1, i + 1))}
                className={`${ring} inline-flex h-7 w-7 items-center justify-center rounded-full text-zinc-600 transition-colors hover:bg-zinc-100 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-300 dark:hover:bg-zinc-800`}
              >
                <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
                  <path d="M12 6v12M6 12h12" />
                </svg>
              </button>
            </div>
          </div>
        </div>

        <div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          <div className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
            <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
              <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
                <circle cx="12" cy="10" r="2.6" />
              </svg>
              <h3 className="text-xs font-semibold uppercase tracking-[0.12em]">Studio</h3>
            </div>
            <address className="mt-3 space-y-0.5 text-sm not-italic leading-relaxed text-zinc-600 dark:text-zinc-400">
              {ADDRESS_LINES.map((line) => (
                <p key={line} className={line === "Northbeam Studio" ? "font-medium text-zinc-900 dark:text-zinc-100" : undefined}>
                  {line}
                </p>
              ))}
            </address>
            <div className="mt-4 flex flex-wrap items-center gap-2">
              <button
                type="button"
                onClick={copyAddress}
                className={`${ring} inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800`}
              >
                {copied ? (
                  <svg viewBox="0 0 24 24" className="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <path d="m5 12.5 4.5 4.5L19 7" />
                  </svg>
                ) : (
                  <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                    <rect x="9" y="9" width="11" height="11" rx="2.5" />
                    <path d="M15 5.5A1.5 1.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A1.5 1.5 0 0 0 5.5 15" />
                  </svg>
                )}
                {copied ? "Copied" : "Copy address"}
              </button>
              <a
                href={OSM_URL}
                target="_blank"
                rel="noopener"
                className={`${ring} inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10`}
              >
                Open in maps
                <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                  <path d="M8 5h11v11M19 5 6 18" />
                </svg>
              </a>
            </div>
            <p role="status" aria-live="polite" className="sr-only">
              {copied ? "Studio address copied to clipboard" : ""}
            </p>
          </div>

          <div className="rounded-2xl border border-zinc-200 bg-white p-5 dark:border-zinc-800 dark:bg-zinc-900/60">
            <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
              <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M6.5 3.5h3l1.5 4-2 1.5a12 12 0 0 0 6 6l1.5-2 4 1.5v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.7 2 2 0 0 1 6.5 3.5z" />
              </svg>
              <h3 className="text-xs font-semibold uppercase tracking-[0.12em]">Reach a human</h3>
            </div>
            <ul className="mt-3 space-y-2.5 text-sm">
              <li>
                <a
                  href="tel:+351213460912"
                  className={`${ring} -mx-1 inline-block rounded px-1 font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition-colors hover:decoration-indigo-500 dark:text-zinc-100 dark:decoration-zinc-600`}
                >
                  +351 21 346 0912
                </a>
                <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-500">Rings the studio desk, not a call centre.</p>
              </li>
              <li>
                <a
                  href="mailto:hello@northbeam.studio"
                  className={`${ring} -mx-1 inline-block rounded px-1 font-medium text-zinc-900 underline decoration-zinc-300 underline-offset-4 transition-colors hover:decoration-indigo-500 dark:text-zinc-100 dark:decoration-zinc-600`}
                >
                  hello@northbeam.studio
                </a>
                <p className="mt-0.5 text-xs text-zinc-500 dark:text-zinc-500">Answered within one working day, by a person who read it.</p>
              </li>
            </ul>
          </div>

          <div className="rounded-2xl border border-zinc-200 bg-white p-5 sm:col-span-2 lg:col-span-1 dark:border-zinc-800 dark:bg-zinc-900/60">
            <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
              <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <circle cx="12" cy="12" r="8.5" />
                <path d="M12 7.5V12l3 2" />
              </svg>
              <h3 className="text-xs font-semibold uppercase tracking-[0.12em]">Open</h3>
            </div>
            <dl className="mt-3 space-y-1.5 text-sm">
              {HOURS.map(([day, hours]) => (
                <div key={day} className="flex items-baseline justify-between gap-4 border-b border-dashed border-zinc-200 pb-1.5 last:border-0 dark:border-zinc-800">
                  <dt className="text-zinc-600 dark:text-zinc-400">{day}</dt>
                  <dd className="font-medium tabular-nums text-zinc-900 dark:text-zinc-100">{hours}</dd>
                </div>
              ))}
            </dl>
            <p className="mt-3 text-xs text-zinc-500 dark:text-zinc-500">
              Lisbon time (WET / WEST). Drop-ins welcome, though the good coffee runs out around 16:00.
            </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 →