First CH Designs
BLK-19Blocks

Access & Directions (Map + Landmark Thumbnails)

An access block that switches between arrival modes (train / bus / car) with ARIA tabs, draws the route and numbered pins on an inline-SVG schematic map, and lists landmark thumbnails — "the view you should be seeing" — beside it. It reworks the tourism-site idea of grouping places into categories and arranging photo thumbnails map-like into corporate wayfinding. Hovering or focusing a landmark highlights its pin; the route flows by staggering dot opacity with negative animation delays. No map API, no image assets, zero dependencies.

Added:
2026-08-11
Dependencies:
None
tags
#access #map #svg #directions #tabs #landmark #thumbnail #company #hairline #section #block #brand #motion #a11y #no-image #ref:asukamura-tourism #pattern:PT-LAYOUT-022

Preview

BLK-19 — Access & Directions

住所ではなく、見える景色で案内します。

はじめての場所で迷うのは、地図が読めないからではなく「いま合っているのか」が分からないからです。手段ごとに、目印になる景色を順番に並べました。

起点

「けやき通り」駅 南口

徒歩7

※ 図は位置関係を示す簡易図です。番号は右(スマートフォンでは下)の目印と対応しています。

目印になる景色

南口以外の出口からは大通りを渡ることになり、5分ほど余計にかかります。

所在地
東京都渋谷区神宮前0-0-0 First CHビル 5F
受付時間
平日 10:00–19:00(土曜は前日までのご予約制)
お電話
03-0000-0000
地図アプリで開く
"use client";

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["600", "700"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * 会社概要・アクセス(地図+目印サムネイル)
 * 参考: 明日香村観光サイト「あすか時間」(asukamura.com) の PT-LAYOUT-022
 * 「場所をカテゴリに分け、実写サムネイルを地図的に配置する情報構造」。
 * 観光地の“エリア別スポット紹介”という構造だけを借り、コーポレートの
 * 「行き方が分かる」アクセス面へ転用した(テキスト・配色・図版はすべてオリジナル。コピーではない)。
 * 既存 BLK-11 company-access(定義リスト型=会社を知る)の対で、こちらは経路特化。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 「カテゴリ別サムネイル」を経路に読み替えると、タブ(電車/バス/車)×目印カードで
 *   "その道で合っているか" を写真的に確認できる導線になる。観光の面情報 → 動線情報への転用。
 * - 地図は外部APIを使わず inline SVG で完結できる(viewBox 480x340・街区/道路/線路/経路)。
 *   道路は「太いグレーの線の上に細い白の線を重ねる」と2本描くだけで縁取り道路になる。
 * - 経路の「流れ」は stroke-dashoffset ではなく **点の opacity を負のdelayでずらす** だけで作れる。
 *   ポリライン上を等間隔サンプリングした円を並べ、animation-delay を -0.12s*i にすると
 *   進行方向へ流れる波になる。transform/opacity のみなので motion-reduce も animate-none 一発。
 * - SVG要素の拡大は transform-box: fill-box + transform-origin: center を付けないと
 *   viewBox 原点基準で飛ぶ。これを付ければ Tailwind の scale-* がそのまま使える。
 * - 「実写サムネイル」は画像ゼロでも代替できる: 120x84 の小さな viewBox に地面/建物/木/標識の
 *   シルエットを置き、主題(駅名板・バス停・Pサイン)にだけアンバーを1点入れると
 *   グレーの図の中で"何の写真か"が読める。写真差し替え前提のプレースホルダとしても使える。
 * - 目印カードは <button aria-pressed> にし、hover だけでなく focus でも地図ピンが連動するようにする。
 *   地図側は aria-hidden(装飾)で、道順は必ず <ol> のテキストとして併記する。
 */

type SceneKind =
  | "station"
  | "crossing"
  | "trees"
  | "bus"
  | "highway"
  | "parking"
  | "office";

type Spot = {
  /** 地図上の通し番号 */
  no: number;
  title: string;
  /** 「この景色が見えたら合っている」という目印の説明 */
  note: string;
  scene: SceneKind;
  /** 地図座標(viewBox 480x340) */
  x: number;
  y: number;
};

type Route = {
  id: string;
  label: string;
  en: string;
  /** 起点の表記 */
  from: string;
  /** 所要時間(数字だけ Montserrat で大きく出す) */
  minutes: string;
  means: string;
  /** 地図に描く経路 */
  path: readonly (readonly [number, number])[];
  spots: Spot[];
  caution: string;
};

const routes: Route[] = [
  {
    id: "train",
    label: "電車で",
    en: "By train",
    from: "「けやき通り」駅 南口",
    minutes: "7",
    means: "徒歩",
    path: [
      [112, 62],
      [150, 84],
      [150, 176],
      [252, 176],
      [252, 236],
    ],
    spots: [
      {
        no: 1,
        title: "駅 南口を出る",
        note: "改札を出て左手、アンバーの駅名板が下がっている出口が南口です。",
        scene: "station",
        x: 128,
        y: 70,
      },
      {
        no: 2,
        title: "けやき並木を南へ",
        note: "並木の歩道をまっすぐ。3本目のけやきの先に大きな交差点が見えます。",
        scene: "trees",
        x: 150,
        y: 130,
      },
      {
        no: 3,
        title: "交差点を左折",
        note: "横断歩道を渡らず、信号を左へ。ここから2ブロックで到着します。",
        scene: "crossing",
        x: 252,
        y: 176,
      },
    ],
    caution: "南口以外の出口からは大通りを渡ることになり、5分ほど余計にかかります。",
  },
  {
    id: "bus",
    label: "バスで",
    en: "By bus",
    from: "「けやき通り三丁目」停留所",
    minutes: "3",
    means: "徒歩",
    path: [
      [432, 176],
      [252, 176],
      [252, 236],
    ],
    spots: [
      {
        no: 1,
        title: "三丁目で降りる",
        note: "丸いバス停標識が目印。降車後は進行方向と逆(西)へ歩きます。",
        scene: "bus",
        x: 432,
        y: 176,
      },
      {
        no: 2,
        title: "交差点を直進",
        note: "信号は渡らず、そのまま歩道を直進。左手に当社の看板が出ます。",
        scene: "crossing",
        x: 252,
        y: 176,
      },
      {
        no: 3,
        title: "1F がガラス張りのビル",
        note: "エントランスの庇にアンバーのサイン。受付は5Fです。",
        scene: "office",
        x: 252,
        y: 236,
      },
    ],
    caution: "1時間に3〜4本の運行です。復路の時刻は受付でもお渡しできます。",
  },
  {
    id: "car",
    label: "お車で",
    en: "By car",
    from: "高速道路 けやき出口",
    minutes: "8",
    means: "走行",
    path: [
      [44, 300],
      [300, 300],
      [300, 262],
    ],
    spots: [
      {
        no: 1,
        title: "けやき出口を降りる",
        note: "高架をくぐって直進。案内標識は「けやき通り方面」を選びます。",
        scene: "highway",
        x: 60,
        y: 300,
      },
      {
        no: 2,
        title: "提携駐車場に停める",
        note: "3台分をご用意しています。満車のときは受付までお電話ください。",
        scene: "parking",
        x: 300,
        y: 276,
      },
      {
        no: 3,
        title: "徒歩1分でビルへ",
        note: "駐車場の西隣、1Fがガラス張りのビルです。駐車券は受付で処理します。",
        scene: "office",
        x: 252,
        y: 236,
      },
    ],
    caution: "ビル前の道路は8:00–10:00 が一方通行です。西側から進入してください。",
  },
];

/** 会社概要の要点だけ(詳細版は BLK-11 company-access 側) */
const summary: { term: string; value: string }[] = [
  { term: "所在地", value: "東京都渋谷区神宮前0-0-0 First CHビル 5F" },
  { term: "受付時間", value: "平日 10:00–19:00(土曜は前日までのご予約制)" },
  { term: "お電話", value: "03-0000-0000" },
];

/* ---------------------------------------------------------------- 地図 */

/**
 * ポリライン上を等間隔でサンプリングして「流れる点」の座標を作る。
 * acc に次の点までの残距離を持ち越すと、折れ点をまたいでも間隔が崩れない。
 */
function flowDots(points: readonly (readonly [number, number])[], step = 15) {
  const dots: { x: number; y: number }[] = [];
  let acc = 0;
  for (let i = 0; i < points.length - 1; i += 1) {
    const [x1, y1] = points[i];
    const [x2, y2] = points[i + 1];
    const len = Math.hypot(x2 - x1, y2 - y1);
    if (len === 0) continue;
    let d = acc;
    while (d < len) {
      const t = d / len;
      dots.push({ x: x1 + (x2 - x1) * t, y: y1 + (y2 - y1) * t });
      d += step;
    }
    acc = d - len;
  }
  return dots;
}

/** 街区(単位は viewBox のユーザー座標) */
const cityBlocks = [
  { x: 18, y: 88, w: 108, h: 62 },
  { x: 176, y: 82, w: 96, h: 68 },
  { x: 300, y: 76, w: 116, h: 74 },
  { x: 18, y: 202, w: 108, h: 70 },
  { x: 176, y: 202, w: 56, h: 70 },
  { x: 276, y: 202, w: 140, h: 46 },
  { x: 336, y: 262, w: 80, h: 34 },
];

function AccessMap({
  route,
  activeNo,
}: {
  route: Route;
  activeNo: number | null;
}) {
  const dots = flowDots(route.path);
  const d = route.path.map(([x, y]) => `${x},${y}`).join(" ");

  return (
    <div
      aria-hidden="true"
      className="relative aspect-[480/340] w-full overflow-hidden rounded-lg border border-gray-200 bg-[#faf9f7]"
    >
      <svg viewBox="0 0 480 340" className="h-full w-full">
        {/* 街区 */}
        {cityBlocks.map((b, i) => (
          <rect
            key={i}
            x={b.x}
            y={b.y}
            width={b.w}
            height={b.h}
            rx="3"
            className="fill-gray-200/70"
          />
        ))}

        {/* 道路: 太いグレー + 細い白 の2度描きで縁取り道路になる */}
        <g className="stroke-gray-300" strokeWidth="26" strokeLinecap="square">
          <line x1="0" y1="176" x2="480" y2="176" />
          <line x1="252" y1="150" x2="252" y2="340" />
          <line x1="150" y1="76" x2="150" y2="200" />
          <line x1="0" y1="300" x2="480" y2="300" />
        </g>
        <g className="stroke-[#fdfcfb]" strokeWidth="20" strokeLinecap="square">
          <line x1="0" y1="176" x2="480" y2="176" />
          <line x1="252" y1="150" x2="252" y2="340" />
          <line x1="150" y1="76" x2="150" y2="200" />
          <line x1="0" y1="300" x2="480" y2="300" />
        </g>

        {/* 線路(温かいグレーの破線。青灰にすると白基調から浮く) */}
        <line
          x1="0"
          y1="62"
          x2="480"
          y2="62"
          className="stroke-[#c9c4bd]"
          strokeWidth="3"
          strokeDasharray="9 7"
        />
        <rect
          x="94"
          y="52"
          width="36"
          height="20"
          rx="3"
          className="fill-white stroke-gray-400"
          strokeWidth="1.5"
        />

        {/* 経路(下地の帯 → 流れる点) */}
        <polyline
          points={d}
          fill="none"
          className="stroke-amber-500/25"
          strokeWidth="7"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
        {dots.map((p, i) => (
          <circle
            key={i}
            cx={p.x}
            cy={p.y}
            r="2.6"
            className="fill-amber-600 opacity-40 animate-[cam-flow_2.2s_ease-in-out_infinite] motion-reduce:animate-none"
            style={{ animationDelay: `${(-i * 0.12).toFixed(2)}s` }}
          />
        ))}

        {/* 到着地点(呼吸するリング) */}
        <g>
          <circle
            cx="252"
            cy="236"
            r="8"
            className="fill-amber-500/40 animate-[cam-pulse_2.8s_ease-out_infinite] motion-reduce:animate-none"
            style={{ transformBox: "fill-box", transformOrigin: "center" }}
          />
          <circle
            cx="252"
            cy="236"
            r="8"
            className="fill-amber-600 stroke-white"
            strokeWidth="2.5"
          />
        </g>

        {/* 目印ピン(transform-box: fill-box を付けると scale-* がそのまま効く) */}
        {route.spots.map((s) => {
          const on = activeNo === s.no;
          return (
            <g
              key={s.no}
              className={`transition-transform duration-300 ease-out motion-reduce:transition-none ${
                on ? "scale-125" : "scale-100"
              }`}
              style={{ transformBox: "fill-box", transformOrigin: "center" }}
            >
              <circle
                cx={s.x}
                cy={s.y}
                r="12"
                strokeWidth="1.5"
                className={
                  on
                    ? "fill-amber-600 stroke-amber-600"
                    : "fill-white stroke-gray-400"
                }
              />
              <text
                x={s.x}
                y={s.y + 4}
                textAnchor="middle"
                className={`${montserrat.className} text-[11px] font-bold ${
                  on ? "fill-white" : "fill-gray-500"
                }`}
              >
                {s.no}
              </text>
            </g>
          );
        })}
      </svg>

      <span
        className={`${montserrat.className} absolute top-2.5 left-3 text-[9px] font-semibold tracking-[0.22em] text-gray-400 uppercase`}
      >
        Around the office
      </span>
    </div>
  );
}

/* -------------------------------------------------- 目印サムネイル(画像ゼロ) */

/** 120x84 の小箱にシルエットを置き、主題にだけアンバーを1点入れる */
function Thumb({ kind }: { kind: SceneKind }) {
  return (
    <svg viewBox="0 0 120 84" className="h-full w-full">
      <rect width="120" height="84" className="fill-[#f4f2ef]" />
      <rect y="64" width="120" height="20" className="fill-gray-200" />

      {kind === "station" && (
        <>
          <rect x="16" y="20" width="88" height="44" className="fill-gray-300" />
          <rect x="10" y="14" width="100" height="8" rx="2" className="fill-gray-400/80" />
          <rect x="44" y="38" width="32" height="26" className="fill-[#faf9f7]" />
          <rect x="22" y="30" width="14" height="10" className="fill-gray-200/90" />
          <rect x="84" y="30" width="14" height="10" className="fill-gray-200/90" />
          <rect x="47" y="24" width="26" height="7" rx="1.5" className="fill-amber-600/85" />
        </>
      )}

      {kind === "trees" && (
        <>
          <rect x="0" y="52" width="120" height="12" className="fill-gray-200/70" />
          {[26, 60, 94].map((cx, i) => (
            <g key={cx}>
              <rect x={cx - 2} y={34 + i * 2} width="4" height={30 - i * 2} className="fill-gray-400/80" />
              <circle cx={cx} cy={28 + i * 2} r={17 - i * 2} className="fill-gray-300" />
            </g>
          ))}
          <rect x="52" y="66" width="16" height="4" rx="2" className="fill-amber-600/80" />
        </>
      )}

      {kind === "crossing" && (
        <>
          <rect y="40" width="120" height="30" className="fill-gray-300" />
          {[8, 26, 44, 62, 80, 98].map((x) => (
            <rect key={x} x={x} y="42" width="10" height="26" className="fill-[#faf9f7]" />
          ))}
          <rect x="96" y="10" width="4" height="34" className="fill-gray-400" />
          <rect x="80" y="10" width="20" height="4" className="fill-gray-400" />
          <circle cx="84" cy="18" r="5" className="fill-amber-600/85" />
        </>
      )}

      {kind === "bus" && (
        <>
          <rect x="4" y="18" width="40" height="46" className="fill-gray-200/90" />
          <rect x="76" y="26" width="40" height="38" className="fill-gray-200/90" />
          <rect x="57" y="26" width="4" height="42" className="fill-gray-400" />
          <circle cx="59" cy="22" r="12" className="fill-amber-600/85" />
          <circle cx="59" cy="22" r="6" className="fill-[#f4f2ef]" />
          <rect x="70" y="56" width="30" height="4" className="fill-gray-400/80" />
          <rect x="72" y="60" width="3" height="8" className="fill-gray-400/80" />
          <rect x="95" y="60" width="3" height="8" className="fill-gray-400/80" />
        </>
      )}

      {kind === "highway" && (
        <>
          <rect y="26" width="120" height="12" className="fill-gray-300" />
          <rect x="16" y="38" width="10" height="26" className="fill-gray-300" />
          <rect x="86" y="38" width="10" height="26" className="fill-gray-300" />
          <path d="M0 84 L44 60 L76 60 L120 84 Z" className="fill-gray-200/90" />
          <rect x="56" y="66" width="8" height="18" className="fill-[#faf9f7]" />
          <rect x="46" y="42" width="28" height="14" rx="2" className="fill-amber-600/85" />
          <rect x="58" y="56" width="4" height="8" className="fill-gray-400" />
        </>
      )}

      {kind === "parking" && (
        <>
          <rect y="34" width="120" height="50" className="fill-gray-200/80" />
          {[24, 62, 100].map((x) => (
            <rect key={x} x={x} y="40" width="2.5" height="38" className="fill-[#faf9f7]" />
          ))}
          <rect x="30" y="50" width="28" height="16" rx="4" className="fill-gray-400/70" />
          <rect x="68" y="50" width="28" height="16" rx="4" className="fill-gray-300" />
          <rect x="6" y="10" width="22" height="22" rx="3" className="fill-amber-600/85" />
          <rect x="15" y="15" width="5" height="12" className="fill-[#f4f2ef]" />
          <rect x="15" y="15" width="10" height="5" className="fill-[#f4f2ef]" />
        </>
      )}

      {kind === "office" && (
        <>
          <rect x="22" y="8" width="76" height="56" className="fill-gray-300" />
          {[16, 30, 44].map((y) =>
            [30, 48, 66, 84].map((x) => (
              <rect key={`${x}-${y}`} x={x} y={y} width="10" height="8" className="fill-[#faf9f7]" />
            ))
          )}
          <rect x="34" y="56" width="52" height="8" className="fill-gray-400/70" />
          <rect x="46" y="64" width="28" height="20" className="fill-[#faf9f7]" />
          <rect x="30" y="46" width="18" height="6" rx="1.5" className="fill-amber-600/85" />
        </>
      )}
    </svg>
  );
}

/* ---------------------------------------------------------------- 本体 */

export default function CompanyAccessMap() {
  const rootRef = useRef<HTMLElement>(null);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const [active, setActive] = useState(false);
  const [routeIndex, setRouteIndex] = useState(0);
  const [activeNo, setActiveNo] = useState<number | null>(null);

  const route = routes[routeIndex];

  // 画面内に入ったらリビール開始(IO 非対応環境は即時表示)
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") {
      const id = requestAnimationFrame(() => setActive(true));
      return () => cancelAnimationFrame(id);
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setActive(true);
          io.disconnect();
        }
      },
      { threshold: 0.15 }
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const reveal = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
    active
      ? "translate-y-0 opacity-100"
      : "translate-y-6 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
  }`;

  const selectRoute = (i: number) => {
    setRouteIndex(i);
    setActiveNo(null);
  };

  const onTabKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const last = routes.length - 1;
    let next = -1;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = i === last ? 0 : i + 1;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = i === 0 ? last : i - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = last;
    if (next < 0) return;
    e.preventDefault();
    selectRoute(next);
    tabRefs.current[next]?.focus();
  };

  return (
    <section
      ref={rootRef}
      aria-labelledby="company-access-map-heading"
      className="w-full max-w-5xl bg-white"
    >
      {/* ヘッダー */}
      <header className="max-w-2xl">
        <p
          className={`${montserrat.className} text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
          style={{ transitionDelay: "0ms" }}
        >
          BLK-19 — Access &amp; Directions
        </p>
        <h2
          id="company-access-map-heading"
          className={`mt-4 text-[clamp(1.7rem,4vw,2.4rem)] leading-[1.3] font-bold tracking-tight text-gray-900 ${reveal}`}
          style={{ transitionDelay: "80ms" }}
        >
          住所ではなく、
          <span className="relative whitespace-nowrap">
            見える景色
            <span
              aria-hidden="true"
              className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70"
            />
          </span>
          <span className="whitespace-nowrap">で案内します。</span>
        </h2>
        <p
          className={`mt-5 text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal}`}
          style={{ transitionDelay: "160ms" }}
        >
          はじめての場所で迷うのは、地図が読めないからではなく「いま合っているのか」が分からないからです。手段ごとに、目印になる景色を順番に並べました。
        </p>
      </header>

      {/* 手段タブ(ARIA tabs・矢印キー対応) */}
      <div
        className={`mt-10 border-t border-gray-200 ${reveal}`}
        style={{ transitionDelay: "240ms" }}
      >
        <div
          role="tablist"
          aria-label="来社手段"
          className="-mb-px flex flex-nowrap items-stretch gap-x-1 overflow-x-auto"
        >
          {routes.map((r, i) => {
            const on = i === routeIndex;
            return (
              <button
                key={r.id}
                ref={(el) => {
                  tabRefs.current[i] = el;
                }}
                type="button"
                role="tab"
                id={`cam-tab-${r.id}`}
                aria-selected={on}
                aria-controls={`cam-panel-${r.id}`}
                tabIndex={on ? 0 : -1}
                onClick={() => selectRoute(i)}
                onKeyDown={(e) => onTabKeyDown(e, i)}
                className={`group relative flex-none px-3 py-4 text-[13px] font-bold whitespace-nowrap tracking-[0.06em] transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none sm:px-5 sm:text-sm ${
                  on ? "text-gray-900" : "text-gray-400 hover:text-gray-700"
                }`}
              >
                <span className="flex items-baseline gap-2">
                  {r.label}
                  {/* 英字は 375px で3タブが折り返す原因になるため sm 未満では出さない
                      (折り返すと下線インジケータだけが別行に取り残されて崩れる) */}
                  <span
                    className={`${montserrat.className} hidden text-[10px] font-semibold tracking-[0.14em] uppercase sm:inline ${
                      on ? "text-amber-600" : "text-gray-300"
                    }`}
                  >
                    {r.en}
                  </span>
                </span>
                <span
                  aria-hidden="true"
                  className={`absolute -bottom-px left-0 h-[2px] w-full origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                    on ? "scale-x-100" : "scale-x-0"
                  }`}
                />
              </button>
            );
          })}
        </div>
      </div>

      {/* 経路パネル */}
      <div
        role="tabpanel"
        id={`cam-panel-${route.id}`}
        aria-labelledby={`cam-tab-${route.id}`}
        tabIndex={-1}
        className={`border-t border-gray-200 pt-8 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 ${reveal}`}
        style={{ transitionDelay: "320ms" }}
      >
        <div
          key={route.id}
          className="grid grid-cols-1 gap-8 animate-[cam-in_450ms_ease-out] motion-reduce:animate-none lg:grid-cols-12 lg:gap-0 lg:divide-x lg:divide-gray-200"
        >
          {/* 左: 起点サマリ + 地図 */}
          <div className="lg:col-span-7 lg:pr-10">
            <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
              <div>
                <p className="text-[11px] font-bold tracking-[0.12em] text-gray-500">起点</p>
                <p className="mt-1.5 text-[13px] leading-[1.7] font-semibold text-gray-900 sm:text-sm">
                  {route.from}
                </p>
              </div>
              <p className="flex items-baseline gap-1.5 text-gray-900">
                <span className="text-[11px] font-bold tracking-[0.12em] text-gray-500">
                  {route.means}
                </span>
                <span
                  className={`${montserrat.className} text-[2.1rem] leading-none font-bold tracking-tight text-amber-600`}
                >
                  {route.minutes}
                </span>
                <span className="text-sm font-bold">分</span>
              </p>
            </div>

            <div className="mt-5">
              <AccessMap route={route} activeNo={activeNo} />
            </div>

            <p className="mt-3 text-[12px] leading-[1.9] text-gray-500">
              ※ 図は位置関係を示す簡易図です。番号は右(スマートフォンでは下)の目印と対応しています。
            </p>
          </div>

          {/* 右: 目印サムネイル(カテゴリ=手段ごとの並び) */}
          <div className="lg:col-span-5 lg:pl-10">
            <h3 className="flex items-center gap-2.5 text-sm font-bold tracking-[0.08em] text-gray-900">
              <span aria-hidden="true" className="h-px w-5 flex-none bg-amber-600" />
              目印になる景色
            </h3>

            <ol className="mt-5 divide-y divide-gray-100 border-t border-gray-100">
              {route.spots.map((s) => {
                const on = activeNo === s.no;
                return (
                  <li key={s.no}>
                    <button
                      type="button"
                      aria-pressed={on}
                      onClick={() => setActiveNo(on ? null : s.no)}
                      onMouseEnter={() => setActiveNo(s.no)}
                      onMouseLeave={() => setActiveNo(null)}
                      onFocus={() => setActiveNo(s.no)}
                      onBlur={() => setActiveNo(null)}
                      className="group flex w-full items-start gap-4 py-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                    >
                      <span className="sr-only">地図で{s.no}番の位置を示す:</span>
                      <span
                        aria-hidden="true"
                        className={`relative w-[5.5rem] flex-none overflow-hidden rounded-md border transition-[transform,border-color] duration-300 ease-out group-hover:-translate-y-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0 ${
                          on ? "border-amber-500" : "border-gray-200"
                        }`}
                      >
                        <Thumb kind={s.scene} />
                        <span
                          className={`${montserrat.className} absolute top-1 left-1 flex size-4.5 items-center justify-center rounded-full text-[10px] font-bold transition-colors duration-300 motion-reduce:transition-none ${
                            on ? "bg-amber-600 text-white" : "bg-white/90 text-gray-600"
                          }`}
                        >
                          {s.no}
                        </span>
                      </span>
                      <span className="min-w-0">
                        <span className="block text-[13px] leading-[1.6] font-bold text-gray-900 sm:text-sm">
                          {s.title}
                        </span>
                        <span className="mt-1.5 block text-[12px] leading-[1.85] text-gray-600">
                          {s.note}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
            </ol>

            <p className="mt-5 flex items-start gap-2.5 text-[12px] leading-[1.9] text-gray-500">
              <span aria-hidden="true" className="mt-[0.85em] h-px w-3 flex-none bg-amber-600" />
              <span>{route.caution}</span>
            </p>
          </div>
        </div>
      </div>

      {/* 住所・受付(地図が読めない場合の逃げ道は必ずテキストで) */}
      <div
        className={`mt-10 border-t border-b border-gray-200 py-8 ${reveal}`}
        style={{ transitionDelay: "420ms" }}
      >
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <dl className="divide-y divide-gray-100">
            {summary.map((row) => (
              <div
                key={row.term}
                className="grid grid-cols-[4.5rem_1fr] gap-x-4 py-2.5 sm:grid-cols-[5.5rem_1fr]"
              >
                <dt className="pt-[0.3em] text-[11px] font-bold tracking-[0.12em] whitespace-nowrap text-gray-500">
                  {row.term}
                </dt>
                <dd className="text-[13px] leading-[1.85] font-medium text-gray-900 sm:text-sm">
                  {row.value}
                </dd>
              </div>
            ))}
          </dl>

          <a
            href="#"
            className="group inline-flex flex-none items-center gap-2 text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
          >
            <span className="relative">
              地図アプリで開く
              <span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
              <span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
            </span>
            <span
              aria-hidden="true"
              className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
            >
              →
            </span>
          </a>
        </div>
      </div>

      {/* このブロック内で完結するキーフレーム(transform / opacity のみ) */}
      <style>{`
        @keyframes cam-flow {
          0%, 100% { opacity: 0.18; }
          45%      { opacity: 0.95; }
        }
        @keyframes cam-pulse {
          0%   { transform: scale(1);   opacity: 0.5; }
          70%  { transform: scale(3.2); opacity: 0; }
          100% { transform: scale(3.2); opacity: 0; }
        }
        @keyframes cam-in {
          from { transform: translateY(10px); opacity: 0; }
          to   { transform: translateY(0);    opacity: 1; }
        }
      `}</style>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/company-access-map.json