First CH Designs
BLK-51Blocks

対応エリア(簡略マップ+エリアリスト)

「自社がどこにあるか」ではなく「相手の住所が範囲に入るか」を答える対応エリアブロック。実地図を使わず、共有格子点をジッタさせた自作SVGの区割り図に当日対応・翌営業日・要相談・対象外の4段をアンバーで塗り分け、右に市区町村の可否台帳(読み・出張費・最短訪問・条件)を並べる。地名や読み・区分名での検索が図と一覧を同時に絞り込み、hover/focusで図とリストが相互にハイライトする。塗りは拠点からの距離順に広がる。画像素材ゼロ・依存ゼロ。

追加日:
2026-09-04
依存:
なし
tags
#block #coverage-area #service-area #map #svg #cartogram #search #highlight #hairline #a11y #no-image #no-deps

プレビュー

対応エリア

ご住所の市区町村から、伺えるかどうかを確かめる

対応の可否は距離ではなく訪問の段取りで決まります。市区町村ごとに、当日伺えるか・出張費がかかるかをそのまま公開しています。地名を入力すると、図と一覧の両方が絞り込まれます。

  • 当日対応
  • 翌営業日
  • 要相談
  • 対象外
  • 拠点
BLK-51 / 対応エリア図(区割りを示す簡略図で、実際の地形・面積とは一致しません)
出張費
片道の実費を上限とし、金額はお見積りに明記します
当日対応
12:00 までにご連絡いただいた分が対象です
範囲外
訪問は承っていませんが、画面共有での打ち合わせに対応します

全12件中 12件を表示

掲載しているのは訪問の可否と条件です。一覧にない地域のご相談も受け付けていますので、実際の日程とあわせてお問い合わせください。

対応の可否を問い合わせる
"use client";

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

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

/**
 * 対応エリア(簡略マップ × 市区町村の可否台帳)
 * 既存の会社概要系は「自社がどこにあるか」を示す構造(BLK-19=1拠点への道順・BLK-50=N拠点の横断台帳)が
 * 既出のため、本ブロックは向きを反転させ「相手の住所が範囲に入るか」を答える構造にした。
 * 主操作はタブでも絞り込みでもなく地名の検索で、地図は塗り分けの凡例として従属する。
 * 飽和していた角丸・末尾の矢印リンク・英字キッカーは意図的に使っていない(見出しは和文ラベルのみ)。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 格子ジッタ・カルトグラム: 実地図を使わず区割り図を作る型。(COLS+1)×(ROWS+1) の格子点を
 *   1度だけ決定論的にジッタさせ、各区画はその4点から組む。点を共有しているので隙間も重なりも
 *   構造的に発生しない。各頂点を重心方向へ 2px 寄せると区画間に白い縫い目ができ、罫線を1本も
 *   引かずに「区割り」が読める。ジッタは Math.sin ベースの決定論的擬似乱数を**モジュール直下**で
 *   評価する(乱数を使うと SSR とクライアントで座標が変わり hydration が割れる)。
 * - 出現順は配列の index ではなく「基点からの距離」でソートして遅延を配る。塗りが拠点から
 *   外側へ広がるので、同じスタッガーでも意味(=対応範囲が伸びていく)を持つ。
 * - SVG 要素に scale を効かせるには transform-box: fill-box と transform-origin: center が要る。
 *   付けないと viewBox 原点基準で拡大され、区画が画面外へ飛ぶ。
 * - 図とリストの相互ハイライトは hover/focus(一時)と click(固定)を別の state に分け、
 *   見た目は hovered ?? pinned で決める。1つに束ねると focus しただけで aria-pressed が true になり、
 *   「押していないボタンが押された状態」を読み上げてしまう。SVG 側は aria-hidden にして
 *   正の情報を必ずリスト側へ置く。検索の絞り込みも同じ query 1つで図とリストの両方を制御する。
 * - <pattern> の id は useId() で作る。同一ページに同じブロックを2つ置くと id が衝突し、
 *   後勝ちで片方のハッチが消える。
 */

type Tier = "same" | "next" | "consult" | "out";

const tiers: Record<
  Tier,
  { label: string; fee: string; lead: string; swatch: string; text: string }
> = {
  same: {
    label: "当日対応",
    fee: "無料",
    lead: "最短 当日",
    swatch: "bg-amber-600",
    text: "text-amber-800",
  },
  next: {
    label: "翌営業日",
    fee: "無料",
    lead: "最短 翌営業日",
    swatch: "bg-amber-500/30",
    text: "text-amber-700",
  },
  consult: {
    label: "要相談",
    fee: "3,000円〜",
    lead: "最短 3営業日",
    swatch: "bg-white",
    text: "text-gray-700",
  },
  out: {
    label: "対象外",
    fee: "—",
    lead: "オンラインで対応",
    swatch: "bg-gray-100",
    text: "text-gray-400",
  },
};

type Area = {
  id: string;
  name: string;
  /** 検索用の読み。漢字が出てこなくても引けるようにする */
  yomi: string;
  tier: Tier;
  /** 格子上の区画位置(列, 行) */
  col: number;
  row: number;
  /** 拠点を置く区画 */
  base?: boolean;
  /** 条件が付く区画にだけ添える1行 */
  note?: string;
};

const areas: Area[] = [
  { id: "keyaki", name: "けやき市", yomi: "けやきし", tier: "same", col: 0, row: 0, base: true },
  { id: "minato", name: "みなと区", yomi: "みなとく", tier: "same", col: 1, row: 0 },
  { id: "sakuradai", name: "さくら台市", yomi: "さくらだいし", tier: "next", col: 2, row: 0 },
  {
    id: "shirasagi",
    name: "白鷺町",
    yomi: "しらさぎまち",
    tier: "consult",
    col: 3,
    row: 0,
    note: "月・木のみ巡回。他曜日は出張費を申し受けます",
  },
  { id: "nagisa", name: "なぎさ市", yomi: "なぎさし", tier: "same", col: 0, row: 1 },
  { id: "nakazato", name: "中里区", yomi: "なかざとく", tier: "same", col: 1, row: 1 },
  { id: "hibari", name: "ひばりが丘市", yomi: "ひばりがおかし", tier: "next", col: 2, row: 1 },
  { id: "wakaba", name: "若葉町", yomi: "わかばまち", tier: "next", col: 3, row: 1 },
  { id: "uminobe", name: "うみのべ町", yomi: "うみのべまち", tier: "next", col: 0, row: 2 },
  {
    id: "minamino",
    name: "南野市",
    yomi: "みなみのし",
    tier: "consult",
    col: 1,
    row: 2,
    note: "橋の通行止め期間は翌々営業日以降になります",
  },
  {
    id: "okumine",
    name: "奥峰村",
    yomi: "おくみねむら",
    tier: "out",
    col: 2,
    row: 2,
    note: "訪問は承っていません。画面共有での対応は可能です",
  },
  {
    id: "misakino",
    name: "岬野村",
    yomi: "みさきのむら",
    tier: "out",
    col: 3,
    row: 2,
    note: "訪問は承っていません。画面共有での対応は可能です",
  },
];

/** 全エリアに共通する条件。台帳の各行に同じ値を12回並べず、図の側へ1度だけ置く */
const conditions: { term: string; value: string }[] = [
  { term: "出張費", value: "片道の実費を上限とし、金額はお見積りに明記します" },
  { term: "当日対応", value: "12:00 までにご連絡いただいた分が対象です" },
  { term: "範囲外", value: "訪問は承っていませんが、画面共有での打ち合わせに対応します" },
];

/* ------------------------------------------------ 格子ジッタ・カルトグラム */

const COLS = 4;
const ROWS = 3;
const VIEW_W = 440;
const VIEW_H = 340;

/** 決定論的な擬似乱数(0..1)。SSR とクライアントで同じ座標を出すため Math.random は使わない */
function noise(col: number, row: number, salt: number) {
  const n = Math.sin(col * 127.1 + row * 311.7 + salt * 74.7) * 43758.5453;
  return n - Math.floor(n);
}

/** 格子点(列 0..COLS・行 0..ROWS)。共有点をジッタさせるので区画に隙間ができない */
const lattice: { x: number; y: number }[][] = Array.from(
  { length: COLS + 1 },
  (_, c) =>
    Array.from({ length: ROWS + 1 }, (_, r) => ({
      x: 16 + c * 102 + (noise(c, r, 1) - 0.5) * 22,
      y: 14 + r * 104 + (noise(c, r, 2) - 0.5) * 18,
    })),
);

/** 各頂点を重心方向へ寄せる。区画の間に白い縫い目ができ、罫線なしで区割りが読める */
function shrink(pts: { x: number; y: number }[], cx: number, cy: number, d = 2.4) {
  return pts.map((p) => {
    const dx = cx - p.x;
    const dy = cy - p.y;
    const len = Math.hypot(dx, dy) || 1;
    return { x: p.x + (dx / len) * d, y: p.y + (dy / len) * d };
  });
}

type Zone = { id: string; points: string; cx: number; cy: number };

const zones: Record<string, Zone> = {};
for (const a of areas) {
  const corners = [
    lattice[a.col][a.row],
    lattice[a.col + 1][a.row],
    lattice[a.col + 1][a.row + 1],
    lattice[a.col][a.row + 1],
  ];
  const cx = corners.reduce((s, p) => s + p.x, 0) / 4;
  const cy = corners.reduce((s, p) => s + p.y, 0) / 4;
  zones[a.id] = {
    id: a.id,
    cx,
    cy,
    points: shrink(corners, cx, cy)
      .map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`)
      .join(" "),
  };
}

/** 出現順は index ではなく拠点からの距離。塗りが拠点から外へ広がる */
const baseZone = zones[areas.find((a) => a.base)?.id ?? areas[0].id];
const revealOrder = new Map(
  [...areas]
    .sort((a, b) => {
      const da = Math.hypot(zones[a.id].cx - baseZone.cx, zones[a.id].cy - baseZone.cy);
      const db = Math.hypot(zones[b.id].cx - baseZone.cx, zones[b.id].cy - baseZone.cy);
      return da - db;
    })
    .map((a, i) => [a.id, i] as const),
);

/** 区画の塗り。要相談は塗らずハッチで「条件付き」を示す */
function zoneFill(tier: Tier, hatchId: string) {
  if (tier === "same") return { className: "fill-amber-600", style: undefined };
  if (tier === "next") return { className: "fill-amber-500/30", style: undefined };
  if (tier === "out") return { className: "fill-gray-100", style: undefined };
  return { className: "fill-white", style: { fill: `url(#${hatchId})` } };
}

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

export default function AreaCoverageMap() {
  const rootRef = useRef<HTMLElement>(null);
  const uid = useId().replace(/:/g, "");
  const hatchId = `acv-hatch-${uid}`;
  const [shown, setShown] = useState(false);
  // hovered=一時(hover/focus)・pinned=固定(クリック)。見た目は hovered を優先する
  const [hovered, setHovered] = useState<string | null>(null);
  const [pinned, setPinned] = useState<string | null>(null);
  const activeId = hovered ?? pinned;
  const [query, setQuery] = useState("");

  // 画面内に入ったら塗りを広げる(IO 非対応環境は即時表示)
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") {
      const id = requestAnimationFrame(() => setShown(true));
      return () => cancelAnimationFrame(id);
    }
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setShown(true);
          io.disconnect();
        }
      },
      // 縦に長い節では threshold(面積比)が満たされず塗りが出ないまま残る。
      // 節の上端がビューポートに入ったら発火させる rootMargin 方式にする。
      { threshold: 0, rootMargin: "0px 0px -15% 0px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const q = query.trim().toLowerCase();
  const hit = (a: Area) =>
    q === "" ||
    a.name.toLowerCase().includes(q) ||
    a.yomi.includes(q) ||
    tiers[a.tier].label.includes(q);

  const filtered = areas.filter(hit);

  return (
    <section
      ref={rootRef}
      aria-labelledby="area-coverage-map-heading"
      className="@container w-full max-w-5xl bg-white"
    >
      {/* 見出し(英字キッカーは置かない。和文ラベル+短いアンバー罫線で節を開く) */}
      <header className="max-w-2xl">
        <p className="flex items-center gap-2.5 text-[12px] font-bold tracking-widest text-gray-500">
          <span aria-hidden="true" className="h-px w-6 flex-none bg-amber-600" />
          対応エリア
        </p>
        <h2
          id="area-coverage-map-heading"
          className="mt-4 text-[clamp(1.55rem,3.6vw,2.2rem)] leading-[1.35] font-bold tracking-tight text-gray-900"
        >
          ご住所の市区町村から、
          <span className="whitespace-nowrap">伺えるかどうかを確かめる</span>
        </h2>
        <p className="mt-5 text-[15px] leading-[1.9] font-medium text-gray-600">
          対応の可否は距離ではなく訪問の段取りで決まります。市区町村ごとに、当日伺えるか・出張費がかかるかをそのまま公開しています。地名を入力すると、図と一覧の両方が絞り込まれます。
        </p>
      </header>

      <div className="mt-9 grid grid-cols-1 gap-9 border-t border-gray-200 pt-9 @2xl:grid-cols-[minmax(0,1fr)_minmax(0,19rem)] @2xl:gap-10">
        {/* 左: 簡略マップ(装飾。情報の正は右の一覧) */}
        {/* sticky にはしない。一覧カードは transform: scale した中で描かれるため、
            sticky の基準がスケール後の座標になり図だけが下へずれて見える */}
        <figure className="m-0 min-w-0">
          <svg
            viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
            aria-hidden="true"
            className="block h-auto w-full border border-gray-200 bg-[#fbfaf8]"
          >
            <defs>
              {/* 要相談=塗らずにハッチ。面積で強さを誤読させない */}
              <pattern
                id={hatchId}
                width="8"
                height="8"
                patternUnits="userSpaceOnUse"
                patternTransform="rotate(45)"
              >
                <line
                  x1="0"
                  y1="0"
                  x2="0"
                  y2="8"
                  className="stroke-amber-500/60"
                  strokeWidth="1.8"
                />
              </pattern>
            </defs>

            {areas.map((a) => {
              const z = zones[a.id];
              const on = activeId === a.id;
              const dim = q !== "" && !hit(a);
              const fill = zoneFill(a.tier, hatchId);
              const label =
                a.tier === "same" ? "fill-white" : a.tier === "out" ? "fill-gray-500" : "fill-gray-700";
              return (
                <g
                  key={a.id}
                  onMouseEnter={() => setHovered(a.id)}
                  onMouseLeave={() => setHovered((cur) => (cur === a.id ? null : cur))}
                  className={`transition-[opacity,scale] duration-500 ease-out motion-reduce:transition-none ${
                    on ? "scale-[1.035]" : "scale-100"
                  } ${
                    shown
                      ? dim
                        ? "opacity-25"
                        : "opacity-100"
                      : "opacity-0 motion-reduce:opacity-100"
                  }`}
                  style={{
                    transformBox: "fill-box",
                    transformOrigin: "center",
                    transitionDelay: shown && q === "" ? `${(revealOrder.get(a.id) ?? 0) * 55}ms` : "0ms",
                  }}
                >
                  <polygon
                    points={z.points}
                    className={`${fill.className} transition-[stroke] duration-200 motion-reduce:transition-none ${
                      on ? "stroke-amber-700" : "stroke-transparent"
                    }`}
                    style={fill.style}
                    strokeWidth={on ? 2.5 : 1}
                  />
                  {/* 図は幅に合わせて縮むので、狭い容器では地名を出さない(読めない文字を敷かない)。
                      正の情報は右の一覧が持つので、狭いときの図は塗り分けの要約として機能する。 */}
                  <text
                    x={z.cx}
                    y={a.base ? z.cy + 10 : z.cy + 4}
                    textAnchor="middle"
                    className={`hidden text-[11px] font-bold @lg:block ${label}`}
                  >
                    {a.name}
                  </text>
                  {a.base && (
                    <>
                      <rect
                        x={z.cx - 4.5}
                        y={z.cy - 15}
                        width="9"
                        height="9"
                        className="fill-white stroke-amber-700"
                        strokeWidth="2"
                      />
                      <text
                        x={z.cx}
                        y={z.cy - 20}
                        textAnchor="middle"
                        className="hidden fill-white text-[9px] font-bold @lg:block"
                      >
                        拠点
                      </text>
                    </>
                  )}
                </g>
              );
            })}
          </svg>

          {/* 凡例 */}
          <ul className="mt-4 flex flex-wrap items-center gap-x-5 gap-y-2">
            {(Object.keys(tiers) as Tier[]).map((t) => (
              <li key={t} className="flex items-center gap-2 text-[12px] font-medium text-gray-600">
                <span
                  aria-hidden="true"
                  className={`size-3 flex-none border ${
                    t === "consult" ? "border-amber-500" : "border-gray-300"
                  } ${tiers[t].swatch}`}
                  style={
                    t === "consult"
                      ? {
                          backgroundImage:
                            "repeating-linear-gradient(45deg, rgba(217,119,6,0.55) 0 1.5px, transparent 1.5px 5px)",
                        }
                      : undefined
                  }
                />
                {tiers[t].label}
              </li>
            ))}
            <li className="flex items-center gap-2 text-[12px] font-medium text-gray-600">
              <span
                aria-hidden="true"
                className="size-3 flex-none border-2 border-amber-700 bg-white"
              />
              拠点
            </li>
          </ul>

          <figcaption className="mt-4 border-t border-gray-100 pt-3 text-[11.5px] leading-[1.9] text-gray-500">
            <span className={`${montserrat.className} font-semibold text-gray-400`}>BLK-51</span>
            {" / 対応エリア図(区割りを示す簡略図で、実際の地形・面積とは一致しません)"}
          </figcaption>

          {/* 表に入れると12行すべてに同じ値が並ぶ条件は、図の側に1度だけ書く */}
          <dl className="mt-6 divide-y divide-gray-100 border-t border-gray-200">
            {conditions.map((row) => (
              <div key={row.term} className="grid grid-cols-[4.5rem_1fr] gap-x-4 py-3">
                <dt className="pt-[0.15em] text-[11.5px] font-bold tracking-widest whitespace-nowrap text-gray-500">
                  {row.term}
                </dt>
                <dd className="text-[12.5px] leading-[1.85] text-gray-700">{row.value}</dd>
              </div>
            ))}
          </dl>
        </figure>

        {/* 右: 地名で引く一覧(ここが情報の正) */}
        <div className="min-w-0">
          <label
            htmlFor="area-coverage-query"
            className="block text-[12px] font-bold tracking-widest text-gray-500"
          >
            市区町村名で調べる
          </label>
          <div className="mt-2.5 flex items-center gap-2 border border-gray-300 px-3 py-2 transition-colors duration-200 focus-within:border-amber-600 motion-reduce:transition-none">
            <svg
              aria-hidden="true"
              viewBox="0 0 20 20"
              className="size-4 flex-none stroke-gray-400"
              fill="none"
              strokeWidth="1.8"
            >
              <circle cx="9" cy="9" r="5.5" />
              <line x1="13.2" y1="13.2" x2="17.5" y2="17.5" strokeLinecap="round" />
            </svg>
            <input
              id="area-coverage-query"
              type="search"
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              placeholder="例: けやき / ひばり / 当日"
              className="w-full min-w-0 bg-transparent py-0.5 text-[14px] text-gray-900 placeholder:text-gray-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
            />
            {query !== "" && (
              <button
                type="button"
                onClick={() => setQuery("")}
                className="flex-none border border-gray-300 px-2 py-0.5 text-[11px] font-bold text-gray-600 transition-colors duration-200 hover:border-gray-500 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
              >
                消す
              </button>
            )}
          </div>

          <p
            role="status"
            className="mt-3 text-[12px] leading-[1.8] font-medium text-gray-500 tabular-nums"
          >
            {`全${areas.length}件中 ${filtered.length}件を表示`}
          </p>

          <ul className="mt-3 divide-y divide-gray-100 border-t border-b border-gray-200">
            {filtered.map((a) => {
              const on = activeId === a.id;
              const tier = tiers[a.tier];
              return (
                <li key={a.id}>
                  <button
                    type="button"
                    aria-pressed={pinned === a.id}
                    onClick={() => setPinned((cur) => (cur === a.id ? null : a.id))}
                    onMouseEnter={() => setHovered(a.id)}
                    onMouseLeave={() => setHovered((cur) => (cur === a.id ? null : cur))}
                    onFocus={() => setHovered(a.id)}
                    onBlur={() => setHovered((cur) => (cur === a.id ? null : cur))}
                    className="group relative block w-full py-3.5 pl-3 text-left transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
                  >
                    {/* 左の細い縦罫が選択の手がかり。面を塗らずに強調する */}
                    <span
                      aria-hidden="true"
                      className={`absolute top-2.5 bottom-2.5 left-0 w-[3px] origin-top bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                        on ? "scale-y-100" : "scale-y-0"
                      }`}
                    />
                    <span className="sr-only">地図で位置を示す:</span>
                    <span className="flex items-baseline justify-between gap-3">
                      <span className="min-w-0">
                        <span className="block text-[14.5px] leading-[1.5] font-bold text-gray-900">
                          {a.name}
                        </span>
                        <span className="mt-0.5 block text-[11px] leading-[1.6] text-gray-400">
                          {a.yomi}
                        </span>
                      </span>
                      <span
                        className={`flex flex-none items-center gap-1.5 text-[12px] font-bold ${tier.text}`}
                      >
                        <span
                          aria-hidden="true"
                          className={`size-2.5 flex-none border ${
                            a.tier === "consult" ? "border-amber-500" : "border-gray-300"
                          } ${tier.swatch}`}
                          style={
                            a.tier === "consult"
                              ? {
                                  backgroundImage:
                                    "repeating-linear-gradient(45deg, rgba(217,119,6,0.55) 0 1.5px, transparent 1.5px 5px)",
                                }
                              : undefined
                          }
                        />
                        {tier.label}
                      </span>
                    </span>
                    <span className="mt-2 flex flex-wrap items-baseline gap-x-4 gap-y-1 text-[11.5px] leading-[1.7] text-gray-600">
                      <span className="flex items-baseline gap-1.5">
                        <span className="text-gray-400">出張費</span>
                        <span className={`${montserrat.className} font-semibold tabular-nums`}>
                          {tier.fee}
                        </span>
                      </span>
                      <span className="flex items-baseline gap-1.5">
                        <span className="text-gray-400">訪問</span>
                        <span className="font-semibold text-gray-800">{tier.lead}</span>
                      </span>
                    </span>
                    {a.note && (
                      <span className="mt-1.5 flex items-start gap-2 text-[11.5px] leading-[1.85] text-gray-500">
                        <span aria-hidden="true" className="mt-[0.8em] h-px w-2.5 flex-none bg-amber-600" />
                        <span>{a.note}</span>
                      </span>
                    )}
                  </button>
                </li>
              );
            })}
          </ul>

          {filtered.length === 0 && (
            <p className="mt-4 text-[13px] leading-[1.9] text-gray-600">
              一致する市区町村がありません。近隣の地名でもお探しいただけますが、一覧にない地域はご相談として承ります。
            </p>
          )}
        </div>
      </div>

      {/* 範囲外の逃げ道は必ずテキストで残す */}
      <div className="mt-9 flex flex-col gap-4 border-t border-gray-200 pt-6 @2xl:flex-row @2xl:items-baseline @2xl:justify-between">
        <p className="max-w-xl text-[13px] leading-[1.9] text-gray-600">
          掲載しているのは訪問の可否と条件です。一覧にない地域のご相談も受け付けていますので、実際の日程とあわせてお問い合わせください。
        </p>
        <a
          href="#"
          className="group inline-flex flex-none items-center 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
              aria-hidden="true"
              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
              aria-hidden="true"
              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>
        </a>
      </div>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

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