BLK-27Blocks
Directory Mega Footer
A footer whose sheer volume of links is the point. Four business-unit columns are bound together by a left rail — a vertical Japanese index label plus a hairline axis — so the eye keeps a fixed starting point as the columns multiply. The dividers flip from stacked horizontal rules on mobile to vertical column rules on desktop from a single DOM (divide-x is avoided because it leaks a rule onto the first cell of a wrapped row). On mobile each column collapses behind an aria-expanded toggle, so a long sitemap never stretches the page end. Link hover slides a zero-width amber rule in front of the label with transform only, leaving layout untouched. Entrance is a single scale-y on the axis plus a column stagger. Zero image assets.
- Added:
- 2026-08-16
- Dependencies:
- None
- tags
- #footer #sitemap #directory #mega-footer #vertical-text #accordion #hairline #a11y #no-image
Preview
"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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/footer-directory.json