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
"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