PGE-02Pages
Editorial Display-Type Hero
An editorial hero built around an oversized serif display line on white. A cascading blurred-bar motif, a vertical scroll indicator, and an underline-wipe text CTA create premium negative space with zero image assets. Optional Lenis smooth scrolling via the smoothScroll prop.
- Added:
- 2026-07-25
- Dependencies:
- lenis
- tags
- #hero #page #editorial #serif #motion #lenis #no-image
Preview
"use client";
import { useEffect, useRef, useState } from "react";
import { Zen_Old_Mincho } from "next/font/google";
// preload: false は和文フォントでは必須。Zen Old Mincho は unicode-range で約37分割されており、
// preload(既定 true)だと2ウェイト分=74ファイルを <link rel="preload"> でギャラリー全ページに
// 先読みさせる(この標本はトップのカードプレビューに載るため、全ページに効いてしまう)。
// 実測(2026-08-08)でトップのLCPが 19.7s。preload を切って unicode-range の遅延読み込みに任せる。
const zenOldMincho = Zen_Old_Mincho({
weight: ["400", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* エディトリアル大タイポヒーロー
* 参考: 白背景×巨大セリフ英文×階段状のぼかしモチーフ(構図の重心=左上、余白の性格=静的な"間")。
* smoothScroll を true にするとページ全体に Lenis の慣性スクロールを適用する
* (ギャラリープレビューでは false。実案件ではルート近くで1回だけ有効化する)。
*/
export default function HeroEditorialTypo({
smoothScroll = false,
headingTag: Heading = "h1",
}: {
smoothScroll?: boolean;
/**
* 見出しのタグ。実案件ではそのまま(h1)。ギャラリーのプレビューは1ページに複数の標本が
* 並ぶため "p" を渡してh1の重複を避ける(見た目はclassNameで決まるので変わらない)。
*/
headingTag?: "h1" | "p";
}) {
const [inView, setInView] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// マウント後にリビール開始(reduced-motion 時は motion-reduce: クラスで即時表示)
useEffect(() => {
const id = requestAnimationFrame(() => setInView(true));
return () => cancelAnimationFrame(id);
}, []);
// Lenis 慣性スクロール(opt-in・アンマウントで確実に破棄)
useEffect(() => {
if (!smoothScroll) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let lenis: { raf: (t: number) => void; destroy: () => void } | null = null;
let rafId = 0;
let cancelled = false;
import("lenis").then(({ default: Lenis }) => {
if (cancelled) return;
lenis = new Lenis({ lerp: 0.12 });
const raf = (time: number) => {
lenis?.raf(time);
rafId = requestAnimationFrame(raf);
};
rafId = requestAnimationFrame(raf);
});
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
lenis?.destroy();
};
}, [smoothScroll]);
// 階段状のぼかしモチーフ(右上→中央へ降りる。装飾なので aria-hidden)
const steps = [
{ top: 4, right: 0, w: 150 },
{ top: 9, right: 6, w: 120 },
{ top: 14, right: 2, w: 170 },
{ top: 20, right: 12, w: 110 },
{ top: 26, right: 5, w: 190 },
{ top: 33, right: 16, w: 130 },
{ top: 40, right: 9, w: 160 },
{ top: 48, right: 22, w: 120 },
{ top: 56, right: 14, w: 180 },
{ top: 64, right: 28, w: 110 },
{ top: 72, right: 19, w: 150 },
{ top: 81, right: 34, w: 100 },
];
const reveal = (delayMs: number) =>
`transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-5 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`.concat(` [transition-delay:${delayMs}ms]`);
return (
<div
ref={rootRef}
className="relative w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
>
{/* 階段モチーフ */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{steps.map((s, i) => (
<span
key={i}
className="absolute h-3 rounded-full bg-gray-300/50 blur-[5px] sm:h-3.5"
style={{
top: `${s.top}%`,
right: `${s.right}%`,
width: `${s.w}px`,
transitionProperty: "opacity, transform",
transitionDuration: "900ms",
transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
transitionDelay: `${200 + i * 60}ms`,
opacity: inView ? 1 : 0,
transform: inView ? "translateX(0)" : "translateX(24px)",
}}
/>
))}
</div>
<div className="relative grid min-h-[420px] grid-cols-[auto_1fr] sm:min-h-[520px]">
{/* 左端: 縦書きスクロールインジケータ */}
<div className="flex w-12 flex-col items-center justify-end pb-6 sm:w-16 sm:pb-8">
<span
className="text-[10px] font-medium tracking-[0.25em] text-gray-400 uppercase [writing-mode:vertical-rl]"
aria-hidden="true"
>
Scroll Down
</span>
<span className="relative mt-3 block h-14 w-px overflow-hidden bg-gray-200 sm:h-20">
<span className="absolute top-0 left-0 h-5 w-px animate-[hero-et-drop_2.2s_ease-in-out_infinite] bg-amber-600 motion-reduce:animate-none" />
</span>
</div>
{/* 本体 */}
<div className="flex flex-col justify-center py-14 pr-8 pl-2 sm:py-20 sm:pr-12">
<p
className={`text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal(0)}`}
>
Web Production Studio
</p>
<Heading
className={`${zenOldMincho.className} mt-5 text-[clamp(2.6rem,7.5vw,4.75rem)] leading-[1.08] tracking-tight text-gray-900 ${reveal(120)}`}
>
Be the standard.
</Heading>
<p
className={`mt-6 max-w-xl text-[17px] leading-[1.8] font-medium tracking-[0.04em] text-gray-800 ${reveal(260)}`}
>
つくって、終わらせない。
<span className="mx-1 inline-block h-px w-6 translate-y-[-0.25em] bg-amber-600 align-middle" />
中小企業のWebを、成果の出る標準へ。
</p>
<div className={`mt-10 ${reveal(400)}`}>
<a
href="#"
className="group inline-flex items-center gap-3 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
制作のご相談
<span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0" />
<span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100" />
</span>
<span
aria-hidden="true"
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
>
→
</span>
</a>
</div>
</div>
</div>
{/* インジケータ用 keyframes(このブロック内で完結させる) */}
<style>{`
@keyframes hero-et-drop {
0% { transform: translateY(-100%); }
55% { transform: translateY(400%); }
100% { transform: translateY(400%); }
}
`}</style>
</div>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/hero-editorial-typo.json