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 & 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