First CH Designs
BLK-27Blocks

ディレクトリ型メガフッター

情報量そのものを主役に据えたフッター。事業別の4列ディレクトリを、左端に立てた和文縦書きラベル+縦hairlineの「軸」で束ねる。列の区切りはSPが横罫の積み上げ、PCが縦罫の列で、DOMは1つのまま(divide-xは複数行グリッドで2行目の先頭に罫が出るため使わない)。SPでは各列がaria-expandedの開閉に切り替わり、長いサイトマップでも末尾が伸びない。リンクのホバーは幅ゼロのアンバー罫を先頭に仕込み、文字をtranslate-xで退かせるだけでレイアウトを動かさない。入場は軸のscale-yと列のスタッガーに集約。画像素材ゼロ。

追加日:
2026-08-16
依存:
なし
tags
#footer #sitemap #directory #mega-footer #vertical-text #accordion #hairline #a11y #no-image

プレビュー

"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",
});

/**
 * ディレクトリ型メガフッター / Directory Mega Footer
 * 情報量そのものを主役に据えたフッター。3段の標準フッター(罫線だけで整理する型)とは役割が違い、
 * 事業・サービスが多い企業で「フッターが索引として機能する」ことを狙う。
 * 白 + アンバー #d97706 / Montserrat + 和文ゴシックで再構築したもので、画像素材ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 多列ディレクトリは「列の数」ではなく「左端の軸」で成立させる: グリッドの左に幅 36〜40px の
 *   レールを立て、和文の縦書きラベル(writing-mode: vertical-rl)+縦 hairline を通す。
 *   列が4本に増えても視線の起点が固定され、リンクの羅列が表に見える。
 *   縦書きラベルは装飾ではなく nav の見出しそのもの(aria-labelledby で紐付ける)にすると、
 *   「意味のない小ラベル」にならずに構図上の役割を持てる。
 * - 情報量の多いフッターは SP で必ず畳む: 列見出しを button(aria-expanded / aria-controls)に、
 *   PC では静的な見出しに切り替える(button は lg:hidden、見出しは hidden lg:flex)。
 *   DOMを2つ持つ代わりに「PCでは開閉できてしまう」矛盾を避けられる。開閉で高さは animate せず、
 *   動かすのは矢印の rotate だけにする(高さの transition は transform/opacity 以外=カクつく)。
 * - グリッドの区切りに divide-x を使わない: 複数行に折り返すグリッドでは2行目の先頭にも
 *   左罫が出る。列側に border-t → lg:border-t-0 lg:border-l lg:first:border-l-0 を持たせて
 *   「SPは横罫の積み上げ / PCは縦罫の列」を1つのDOMで作る。
 * - リンクのホバーは「幅ゼロのアンバーの罫」を先頭に仕込み、文字を -translate-x-3 で被せておく:
 *   ホバーで罫が scale-x 0→1、文字が translate-x で退く。レイアウトを動かさずに
 *   マーカーが差し込まれたように見え、transform だけで完結する。
 * - 入場は「軸を1本引く」に集約: レールの縦 hairline を origin-top で scale-y 0→1、
 *   列は translate-y-2 → 0 のスタッガー。reduced-motion では全て即時表示。
 *
 * 実案件への移植:
 * <footer> をそのまま最下部に置く(rounded/border は外して border-t だけにする)。
 * 列数は lg:grid-cols-4 を事業数に合わせて増減する(5列を超えるなら2段に折る方が読める)。
 */

type LinkItem = { label: string; href: string };
type Group = { heading: string; links: LinkItem[] };

const GROUPS: Group[] = [
  {
    heading: "Web制作",
    links: [
      { label: "コーポレートサイト", href: "#" },
      { label: "ランディングページ", href: "#" },
      { label: "ECサイト構築", href: "#" },
      { label: "サイトリニューアル", href: "#" },
      { label: "多言語サイト", href: "#" },
    ],
  },
  {
    heading: "運用・改善",
    links: [
      { label: "保守・運用代行", href: "#" },
      { label: "SEO改善", href: "#" },
      { label: "アクセス解析", href: "#" },
      { label: "コンテンツ更新", href: "#" },
    ],
  },
  {
    heading: "AI導入支援",
    links: [
      { label: "活用レクチャー", href: "#" },
      { label: "業務自動化の設計", href: "#" },
      { label: "導入診断レポート", href: "#" },
      { label: "月額伴走支援", href: "#" },
    ],
  },
  {
    heading: "会社・採用",
    links: [
      { label: "会社概要", href: "#" },
      { label: "制作実績", href: "#" },
      { label: "お知らせ", href: "#" },
      { label: "採用情報", href: "#" },
      { label: "お問い合わせ", href: "#" },
    ],
  },
];

const LEGAL: LinkItem[] = [
  { label: "プライバシーポリシー", href: "#" },
  { label: "特定商取引法に基づく表記", href: "#" },
  { label: "サイトのご利用について", href: "#" },
];

/** ディレクトリ内のリンク(先頭にアンバーの罫がホバーで差し込まれる) */
function DirectoryLink({ label, href }: LinkItem) {
  return (
    <a
      href={href}
      className="group flex items-center py-1.5 text-[12px] leading-relaxed text-gray-600 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
    >
      <span
        aria-hidden="true"
        className="h-px w-3 shrink-0 origin-left scale-x-0 bg-amber-600 transition-transform duration-300 ease-out group-hover:scale-x-100 group-focus-visible:scale-x-100 motion-reduce:transition-none"
      />
      <span className="-translate-x-3 transition-transform duration-300 ease-out group-hover:translate-x-0 group-focus-visible:translate-x-0 motion-reduce:transition-none">
        {label}
      </span>
    </a>
  );
}

export default function FooterDirectory() {
  const rootRef = useRef<HTMLElement>(null);
  const [inView, setInView] = useState(false);
  // SPでの開閉状態(PCでは lg:block で常に開く)。初期は先頭列だけ開く。
  const [openIndex, setOpenIndex] = useState(0);
  const uid = useId();

  // 画面に入ったら1度だけリビール(reduced-motion 時は motion-reduce: クラスで即時表示)
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setInView(true);
          io.disconnect();
        }
      },
      { threshold: 0.1 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const toTop = () => {
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
  };

  const count = (n: number) => String(n).padStart(2, "0");

  return (
    <footer
      ref={rootRef}
      className="relative w-full max-w-5xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      <div className="px-5 sm:px-7">
        {/* 1段目: 左のレール(縦書きの索引ラベル+軸)+ 多列ディレクトリ */}
        <div className="grid grid-cols-[auto_1fr] gap-x-4 lg:gap-x-6">
          <div className="flex flex-col items-center pt-6 pb-6 lg:pt-8">
            <span
              id={`${uid}-index`}
              className="text-[10px] leading-none font-semibold tracking-[0.3em] whitespace-nowrap text-gray-500 [writing-mode:vertical-rl]"
            >
              サイト内インデックス
            </span>
            <span
              aria-hidden="true"
              className={`mt-3 w-px flex-1 origin-top bg-gray-200 transition-transform duration-700 ease-out motion-reduce:transition-none ${
                inView ? "scale-y-100" : "scale-y-0 motion-reduce:scale-y-100"
              }`}
            />
          </div>

          <nav
            aria-labelledby={`${uid}-index`}
            className="grid grid-cols-1 lg:grid-cols-4"
          >
            {GROUPS.map((group, i) => {
              const open = openIndex === i;
              const panelId = `${uid}-panel-${i}`;
              return (
                <div
                  key={group.heading}
                  className={`border-t border-gray-200 py-4 transition-[opacity,transform] duration-700 ease-out first:border-t-0 motion-reduce:transition-none lg:border-t-0 lg:border-l lg:px-4 lg:py-8 lg:first:border-l-0 lg:first:pl-0 lg:last:pr-0 ${
                    inView
                      ? "translate-y-0 opacity-100"
                      : "translate-y-2 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
                  }`}
                  style={{ transitionDelay: inView ? `${140 + i * 90}ms` : "0ms" }}
                >
                  {/* SP: 開閉ボタン / PC: 静的な見出し(同じ役割を2つのDOMで出し分ける) */}
                  <button
                    type="button"
                    aria-expanded={open}
                    aria-controls={panelId}
                    onClick={() => setOpenIndex(open ? -1 : i)}
                    className="flex w-full items-center gap-2 py-1.5 text-left text-[12px] font-bold tracking-[0.12em] text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 lg:hidden"
                  >
                    <span
                      aria-hidden="true"
                      className="inline-block size-1 shrink-0 bg-amber-600"
                    />
                    {group.heading}
                    <span
                      className={`${montserrat.className} ml-auto text-[10px] font-semibold tabular-nums text-gray-300`}
                    >
                      {count(group.links.length)}
                    </span>
                    <svg
                      aria-hidden="true"
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.6"
                      className={`size-3.5 shrink-0 text-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                        open ? "rotate-180" : ""
                      }`}
                    >
                      <path d="M5 8l5 5 5-5" strokeLinecap="round" />
                    </svg>
                  </button>
                  <h2 className="hidden items-center gap-2 text-[12px] font-bold tracking-[0.12em] text-gray-900 lg:flex">
                    <span
                      aria-hidden="true"
                      className="inline-block size-1 shrink-0 bg-amber-600"
                    />
                    {group.heading}
                    <span
                      className={`${montserrat.className} ml-auto text-[10px] font-semibold tabular-nums text-gray-300`}
                    >
                      {count(group.links.length)}
                    </span>
                  </h2>

                  {/* pl-3 = 見出しの角(4px)+gap(8px)。ホバー前のリンク文字が見出しの文字と縦に揃う */}
                  <ul
                    id={panelId}
                    className={`mt-1 pl-3 lg:mt-3 lg:block ${open ? "" : "hidden"}`}
                  >
                    {group.links.map((link) => (
                      <li key={link.label}>
                        <DirectoryLink {...link} />
                      </li>
                    ))}
                  </ul>
                </div>
              );
            })}
          </nav>
        </div>

        {/* 2段目: 法務・ご利用について(ディレクトリの列に混ぜず1行に寝かせる) */}
        <ul className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-gray-200 py-4">
          {LEGAL.map((item) => (
            <li key={item.label}>
              <a
                href={item.href}
                className="inline-block py-0.5 text-[11px] whitespace-nowrap text-gray-500 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
              >
                {item.label}
              </a>
            </li>
          ))}
        </ul>

        {/* 3段目: ワードマーク小・連絡先・コピーライト・トップへ戻る */}
        <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-gray-200 py-5">
          <a
            href="#"
            className="group inline-flex shrink-0 items-baseline rounded-sm whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
          >
            <span
              className={`${montserrat.className} text-[13px] font-bold tracking-[0.16em] text-gray-900`}
            >
              FIRST CH
            </span>
            <span
              aria-hidden="true"
              className="ml-1 inline-block size-1.5 bg-amber-600 transition-transform duration-300 ease-out group-hover:translate-y-[-2px] motion-reduce:transition-none"
            />
          </a>

          <span
            aria-hidden="true"
            className="hidden h-3 w-px shrink-0 bg-gray-200 sm:block"
          />

          <dl className="flex flex-wrap items-baseline gap-x-5 gap-y-1 text-[11px] text-gray-500">
            <div className="flex items-baseline gap-2 whitespace-nowrap">
              <dt className="font-semibold text-gray-700">電話</dt>
              <dd className={`${montserrat.className} tabular-nums`}>
                03-0000-0000
              </dd>
            </div>
            <div className="flex items-baseline gap-2 whitespace-nowrap">
              <dt className="font-semibold text-gray-700">受付</dt>
              <dd>平日 10:00–19:00</dd>
            </div>
          </dl>

          <p
            className={`${montserrat.className} ml-auto text-[11px] tracking-[0.08em] whitespace-nowrap text-gray-400`}
          >
            © 2026 First CH LLC.
          </p>

          <button
            type="button"
            onClick={toTop}
            className="group inline-flex shrink-0 items-center gap-2 rounded-sm text-[11px] font-semibold whitespace-nowrap text-gray-600 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
          >
            トップへ戻る
            <span
              aria-hidden="true"
              className="inline-block transition-transform duration-300 ease-out group-hover:-translate-y-0.5 motion-reduce:transition-none"
            >
              ↑
            </span>
          </button>
        </div>
      </div>
    </footer>
  );
}

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

npx shadcn@latest add https://designs.first-ch.com/r/footer-directory.json