BLK-90Blocks

メガメニュー(項目にカーソルを合わせると中身を先に見せる)

グローバルナビの項目を開くと、左に下位ページの一覧、右に指している行き先の概要・数字2つ・ページ内の見出しを先出しするメガメニュー。開くのは120ms、閉じるのは300ms待ち、斜めにカーソルを運ぶ途中で別の項目に触れても三角形の内外判定で切り替えを保留する(猶予の三角形は切替で描いて見られる)。タッチは1回目のタップで概要、2回目で移動。キーボードは左右キーでナビ、↓で一覧、Escで閉じる操作まで完結する。動かすのはopacityとtransformだけで、reduced-motionでは静止したまま読める。画像素材ゼロ。

追加日:
2026-10-05
依存:
なし
tags
#mega-menu #navigation #header #hover-intent #menu-aim #preview #keyboard #touch #accessibility #container-query #no-image

プレビュー

First CH

/service/web/

Webサイト制作

企画と設計からデザイン・実装・公開後の更新まで、ひとつの窓口で引き受けます。

期間の目安
2〜3か月
費用の目安
80万円〜

このページの見出し

  1. 01進め方の6工程
  2. 02費用の内訳
  3. 03対応できる範囲
  4. 04よくある質問
Webサイト制作を開く

キーボードは Tab でナビへ、↓ で一覧へ、Esc で閉じる

"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type FocusEvent,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { Montserrat } from "next/font/google";

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

/**
 * メガメニュー(項目にカーソルを合わせると中身を先に見せる)
 *
 * 差別化: 既存のグローバルヘッダーはメニューを開閉するだけ、コマンドパレットは打鍵で行き先を絞り込む型だった。
 * 本ブロックは下位ページを一覧で見せ、指した行き先の概要・数字・見出しを右側に先出しする「見てから選ぶ」型で、
 * 斜めにカーソルを運ぶ途中で別の項目に触れても切り替わらない猶予の判定が中心。角丸・英字の小見出し・末尾の矢印リンクは置かない。
 *
 * 汎用技法メモ:
 * 1. **開くのは 120ms 待ち、閉じるのは 300ms 待ち**。ナビを横切っただけでは開かず、
 *    ナビとパネルの外へ少しはみ出しただけでは閉じない。どちらも戻ってきたら予約を取り消す。
 * 2. **斜め移動の猶予は三角形の内外判定**。直近 120ms・4点までの位置を持ち、最古の P0 と今の P について、
 *    P が「P0 と行き先の手前の辺の両端」で作る三角形の内側にあれば、行き先へ向かっている途中とみなす。
 *    そのあいだは別の項目に触れても切り替えず、動きが 180ms 止まったら初めて触れている項目へ切り替える。
 *    行き先はナビの項目ならパネルの上辺、一覧の行なら概要欄の左辺。三角形は見本の下の切替で描いて確かめられる。
 * 3. **ホバーで開いた直後のクリックは閉じない**。開いて 500ms 以内のクリックは「開く」の続きとして扱う
 *    (ホバーで開いてからクリックすると閉じてしまう、よくある誤作動を防ぐ)。
 * 4. **タッチはホバーを使わない**。pointerType が mouse 以外の入力では待ち時間も猶予も通さず、
 *    ナビはタップで開閉、一覧の行は1回目のタップで概要を出し、2回目で移動する。外側のタップで閉じる。
 * 5. **キーボード**: ナビは左右キーで項目を移り(開いていれば中身も追従)、下キーか Tab で一覧へ入る。
 *    一覧は上下キー・Home・End で移り、フォーカスした行の概要がそのまま右に出る(概要の文は aria-describedby で読む)。
 *    Esc で閉じて元の項目へ戻る。パネルは閉じている間 inert にしてフォーカスが迷い込まないようにする。
 * 6. 動きは opacity と transform だけ。prefers-reduced-motion では遷移を切り、開いた状態がそのまま読める。
 *    一覧の縮小カード(rect.width ÷ offsetWidth が 1 未満)ではホバーを無効にし、開いた見本のまま止める。
 *
 * 文言・項目・数字はすべて First CH のオリジナル。
 */

type Sub = {
  id: string;
  label: string;
  path: string;
  summary: string;
  figures: [string, string][];
  outline: string[];
};

type Top = {
  id: string;
  label: string;
  lead: string;
  subs: Sub[];
};

const NAV: Top[] = [
  {
    id: "service",
    label: "サービス",
    lead: "相談の窓口は4つあります",
    subs: [
      {
        id: "web",
        label: "Webサイト制作",
        path: "/service/web/",
        summary: "企画と設計からデザイン・実装・公開後の更新まで、ひとつの窓口で引き受けます。",
        figures: [
          ["期間の目安", "2〜3か月"],
          ["費用の目安", "80万円〜"],
        ],
        outline: ["進め方の6工程", "費用の内訳", "対応できる範囲", "よくある質問"],
      },
      {
        id: "lp",
        label: "LP・広告ページ",
        path: "/service/lp/",
        summary: "1枚で成果を測る縦長のページ。計測の設計と、公開後の改善の回し方まで含みます。",
        figures: [
          ["期間の目安", "3〜5週間"],
          ["費用の目安", "30万円〜"],
        ],
        outline: ["成果の測り方", "構成案の作り方", "改善の頻度", "費用の内訳"],
      },
      {
        id: "tool",
        label: "業務ツール開発",
        path: "/service/tool/",
        summary: "表計算と手作業で回している業務を、社内向けの小さなWebツールに置き換えます。",
        figures: [
          ["期間の目安", "1〜2か月"],
          ["費用の目安", "50万円〜"],
        ],
        outline: ["置き換えの事例", "最初の聞き取り", "運用の引き継ぎ", "費用の内訳"],
      },
      {
        id: "care",
        label: "保守・運用",
        path: "/service/care/",
        summary: "月ごとの更新作業、障害への対応、表示速度の点検を定額で引き受けます。",
        figures: [
          ["月額", "3万円〜"],
          ["初動", "翌営業日"],
        ],
        outline: ["月額に含む作業", "障害時の連絡", "点検の報告書", "解約の条件"],
      },
    ],
  },
  {
    id: "works",
    label: "制作実績",
    lead: "近い業種から探すと早い",
    subs: [
      {
        id: "industry",
        label: "業種別の一覧",
        path: "/works/",
        summary: "製造・医療・士業・小売の4分類で、自社に近い事例から順に見られます。",
        figures: [
          ["掲載数", "48件"],
          ["更新", "毎月"],
        ],
        outline: ["製造業", "医療・福祉", "士業", "小売・飲食"],
      },
      {
        id: "story",
        label: "事例の読み物",
        path: "/works/story/",
        summary: "依頼の前に何に困っていたか、公開後に何が変わったかを担当者の言葉で残しています。",
        figures: [
          ["掲載数", "12本"],
          ["読了時間", "約6分"],
        ],
        outline: ["相談前の課題", "決めたこと", "公開後の変化", "担当者から"],
      },
      {
        id: "numbers",
        label: "数字で見る成果",
        path: "/works/numbers/",
        summary: "問い合わせ数・表示速度・更新にかかる時間など、公開前後で測れた変化だけを載せます。",
        figures: [
          ["対象", "31件"],
          ["計測期間", "公開後半年"],
        ],
        outline: ["問い合わせ数", "表示速度", "更新の手間", "測り方の注記"],
      },
    ],
  },
  {
    id: "company",
    label: "会社情報",
    lead: "取引の前に確かめたいこと",
    subs: [
      {
        id: "profile",
        label: "会社概要",
        path: "/company/",
        summary: "所在地・設立・取引銀行・加入している団体など、取引の確認に要る事項をまとめています。",
        figures: [
          ["設立", "2019年"],
          ["拠点", "1か所"],
        ],
        outline: ["基本情報", "沿革", "取引銀行", "加入団体"],
      },
      {
        id: "quality",
        label: "品質への取り組み",
        path: "/company/quality/",
        summary: "公開前に必ず通す点検の項目と、誰がいつ確認するかを決まりとして公開しています。",
        figures: [
          ["点検項目", "62項目"],
          ["確認者", "2名以上"],
        ],
        outline: ["公開前の点検", "表示の確認", "情報の扱い", "改善の記録"],
      },
      {
        id: "access",
        label: "アクセス",
        path: "/company/access/",
        summary: "最寄り駅からの道順と、打ち合わせの部屋の予約方法を載せています。",
        figures: [
          ["駅から", "徒歩6分"],
          ["駐車場", "2台"],
        ],
        outline: ["地図", "駅からの道順", "車で来る場合", "部屋の予約"],
      },
    ],
  },
  {
    id: "recruit",
    label: "採用",
    lead: "通年で受け付けています",
    subs: [
      {
        id: "jobs",
        label: "募集中の職種",
        path: "/recruit/jobs/",
        summary: "デザイナーと開発者を募集しています。経験の年数より、作ったものを見せてください。",
        figures: [
          ["募集", "2職種"],
          ["勤務", "週4日から"],
        ],
        outline: ["デザイナー", "開発者", "待遇", "応募の方法"],
      },
      {
        id: "flow",
        label: "選考の流れ",
        path: "/recruit/flow/",
        summary: "書類から内定までの段階と、それぞれで見ていることを先にお伝えしています。",
        figures: [
          ["面談", "2回"],
          ["結果まで", "約3週間"],
        ],
        outline: ["書類", "作品の面談", "条件の面談", "内定後"],
      },
    ],
  },
];

const OPEN_DELAY = 120;
const CLOSE_DELAY = 300;
const AIM_DELAY = 180;
const CLICK_GRACE = 500;
const HISTORY_MS = 120;

type Pt = { x: number; y: number };
type Candidate = { kind: "top"; id: string } | { kind: "sub"; id: string } | null;

/** P が三角形 ABC の内側(辺上を含む)にあるか */
function inTriangle(p: Pt, a: Pt, b: Pt, c: Pt) {
  const s = (p1: Pt, p2: Pt, p3: Pt) => (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y);
  const d1 = s(p, a, b);
  const d2 = s(p, b, c);
  const d3 = s(p, c, a);
  const neg = d1 < 0 || d2 < 0 || d3 < 0;
  const pos = d1 > 0 || d2 > 0 || d3 > 0;
  return !(neg && pos);
}

/** 行き先の手前の辺の両端。below=上辺(左右に少し広げる)、right=左辺(上下に少し広げる) */
function edgeOf(rect: DOMRect, side: "below" | "right"): [Pt, Pt] {
  return side === "below"
    ? [
        { x: rect.left - 8, y: rect.top },
        { x: rect.right + 8, y: rect.top },
      ]
    : [
        { x: rect.left, y: rect.top - 8 },
        { x: rect.left, y: rect.bottom + 8 },
      ];
}

export default function MegaMenuPreview() {
  const uid = useId();
  const panelId = `${uid}-panel`;
  const summaryId = `${uid}-summary`;

  const [openId, setOpenId] = useState<string | null>("service");
  const [activeSub, setActiveSub] = useState<Record<string, string>>(() =>
    Object.fromEntries(NAV.map((t) => [t.id, t.subs[0].id])),
  );
  const [showAim, setShowAim] = useState(false);
  const [status, setStatus] = useState("");

  const rootRef = useRef<HTMLDivElement>(null);
  const navRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const previewRef = useRef<HTMLDivElement>(null);
  const aimRef = useRef<SVGPolygonElement>(null);
  const topBtns = useRef<Record<string, HTMLButtonElement | null>>({});
  const subLinks = useRef<Record<string, HTMLAnchorElement | null>>({});
  const openLinkRef = useRef<HTMLAnchorElement>(null);

  // 状態の写し(タイマーの中から最新値を読むため。書き換えは必ずハンドラ内で行う)
  const openRef = useRef<string | null>("service");
  const activeRef = useRef<Record<string, string>>({});
  const candRef = useRef<Candidate>(null);
  const ptsRef = useRef<(Pt & { t: number })[]>([]);
  const openTimer = useRef<number | undefined>(undefined);
  const closeTimer = useRef<number | undefined>(undefined);
  const aimTimer = useRef<number | undefined>(undefined);
  const hoverOpenedAt = useRef(0);
  const pointerKind = useRef("mouse");
  const pointerPressing = useRef(false);
  const focusFirstAfterOpen = useRef(false);

  const isShrunk = () => {
    const el = rootRef.current;
    if (!el || el.offsetWidth === 0) return true;
    return el.getBoundingClientRect().width / el.offsetWidth < 0.75;
  };

  const currentSub = (topId: string) =>
    activeRef.current[topId] ?? activeSub[topId] ?? NAV.find((t) => t.id === topId)!.subs[0].id;

  const open = (id: string | null) => {
    openRef.current = id;
    setOpenId(id);
    setStatus("");
  };

  const pick = (topId: string, subId: string) => {
    activeRef.current = { ...activeRef.current, [topId]: subId };
    setActiveSub((s) => ({ ...s, [topId]: subId }));
  };

  const clearTimers = () => {
    window.clearTimeout(openTimer.current);
    window.clearTimeout(closeTimer.current);
    window.clearTimeout(aimTimer.current);
  };

  useEffect(() => () => clearTimers(), []);

  // 下キーで開いたとき、inert が外れてから最初の行へ移る
  useEffect(() => {
    if (!openId || !focusFirstAfterOpen.current) return;
    focusFirstAfterOpen.current = false;
    const first = NAV.find((t) => t.id === openId)!.subs[0];
    subLinks.current[`${openId}:${first.id}`]?.focus();
  }, [openId]);

  // 外側をタップ・クリックしたら閉じる
  useEffect(() => {
    const onDown = (e: PointerEvent) => {
      if (!openRef.current || isShrunk()) return;
      if (navRef.current && !navRef.current.contains(e.target as Node)) {
        clearTimers();
        open(null);
      }
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, []);

  /** いまの動きが行き先へ向かっているか。猶予の三角形も描く */
  const aiming = (): boolean => {
    const pts = ptsRef.current;
    const cand = candRef.current;
    const poly = aimRef.current;
    const target = !cand || !openRef.current ? null : cand.kind === "top" ? panelRef.current : previewRef.current;
    if (!target || pts.length < 2) {
      poly?.setAttribute("points", "");
      return false;
    }
    const p0 = pts[0];
    const p = pts[pts.length - 1];
    const [a, b] = edgeOf(target.getBoundingClientRect(), cand!.kind === "top" ? "below" : "right");
    if (poly && rootRef.current) {
      const r = rootRef.current.getBoundingClientRect();
      poly.setAttribute("points", [p, a, b].map((q) => `${q.x - r.left},${q.y - r.top}`).join(" "));
    }
    if (Math.hypot(p.x - p0.x, p.y - p0.y) < 2) return false;
    return inTriangle(p, p0, a, b);
  };

  /** 触れている項目へ切り替えるか、向かっている途中なら止まるまで待つ */
  const decide = () => {
    const cand = candRef.current;
    const topId = openRef.current;
    if (!cand || !topId) return;
    const already = cand.kind === "top" ? cand.id === topId : cand.id === currentSub(topId);
    const hold = aiming();
    rootRef.current?.setAttribute("data-hold", hold && !already ? "1" : "0");
    window.clearTimeout(aimTimer.current);
    if (already) return;
    const apply = () => {
      rootRef.current?.setAttribute("data-hold", "0");
      const c = candRef.current;
      const t = openRef.current;
      if (!c || !t) return;
      if (c.kind === "top") open(c.id);
      else pick(t, c.id);
    };
    if (hold) aimTimer.current = window.setTimeout(apply, AIM_DELAY);
    else apply();
  };

  const onNavPointerMove = (e: ReactPointerEvent) => {
    if (e.pointerType !== "mouse" || isShrunk()) return;
    const pts = ptsRef.current;
    pts.push({ x: e.clientX, y: e.clientY, t: e.timeStamp });
    // 止まっていた間の古い点は捨てる(向きは直近の動きだけで見る)
    while (pts.length > 4 || (pts.length > 1 && e.timeStamp - pts[0].t > HISTORY_MS)) pts.shift();
    decide();
  };

  const onNavPointerEnter = (e: ReactPointerEvent) => {
    if (e.pointerType !== "mouse" || isShrunk()) return;
    window.clearTimeout(closeTimer.current);
  };

  const onNavPointerLeave = (e: ReactPointerEvent) => {
    if (e.pointerType !== "mouse" || isShrunk()) return;
    candRef.current = null;
    ptsRef.current = [];
    aimRef.current?.setAttribute("points", "");
    window.clearTimeout(openTimer.current);
    window.clearTimeout(aimTimer.current);
    if (!openRef.current) return;
    closeTimer.current = window.setTimeout(() => open(null), CLOSE_DELAY);
  };

  const onTopEnter = (e: ReactPointerEvent, id: string) => {
    if (e.pointerType !== "mouse" || isShrunk()) return;
    candRef.current = { kind: "top", id };
    if (openRef.current) {
      decide();
      return;
    }
    window.clearTimeout(openTimer.current);
    // 開いた時刻はイベントの timeStamp から見積もる(performance.now と同じ基準)
    const openedAt = e.timeStamp + OPEN_DELAY;
    openTimer.current = window.setTimeout(() => {
      if (candRef.current?.kind === "top" && candRef.current.id === id) {
        hoverOpenedAt.current = openedAt;
        open(id);
      }
    }, OPEN_DELAY);
  };

  /** 項目から出たら候補から外す(通り過ぎた項目へ、行き先に着いてから切り替わらないように) */
  const forget = (id: string) => {
    if (candRef.current?.id !== id) return;
    candRef.current = null;
    window.clearTimeout(aimTimer.current);
    rootRef.current?.setAttribute("data-hold", "0");
  };

  const onTopLeave = (e: ReactPointerEvent, id: string) => {
    if (e.pointerType !== "mouse") return;
    window.clearTimeout(openTimer.current);
    forget(id);
  };

  const onTopClick = (e: MouseEvent, id: string) => {
    clearTimers();
    if (openRef.current === id && e.timeStamp - hoverOpenedAt.current < CLICK_GRACE) return;
    open(openRef.current === id ? null : id);
  };

  const focusTop = (id: string) => topBtns.current[id]?.focus();

  const onTopKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
    const id = NAV[index].id;
    let next: number | null = null;
    if (e.key === "ArrowRight") next = (index + 1) % NAV.length;
    else if (e.key === "ArrowLeft") next = (index - 1 + NAV.length) % NAV.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = NAV.length - 1;
    if (next !== null) {
      e.preventDefault();
      const nid = NAV[next].id;
      focusTop(nid);
      if (openRef.current) open(nid);
      return;
    }
    if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey && openRef.current === id)) {
      e.preventDefault();
      clearTimers();
      if (openRef.current === id) {
        subLinks.current[`${id}:${NAV[index].subs[0].id}`]?.focus();
      } else {
        focusFirstAfterOpen.current = true;
        open(id);
      }
      return;
    }
    if (e.key === "Escape" && openRef.current) {
      e.preventDefault();
      open(null);
    }
  };

  const onPanelKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const topId = openRef.current;
    if (!topId) return;
    const top = NAV.find((t) => t.id === topId)!;
    const index = NAV.indexOf(top);
    const target = e.target as HTMLElement;
    const subIndex = top.subs.findIndex((s) => subLinks.current[`${topId}:${s.id}`] === target);

    if (e.key === "Escape") {
      e.preventDefault();
      open(null);
      focusTop(topId);
      return;
    }
    if (subIndex >= 0) {
      let next: number | null = null;
      if (e.key === "ArrowDown") next = (subIndex + 1) % top.subs.length;
      else if (e.key === "ArrowUp") {
        if (subIndex === 0) {
          e.preventDefault();
          focusTop(topId);
          return;
        }
        next = subIndex - 1;
      } else if (e.key === "Home") next = 0;
      else if (e.key === "End") next = top.subs.length - 1;
      if (next !== null) {
        e.preventDefault();
        subLinks.current[`${topId}:${top.subs[next].id}`]?.focus();
        return;
      }
      if (e.key === "Tab" && e.shiftKey && subIndex === 0) {
        e.preventDefault();
        focusTop(topId);
        return;
      }
    }
    // 概要欄のリンクから先へ Tab したら、次のナビ項目へ戻す(パネルは DOM 上ナビの後ろにあるため)
    if (e.key === "Tab" && !e.shiftKey && target === openLinkRef.current && index < NAV.length - 1) {
      e.preventDefault();
      open(null);
      focusTop(NAV[index + 1].id);
    }
  };

  const onNavBlur = (e: FocusEvent) => {
    const to = e.relatedTarget as Node | null;
    if (to && navRef.current?.contains(to)) return;
    if (!to) return; // 窓の外へ出ただけなら開いたまま
    clearTimers();
    open(null);
  };

  const onSubEnter = (e: ReactPointerEvent, id: string) => {
    if (e.pointerType !== "mouse" || isShrunk()) return;
    candRef.current = { kind: "sub", id };
    decide();
  };

  const go = (sub: Sub) => setStatus(`「${sub.label}」(${sub.path})へ移動します。見本のため実際には移動しません`);

  const onSubClick = (e: MouseEvent, topId: string, sub: Sub) => {
    e.preventDefault();
    const touch = pointerKind.current !== "mouse" && e.detail > 0;
    pointerKind.current = "mouse";
    pointerPressing.current = false;
    const first = currentSub(topId) !== sub.id;
    pick(topId, sub.id);
    // タッチの1回目は概要を出すだけ。2回目で移動する
    if (touch && first) {
      setStatus("");
      return;
    }
    go(sub);
  };

  const top = NAV.find((t) => t.id === openId) ?? null;
  const shownTop = top ?? NAV[0];
  const sub = shownTop.subs.find((s) => s.id === activeSub[shownTop.id]) ?? shownTop.subs[0];
  const isOpen = openId !== null;

  return (
    <section
      aria-label="メガメニューの見本"
      className="@container w-full bg-white text-gray-900"
    >
      <style>{`
        @keyframes mm-preview-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
        [data-hold="1"] .mm-aim { fill: rgb(217 119 6 / 0.16); stroke-opacity: 1; }
      `}</style>

      <div ref={rootRef} data-hold="0" className="relative overflow-hidden border border-gray-200">
        {/* ナビとパネル(ここを出入りしたかで開閉の予約をかける) */}
        <div
          ref={navRef}
          className="relative z-20"
          onPointerMove={onNavPointerMove}
          onPointerEnter={onNavPointerEnter}
          onPointerLeave={onNavPointerLeave}
          onBlur={onNavBlur}
        >
          <header className="relative z-10 flex flex-col border-b border-gray-200 bg-white @2xl:h-16 @2xl:flex-row @2xl:items-stretch">
            <div className="flex h-12 shrink-0 items-center gap-2 px-4 @2xl:h-auto @2xl:px-6">
              <span aria-hidden className="block size-2.5 bg-amber-600" />
              <span className={`${montserrat.className} text-[15px] font-bold tracking-tight whitespace-nowrap`}>First CH</span>
            </div>

            <nav aria-label="グローバル" className="border-t border-gray-100 @2xl:ml-4 @2xl:border-t-0">
              <ul className="grid grid-cols-4 @2xl:flex @2xl:h-full">
                {NAV.map((t, i) => {
                  const current = openId === t.id;
                  return (
                    <li key={t.id} className="@2xl:h-full">
                      <button
                        ref={(el) => {
                          topBtns.current[t.id] = el;
                        }}
                        type="button"
                        aria-expanded={current}
                        aria-controls={panelId}
                        onPointerEnter={(e) => onTopEnter(e, t.id)}
                        onPointerLeave={(e) => onTopLeave(e, t.id)}
                        onClick={(e) => onTopClick(e, t.id)}
                        onKeyDown={(e) => onTopKeyDown(e, i)}
                        className={`relative flex h-11 w-full items-center justify-center gap-1 px-1 text-[13px] font-medium whitespace-nowrap transition-colors outline-none focus-visible:z-10 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none @2xl:h-full @2xl:px-4 @2xl:text-sm ${
                          current ? "text-gray-900" : "text-gray-600 hover:text-gray-900"
                        }`}
                      >
                        {t.label}
                        <svg
                          aria-hidden
                          viewBox="0 0 10 6"
                          className={`hidden h-1.5 w-2.5 fill-none stroke-current transition-transform duration-200 motion-reduce:transition-none @2xl:block ${current ? "rotate-180" : ""}`}
                        >
                          <path d="M1 1l4 4 4-4" strokeWidth="1.4" />
                        </svg>
                        <span
                          aria-hidden
                          className={`absolute inset-x-2 bottom-0 h-0.5 origin-left bg-amber-600 transition-transform duration-200 motion-reduce:transition-none @2xl:inset-x-4 ${
                            current ? "scale-x-100" : "scale-x-0"
                          }`}
                        />
                      </button>
                    </li>
                  );
                })}
              </ul>
            </nav>

            <a
              href="#contact"
              onClick={(e) => {
                e.preventDefault();
                setStatus("お問い合わせのページへ移動します。見本のため実際には移動しません");
              }}
              className="ml-auto hidden items-center border-l border-gray-200 px-6 text-sm font-semibold whitespace-nowrap text-amber-700 outline-none hover:bg-amber-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 @2xl:flex"
            >
              お問い合わせ
            </a>
          </header>

          {/* パネル(ナビの真下にページへ重ねて出す) */}
          <div
            ref={panelRef}
            id={panelId}
            role="region"
            aria-label={top ? `${top.label}の下位ページ` : undefined}
            inert={!isOpen}
            onKeyDown={onPanelKeyDown}
            className={`absolute inset-x-0 top-full border-b border-gray-200 bg-white shadow-[0_28px_40px_-28px_rgba(17,24,39,0.28)] transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none ${
              isOpen ? "translate-y-0 opacity-100" : "pointer-events-none -translate-y-1 opacity-0"
            }`}
          >
            <div className="grid @2xl:grid-cols-[15rem_1fr]">
              {/* 下位ページの一覧 */}
              <div className="border-b border-gray-100 px-4 pt-4 pb-3 @2xl:border-r @2xl:border-b-0 @2xl:px-6 @2xl:pt-6 @2xl:pb-6">
                <p className="text-xs leading-relaxed text-gray-500">{shownTop.lead}</p>
                <ul className="mt-3 border-t border-gray-100">
                  {shownTop.subs.map((s) => {
                    const on = s.id === sub.id;
                    return (
                      <li key={s.id} className="border-b border-gray-100">
                        <a
                          ref={(el) => {
                            subLinks.current[`${shownTop.id}:${s.id}`] = el;
                          }}
                          href={s.path}
                          aria-describedby={on ? summaryId : undefined}
                          onPointerEnter={(e) => onSubEnter(e, s.id)}
                          onPointerLeave={(e) => e.pointerType === "mouse" && forget(s.id)}
                          onPointerDown={(e) => {
                            pointerKind.current = e.pointerType;
                            pointerPressing.current = true;
                          }}
                          onFocus={() => {
                            // 押してのフォーカスは click 側で扱う(タッチの1回目を移動にしないため)
                            if (!pointerPressing.current) pick(shownTop.id, s.id);
                          }}
                          onClick={(e) => onSubClick(e, shownTop.id, s)}
                          className={`relative flex items-center justify-between gap-3 py-2.5 pr-2 pl-3.5 text-sm outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 @2xl:py-3 ${
                            on ? "bg-gray-50 font-semibold text-gray-900" : "text-gray-600 hover:text-gray-900"
                          }`}
                        >
                          <span
                            aria-hidden
                            className={`absolute inset-y-0 left-0 w-0.5 origin-top bg-amber-600 transition-transform duration-200 motion-reduce:transition-none ${
                              on ? "scale-y-100" : "scale-y-0"
                            }`}
                          />
                          <span className="whitespace-nowrap">{s.label}</span>
                          <span className={`${montserrat.className} text-[10px] font-medium text-gray-400 tabular-nums`}>
                            {String(shownTop.subs.indexOf(s) + 1).padStart(2, "0")}
                          </span>
                        </a>
                      </li>
                    );
                  })}
                </ul>
              </div>

              {/* 指している行き先の概要 */}
              <div ref={previewRef} className="px-4 pt-4 pb-5 @2xl:px-8 @2xl:pt-6 @2xl:pb-7">
                <div key={`${shownTop.id}:${sub.id}`} className="motion-safe:animate-[mm-preview-in_240ms_ease-out]">
                  <p className={`${montserrat.className} text-[11px] font-medium text-gray-400`}>{sub.path}</p>
                  <h3 className="mt-1 text-lg font-bold @2xl:text-xl">{sub.label}</h3>
                  <p id={summaryId} className="mt-2 text-[13px] leading-[1.85] text-gray-600 @2xl:text-sm">
                    {sub.summary}
                  </p>

                  <dl className="mt-4 grid grid-cols-2 border-y border-gray-200">
                    {sub.figures.map(([k, v], i) => (
                      <div key={k} className={`py-2.5 ${i === 1 ? "border-l border-gray-200 pl-4" : "pr-4"}`}>
                        <dt className="text-[11px] text-gray-500">{k}</dt>
                        <dd className="mt-0.5 text-base font-bold whitespace-nowrap tabular-nums">{v}</dd>
                      </div>
                    ))}
                  </dl>

                  <div className="mt-4 @2xl:grid @2xl:grid-cols-[1fr_auto] @2xl:items-end @2xl:gap-6">
                    <div>
                      <p className="text-[11px] text-gray-500">このページの見出し</p>
                      <ol className="mt-1.5 grid grid-cols-2 gap-x-4 text-[13px] text-gray-700">
                        {sub.outline.map((h, i) => (
                          <li key={h} className="flex items-baseline gap-2 border-b border-gray-100 py-1.5">
                            <span className={`${montserrat.className} text-[10px] font-semibold text-amber-700 tabular-nums`}>
                              {String(i + 1).padStart(2, "0")}
                            </span>
                            <span className="whitespace-nowrap">{h}</span>
                          </li>
                        ))}
                      </ol>
                    </div>
                    <a
                      ref={openLinkRef}
                      href={sub.path}
                      onClick={(e) => {
                        e.preventDefault();
                        go(sub);
                      }}
                      className="mt-4 inline-block text-sm font-semibold whitespace-nowrap text-amber-700 underline decoration-amber-600/40 underline-offset-[6px] outline-none hover:decoration-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 @2xl:mt-0"
                    >
                      {sub.label}を開く
                    </a>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>

        {/* 下に敷いた見本のページ(パネルがページへ重なることを見せる) */}
        <div aria-hidden className="min-h-[38rem] px-4 pt-8 @2xl:min-h-[25rem] @2xl:px-6 @2xl:pt-10">
          <div className="h-2.5 w-24 bg-amber-600/30" />
          <div className="mt-4 h-6 w-3/4 max-w-md bg-gray-100" />
          <div className="mt-2 h-6 w-1/2 max-w-xs bg-gray-100" />
          <div className="mt-8 grid gap-6 border-t border-gray-100 pt-6 @2xl:grid-cols-3">
            {[0, 1, 2].map((i) => (
              <div key={i} className="space-y-2">
                <div className="h-2 w-full bg-gray-100" />
                <div className="h-2 w-5/6 bg-gray-100" />
                <div className="h-2 w-2/3 bg-gray-100" />
              </div>
            ))}
          </div>
        </div>

        {/* 猶予の三角形(切替で表示) */}
        <svg aria-hidden className={`pointer-events-none absolute inset-0 z-30 size-full ${showAim ? "" : "hidden"}`}>
          <polygon
            ref={aimRef}
            points=""
            className="mm-aim"
            fill="rgb(217 119 6 / 0.07)"
            stroke="#d97706"
            strokeOpacity="0.5"
            strokeDasharray="4 3"
          />
        </svg>
      </div>

      {/* 見本の操作 */}
      <div className="flex flex-col gap-3 border-x border-b border-gray-200 px-4 py-3 text-xs text-gray-600 @2xl:flex-row @2xl:items-center @2xl:justify-between @2xl:px-6">
        <label className="flex cursor-pointer items-center gap-2">
          <input
            type="checkbox"
            checked={showAim}
            onChange={(e) => setShowAim(e.target.checked)}
            className="size-4 accent-amber-600 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          />
          斜めに動かすときの猶予を表示する
        </label>
        <p className="leading-relaxed text-gray-500">
          キーボードは Tab でナビへ、
          <kbd className="border border-gray-300 px-1 font-sans">↓</kbd> で一覧へ、
          <kbd className="border border-gray-300 px-1 font-sans">Esc</kbd> で閉じる
        </p>
      </div>
      <p role="status" className="sr-only">
        {status}
      </p>
      {status && (
        <p aria-hidden className="border-x border-b border-gray-200 bg-amber-50/60 px-4 py-2 text-xs text-amber-900 @2xl:px-6">
          {status}
        </p>
      )}
    </section>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/mega-menu-preview.json