First CH Designs
BLK-09Blocks

Site Footer

A three-tier corporate footer organised by hairlines alone — no panels, no cards, no background fills, so the end of the page never outweighs the CTA above it. Tier one is the sitemap (services / company / legal); the divide flips from horizontal rules on mobile to vertical column rules on desktop from a single DOM. Tier two restates the logo small — wordmark only, so it does not duplicate the header — next to a one-line address and contact details. Tier three carries the copyright and a back-to-top control. An amber tick wipes in once on the top rule via IntersectionObserver; link hovers are a 2px nudge plus an underline scale-x. Zero image assets.

Added:
2026-08-03
Dependencies:
None
tags
#footer #sitemap #corporate #hairline #no-card #back-to-top #a11y #no-image

Preview

"use client";

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

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

/**
 * サイトフッター / Site Footer
 * 参考: 他社コーポレートサイトに見られるフッター(多段リンク・罫線だけの整理)。
 * 構造だけ借りて First CH のブランド(白 + アンバー #d97706 / Montserrat + Noto Sans JP)で
 * 再構築したもので、見た目のコピーではない。画像素材ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - フッターは「面」ではなく「罫線」で組む: 背景色ブロックもカードも使わず、白地に hairline
 *   (border-gray-200)だけで3段に切る。フッターに色面を敷くとページ末尾が急に重くなり、
 *   本文側のCTAより目立ってしまう。
 * - 列の区切りはブレークポイントで divide の向きを入れ替える:
 *   grid-cols-1 divide-y → sm:grid-cols-3 sm:divide-x sm:divide-y-0。
 *   SPでは横罫(積み上げの段組み)、PCでは縦罫(台帳の列)になり、DOMは1つのまま。
 * - ロゴの再掲は最下段に置く: フッター左上に置くとヘッダーと同じロックアップが同じ位置に出て
 *   「ヘッダーの複製」に見える。ロゴ+住所+電話を最下段のユーティリティ帯にまとめると、
 *   上段のリンク列(=サイトマップ)と役割が衝突しない。モノグラムは外しワードマークだけ再掲する。
 * - 見出しは 4px のアンバー角と和文の短いラベルだけ: 意味のない英語の小ラベル(SERVICE 等)を
 *   足すと、どのサイトにも移植できる=役割のない装飾になる。
 * - 入場モーションは「1本の罫線」に集約: 上端に w-16 のアンバーの tick を重ね、
 *   IntersectionObserver で1度だけ origin-left の scale-x 0→1 でワイプ。列は translate-y-2→0 と
 *   opacity のスタッガーのみ。動かすのは transform / opacity だけで、reduced-motion では即時表示。
 * - リンクのホバーは「文字ごと 2px 右へ + アンバーの下線ワイプ」。下線を内側の relative span に
 *   入れると、文字の translate に下線が追従して長さもズレない。
 *
 * 実案件への移植:
 * <footer> をそのまま最下部に置くだけでよい(デモシェルは同梱していない)。
 * 「トップへ戻る」は window.scrollTo で、prefers-reduced-motion: reduce のときは smooth を外す。
 */

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

const GROUPS: Group[] = [
  {
    heading: "サービス",
    links: [
      { label: "コーポレートサイト制作", href: "#" },
      { label: "ランディングページ制作", href: "#" },
      { label: "保守・運用改善", href: "#" },
      { label: "AIコンサルティング", href: "#" },
    ],
  },
  {
    heading: "会社案内",
    links: [
      { label: "会社概要", href: "#" },
      { label: "制作実績", href: "#" },
      { label: "お知らせ", href: "#" },
      { label: "採用情報", href: "#" },
    ],
  },
  {
    heading: "法務・ご利用について",
    links: [
      { label: "プライバシーポリシー", href: "#" },
      { label: "特定商取引法に基づく表記", href: "#" },
      { label: "サイトのご利用について", href: "#" },
    ],
  },
];

function FooterLink({ label, href }: LinkItem) {
  return (
    <a
      href={href}
      className="group inline-flex 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 className="relative inline-block transition-transform duration-300 ease-out group-hover:translate-x-0.5 group-focus-visible:translate-x-0.5 motion-reduce:transition-none">
        {label}
        <span
          aria-hidden="true"
          className="absolute -bottom-0.5 left-0 h-px w-full 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>
    </a>
  );
}

export default function SiteFooter() {
  const rootRef = useRef<HTMLElement>(null);
  const [inView, setInView] = useState(false);

  // 画面に入ったら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.15 },
    );
    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" });
  };

  return (
    <footer
      ref={rootRef}
      className="relative w-full max-w-5xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      {/* 上端の罫線に重ねるアンバーの tick(入場で左からワイプ) */}
      <span
        aria-hidden="true"
        className={`absolute top-0 left-0 h-px w-16 origin-left bg-amber-600 transition-transform duration-700 ease-out motion-reduce:transition-none ${
          inView ? "scale-x-100" : "scale-x-0 motion-reduce:scale-x-100"
        }`}
      />

      <div className="px-6 sm:px-8">
        {/* 1段目: サイトマップ(SPは横罫の積み上げ / PCは縦罫の列) */}
        <nav
          aria-label="フッター"
          className="grid grid-cols-1 divide-y divide-gray-200 sm:grid-cols-3 sm:divide-x sm:divide-y-0"
        >
          {GROUPS.map((group, i) => (
            <div
              key={group.heading}
              className={`py-7 transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none sm:px-6 sm:py-9 sm:first:pl-0 sm: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 ? `${120 + i * 90}ms` : "0ms" }}
            >
              <h2 className="flex items-center gap-2 text-[11px] font-bold tracking-[0.14em] text-gray-900">
                <span
                  aria-hidden="true"
                  className="inline-block size-1 shrink-0 bg-amber-600"
                />
                {group.heading}
              </h2>
              <ul className="mt-3">
                {group.links.map((link) => (
                  <li key={link.label}>
                    <FooterLink {...link} />
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>

        {/* 2段目: ロゴ小・住所1行・連絡先(ヘッダーの複製にしないため最下段へ置く) */}
        <div className="flex flex-wrap items-center gap-x-5 gap-y-3 border-t border-gray-200 py-6">
          <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"
          />

          <address className="text-[11px] leading-relaxed font-normal text-gray-500 not-italic">
            <span className={`${montserrat.className} tabular-nums`}>
              〒150-0000
            </span>{" "}
            東京都渋谷区0-0-0 サンプルビル5F
          </address>

          <dl className="ml-auto flex flex-wrap items-baseline gap-x-5 gap-y-1 text-[11px] text-gray-500">
            <div className="flex items-baseline gap-2">
              <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">
              <dt className="font-semibold text-gray-700">受付</dt>
              <dd>平日 10:00–19:00</dd>
            </div>
          </dl>
        </div>

        {/* 3段目: コピーライト + トップへ戻る */}
        <div className="flex flex-col gap-3 border-t border-gray-200 py-5 sm:flex-row sm:items-center sm:justify-between">
          <p
            className={`${montserrat.className} text-[11px] tracking-[0.08em] text-gray-400`}
          >
            © 2026 First CH LLC.
          </p>
          <button
            type="button"
            onClick={toTop}
            className="group inline-flex items-center gap-2 self-start rounded-sm text-[11px] font-semibold text-gray-600 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 sm:self-auto"
          >
            トップへ戻る
            <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/site-footer.json