First CH Designs
BLK-08Blocks

Global Site Header

A corporate global header: logo left, nav right, and exactly one CTA pinned to the far right behind a vertical hairline so the hierarchy is unmistakable. On scroll a blurred white backdrop fades in and a bottom hairline wipes in from the left — no height or padding changes, so nothing reflows. On mobile a hamburger opens a full-screen overlay (Escape to close, inert while hidden). Includes a skip link; zero image assets. Ships with a scrollable demo shell so the scroll state is visible inside the gallery.

Added:
2026-08-02
Dependencies:
None
tags
#header #navigation #sticky #corporate #hairline #mobile-overlay #a11y #no-image

Preview

Web Production Studio

事業の伸びしろを、Webから引き出す。

コーポレートサイトからLP、公開後の運用改善まで。 中小企業のWebを、成果の出る標準へ整えます。

Services

  • コーポレートサイト制作情報設計から実装・公開まで一貫して。
  • ランディングページ制作問い合わせまでの距離を最短にする構成で。
  • 保守・運用改善公開後の更新代行と、数字を見た改善提案を毎月。
© First CH — ヘッダー標本用のダミー本文です。
"use client";

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

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

/**
 * グローバルヘッダー・ナビ / Global Site Header
 * 参考: 他社コーポレートサイトに見られるヘッダー(ロゴ左・ナビ右・CTAは右端に1本)。
 * 構造だけ借りて First CH のブランド(白 + アンバー #d97706 / Montserrat + Noto Sans JP)で
 * 再構築したもので、見た目のコピーではない。画像素材ゼロ。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 主従はCTAの本数で作る: ナビ右端に実線ボタンを1本だけ置き、他は全部テキスト+hairline下線に落とす。
 *   ボタンを2本置いた瞬間に「どちらも押されないヘッダー」になる。ナビとCTAの間は縦hairline1本で仕切る。
 * - スクロール状態変化は transform/opacity だけで作る: ヘッダーの高さ・padding は一切動かさず、
 *   (a) bg-white/90 + backdrop-blur の背景レイヤーを opacity 0→1、(b) 下端 hairline を origin-left の
 *   scale-x 0→1 で左からワイプ。高さを動かすと本文がリフローし、reduced-motion でも止められない
 *   動きになる(=止められる動きだけを足す、が原則)。
 * - 閾値には遊びを入れる: scrollTop > 8px で切り替え、0px 付近のちらつきを防ぐ。
 * - ヘッダーはヒーローに「かぶせる」: header に -mb(=自身の高さ)、直下のセクションに同値の pt を入れると、
 *   sticky のままヒーロー上に重なる。透明状態の下に必ず色地が来るので状態変化が読める。
 * - SPのフルスクリーンオーバーレイ: display:none にせず常時DOMに置き opacity+translate で出し入れし、
 *   閉じている間は inert(React 19 の boolean 属性)で tab 到達だけ止める。出入りにモーションが乗り、
 *   かつ背後のリンクをフォーカスで踏まない。Escape で閉じ、開閉でフォーカスを閉じるボタン⇄トグルへ戻す。
 *   オーバーレイ自身を overflow-y-auto + overscroll-contain にすると、背後のスクロールを
 *   body ロックなしで止められる(SPでスクロール位置が飛ばない)。
 * - ハンバーガーも × も h-px の直線と rotate だけで描く(アイコンSVG・アイコンフォント不要)。
 * - スキップリンクは -translate-y で画面外に置き focus で 0 に戻す(hidden にすると読み上げ順から消える)。
 *
 * 実案件への移植:
 * このファイルはギャラリー内でスクロール状態変化を見せるため「デモシェル(自前のスクロールコンテナ)+
 * ダミーのページ本文」を同梱している。実サイトでは <header> だけを取り出して
 *   1. <header className="sticky top-0 z-50 ..."> をページ直下に置く
 *   2. スクロール監視を shell.scrollTop → window.scrollY に差し替える
 *   3. オーバーレイを absolute inset-0 → fixed inset-0 にし、開いている間は
 *      document.body に overflow-hidden を付ける
 * の3点を変えるだけでよい。
 */

type NavItem = { label: string; href: string; current?: boolean };

const NAV: NavItem[] = [
  { label: "サービス", href: "#", current: true },
  { label: "制作実績", href: "#" },
  { label: "会社案内", href: "#" },
  { label: "お知らせ", href: "#" },
];

/** ロゴ・ロックアップ(画像なし: 角丸の墨マーク + 欧文ワードマーク + アンバーの終止符) */
function Logo() {
  return (
    <a
      href="#"
      className="group inline-flex items-center gap-2.5 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
    >
      <span
        aria-hidden="true"
        className={`${montserrat.className} grid size-8 shrink-0 place-items-center rounded-[6px] bg-gray-900 text-[13px] font-bold text-white transition-transform duration-300 ease-out group-hover:-rotate-6 motion-reduce:transition-none`}
      >
        F
      </span>
      <span className="flex items-baseline whitespace-nowrap">
        <span
          className={`${montserrat.className} text-[15px] 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"
        />
      </span>
    </a>
  );
}

export default function SiteHeader() {
  const shellRef = useRef<HTMLDivElement>(null);
  const toggleRef = useRef<HTMLButtonElement>(null);
  const closeRef = useRef<HTMLButtonElement>(null);
  const openedOnce = useRef(false);
  const [scrolled, setScrolled] = useState(false);
  const [progress, setProgress] = useState(0);
  const [open, setOpen] = useState(false);

  // スクロール状態(実案件では window.scrollY を見る。ここはデモシェルの scrollTop)
  useEffect(() => {
    const el = shellRef.current;
    if (!el) return;
    const onScroll = () => {
      const max = el.scrollHeight - el.clientHeight;
      setScrolled(el.scrollTop > 8);
      setProgress(max > 0 ? Math.min(1, el.scrollTop / max) : 0);
    };
    onScroll();
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  // Escape で閉じる
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setOpen(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  // lg 以上へ広がったらオーバーレイを閉じる(回転・リサイズで開きっぱなしにしない)
  useEffect(() => {
    const mq = window.matchMedia("(min-width: 1024px)");
    const onChange = () => {
      if (mq.matches) setOpen(false);
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  // 開いたら閉じるボタンへ、閉じたらトグルへフォーカスを戻す(初回マウントでは動かさない)
  useEffect(() => {
    if (open) {
      openedOnce.current = true;
      closeRef.current?.focus();
    } else if (openedOnce.current) {
      toggleRef.current?.focus();
    }
  }, [open]);

  const navLink =
    "group relative block px-3 py-2 text-[13px] font-semibold whitespace-nowrap text-gray-700 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600";

  return (
    <div className="relative w-full max-w-5xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
      {/* デモシェル: 実サイトではこのスクロールコンテナごと不要(window スクロールに読み替える) */}
      <div ref={shellRef} className="h-[460px] overflow-y-auto sm:h-[520px]">
        {/* ===== ここから移植対象のヘッダー ===== */}
        <header className="sticky top-0 z-30 -mb-16 sm:-mb-[72px]">
          <div className="relative">
            {/* 背景レイヤー(スクロールで出る) */}
            <div
              aria-hidden="true"
              className={`absolute inset-0 bg-white/90 backdrop-blur-lg transition-opacity duration-500 ease-out motion-reduce:transition-none ${
                scrolled ? "opacity-100" : "opacity-0"
              }`}
            />
            {/* 下端 hairline(左からワイプ) */}
            <div
              aria-hidden="true"
              className={`absolute bottom-0 left-0 h-px w-full origin-left bg-gray-200 transition-transform duration-500 ease-out motion-reduce:transition-none ${
                scrolled ? "scale-x-100" : "scale-x-0"
              }`}
            />
            {/* 読了インジケータ(scroll連動・transformのみ) */}
            <div
              aria-hidden="true"
              className="absolute bottom-0 left-0 h-px w-full origin-left bg-amber-600"
              style={{ transform: `scaleX(${progress})` }}
            />

            <div className="relative flex h-16 items-center gap-4 px-4 sm:h-[72px] sm:px-6 lg:px-8">
              {/* スキップリンク(フォーカスで降りてくる) */}
              <a
                href="#main"
                className="absolute top-2 left-4 z-10 -translate-y-16 rounded-md bg-gray-900 px-3 py-1.5 text-xs font-semibold text-white transition-transform duration-200 ease-out focus:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
              >
                本文へスキップ
              </a>

              <Logo />

              <div className="ml-auto flex items-center gap-4">
                <nav aria-label="グローバル" className="hidden lg:block">
                  <ul className="flex items-center gap-1">
                    {NAV.map((item) => (
                      <li key={item.label}>
                        <a
                          href={item.href}
                          aria-current={item.current ? "page" : undefined}
                          className={navLink}
                        >
                          {item.label}
                          <span
                            aria-hidden="true"
                            className={`absolute right-3 bottom-1 left-3 h-px origin-left bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                              item.current
                                ? "scale-x-100"
                                : "scale-x-0 group-hover:scale-x-100 group-focus-visible:scale-x-100"
                            }`}
                          />
                        </a>
                      </li>
                    ))}
                  </ul>
                </nav>

                {/* ナビとCTAの主従を仕切る縦 hairline */}
                <span
                  aria-hidden="true"
                  className="hidden h-4 w-px bg-gray-200 lg:block"
                />

                {/* CTAはここに1本だけ */}
                <a
                  href="#"
                  className="group relative hidden overflow-hidden rounded-lg bg-amber-600 px-5 py-2.5 text-[13px] font-bold whitespace-nowrap text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 lg:inline-flex lg:items-center lg:gap-2"
                >
                  <span
                    aria-hidden="true"
                    className="absolute inset-0 origin-left scale-x-0 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-100 motion-reduce:transition-none"
                  />
                  <span className="relative">お問い合わせ</span>
                  <span
                    aria-hidden="true"
                    className="relative inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
                  >
                    →
                  </span>
                </a>

                {/* SP: ハンバーガー */}
                <button
                  ref={toggleRef}
                  type="button"
                  onClick={() => setOpen((v) => !v)}
                  aria-expanded={open}
                  aria-controls="site-header-overlay"
                  aria-label="メニューを開く"
                  className="grid size-10 shrink-0 place-items-center rounded-lg border border-gray-200 bg-white/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 lg:hidden"
                >
                  <span aria-hidden="true" className="relative block h-[13px] w-5">
                    <span className="absolute top-0 left-0 h-px w-full bg-gray-900" />
                    <span className="absolute top-[6px] left-0 h-px w-full bg-gray-900" />
                    <span className="absolute top-[12px] left-0 h-px w-full bg-gray-900" />
                  </span>
                </button>
              </div>
            </div>
          </div>
        </header>
        {/* ===== ここまで移植対象のヘッダー ===== */}

        {/* --- 以下はヘッダーの状態変化を見せるためのダミー本文(移植不要) --- */}
        <main id="main">
          <section className="bg-[#faf7f2] px-6 pt-16 pb-16 sm:px-10 sm:pt-[72px] sm:pb-24">
            <div className="pt-10 sm:pt-14">
              <p className="text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
                Web Production Studio
              </p>
              {/* ダミー本文の見出しはpで組む。この標本の成果物はヘッダーだけで、ギャラリーの
                  カードプレビューに載るこの部分がh1だと1ページにh1が複数出てしまうため。 */}
              <p className="mt-4 max-w-md text-[clamp(1.45rem,3.4vw,2.4rem)] leading-[1.35] font-bold tracking-tight text-gray-900">
                事業の伸びしろを、Webから引き出す。
              </p>
              <p className="mt-5 max-w-md text-[13px] leading-[1.9] font-medium text-gray-600">
                コーポレートサイトからLP、公開後の運用改善まで。
                中小企業のWebを、成果の出る標準へ整えます。
              </p>
            </div>
          </section>

          <section className="border-t border-gray-200 px-6 py-10 sm:px-10 sm:py-14">
            <h2 className="text-[11px] font-bold tracking-[0.22em] text-gray-400 uppercase">
              Services
            </h2>
            <ul className="mt-4 divide-y divide-gray-200 border-t border-gray-200">
              {[
                ["コーポレートサイト制作", "情報設計から実装・公開まで一貫して。"],
                ["ランディングページ制作", "問い合わせまでの距離を最短にする構成で。"],
                ["保守・運用改善", "公開後の更新代行と、数字を見た改善提案を毎月。"],
              ].map(([title, lead]) => (
                <li
                  key={title}
                  className="flex flex-col gap-1 py-4 sm:flex-row sm:items-baseline sm:gap-6"
                >
                  <span className="text-[13px] font-bold text-gray-900 sm:w-56 sm:shrink-0">
                    {title}
                  </span>
                  <span className="text-[12px] leading-[1.8] text-gray-500">
                    {lead}
                  </span>
                </li>
              ))}
            </ul>
          </section>

          <div className="border-t border-gray-200 px-6 py-6 text-[11px] tracking-wide text-gray-400 sm:px-10">
            © First CH — ヘッダー標本用のダミー本文です。
          </div>
        </main>
      </div>

      {/* SP: フルスクリーンオーバーレイ(実サイトでは fixed inset-0) */}
      <div
        id="site-header-overlay"
        inert={!open}
        aria-hidden={!open}
        className={`absolute inset-0 z-40 flex flex-col overflow-y-auto overscroll-contain bg-white transition-[opacity,transform] duration-300 ease-out motion-reduce:transition-none lg:hidden ${
          open
            ? "pointer-events-auto translate-y-0 opacity-100"
            : "pointer-events-none -translate-y-2 opacity-0"
        }`}
      >
        <div className="flex h-16 shrink-0 items-center justify-between border-b border-gray-200 px-4 sm:h-[72px] sm:px-6">
          <Logo />
          <button
            ref={closeRef}
            type="button"
            onClick={() => setOpen(false)}
            aria-label="メニューを閉じる"
            className="grid size-10 shrink-0 place-items-center rounded-lg border border-gray-200 bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            <span aria-hidden="true" className="relative block h-[13px] w-5">
              <span className="absolute top-[6px] left-0 h-px w-full rotate-45 bg-gray-900" />
              <span className="absolute top-[6px] left-0 h-px w-full -rotate-45 bg-gray-900" />
            </span>
          </button>
        </div>

        <nav aria-label="モバイル" className="px-4 sm:px-6">
          <ul className="divide-y divide-gray-200">
            {NAV.map((item, i) => (
              <li
                key={item.label}
                className={`transition-[opacity,transform] duration-300 ease-out motion-reduce:transition-none ${
                  open
                    ? "translate-y-0 opacity-100"
                    : "translate-y-2 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
                }`}
                style={{ transitionDelay: open ? `${90 + i * 50}ms` : "0ms" }}
              >
                <a
                  href={item.href}
                  aria-current={item.current ? "page" : undefined}
                  onClick={() => setOpen(false)}
                  className="group flex items-baseline gap-3 py-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                >
                  <span className="text-[17px] font-bold tracking-tight text-gray-900">
                    {item.label}
                  </span>
                  {item.current && (
                    <span
                      aria-hidden="true"
                      className="inline-block size-1.5 shrink-0 translate-y-[-2px] bg-amber-600"
                    />
                  )}
                  <span
                    aria-hidden="true"
                    className="ml-auto inline-block text-gray-300 transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
                  >
                    →
                  </span>
                </a>
              </li>
            ))}
          </ul>
        </nav>

        <div className="mt-auto border-t border-gray-200 px-4 py-6 sm:px-6">
          <dl className="flex flex-wrap gap-x-8 gap-y-2 text-[12px] 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>
          <a
            href="#"
            className="group relative mt-5 flex items-center justify-center gap-2 overflow-hidden rounded-lg bg-amber-600 px-5 py-3 text-[13px] font-bold text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            <span
              aria-hidden="true"
              className="absolute inset-0 origin-left scale-x-0 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-100 motion-reduce:transition-none"
            />
            <span className="relative">お問い合わせ</span>
            <span
              aria-hidden="true"
              className="relative inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
            >
              →
            </span>
          </a>
        </div>
      </div>
    </div>
  );
}

Add to your project via shadcn CLI

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