Document Download CTA (CSS Cover Mock + Single-Field Form)
A lead-magnet CTA that shows the thing being offered instead of relying on copy and a button alone. The cover is a CSS-only 3D mock — a cover face, an inner stack of sheets and a spine plane standing on a perspective / preserve-3d stage — that straightens on reveal and turns further to the front on hover, with zero image assets. On the right sit an excerpt of the table of contents (chapter number, title, dotted leader rule, page number) and a form with a single field. The top strip carries the document's format (PDF, 24 pages, free) as practical information rather than a Latin kicker. No rounded corners anywhere: everything is bound by hairline rules and spacing. Submitting swaps in a completion panel that catches focus, and sending again returns focus to the input. All entries are display samples.
- Added:
- 2026-08-31
- Dependencies:
- None
- tags
- #cta #lead-magnet #download #form #section #block #css-3d #hairline #tabular-nums #brand #motion #no-image
Preview
改修の優先順位を、
社内で決めきる一冊
どこから直すかで、同じ予算でも結果は変わります。判断の材料と進め方を、実際の順番どおりにまとめました。
収録内容から抜粋
- 01現状を数字で捉える4項目p.04
- 02直す順番の決め方(影響×手間)p.09
- 03制作会社へ渡す要件の書き方p.14
- 04公開後3か月の運用計画p.19
- 05相見積もりの読み解き方p.22
ほか、全7章。チェックリストの原本も付属します。
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 資料ダウンロードCTA(表紙モック+最小フォーム)
* 既存のCTAは cta-banner(BLK-01・中央寄せの面)と cta-split-band(BLK-10・罫線で切る横帯)が
* どちらも「文とボタンだけ」で成り立つ型のため、本ブロックは"交換条件そのものを見せる"型にした。
* 左に配布物の実体(CSSだけで組んだ立体の表紙)、右に目次抜粋と入力1欄を置き、
* 「何がもらえるか」を読ませてからその場で完結させる。入力の設計を主題にした
* contact-form-block(BLK-12・必須5項目+エラーサマリ)とは情報密度が逆で、欄は1つしか無い。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 画像素材ゼロの冊子モックは「面を4枚立てる」だけで作れる: perspective を親に、
* transform-style: preserve-3d を冊子に置き、①表紙(z=0)②中の紙束(translateZ で後ろへ)
* ③背表紙(左端に立てた面)を重ねる。影やグラデーションで奥行きを描くのではなく、
* 実際に3D空間へ置くのでリビールで角度を変えるだけで立体感が生きる。
* - 背表紙の向きは符号を2回間違えやすい。rotateY(+θ) は左端が手前に来る(+x が -z へ回るため)。
* よって背表紙は左端に付ける。面そのものは「left: -T + transform-origin: right center +
* rotateY(-90deg)」にすると、外向きの法線が -x(=左を向く=手前に回った左端から見える)で、
* かつ奥(-z)へ折れる。origin を left にすると手前(+z)へ飛び出して表紙を突き抜ける。
* - 中の紙束は「同じ inset で後ろに置く」と見えない: 奥のものは遠近法で小さく描かれ表紙の内側へ
* 隠れる。right / bottom を数px マイナスにして表紙よりわずかに大きく取り、小口側だけ覗かせる。
* - 3Dの角度は inline style で持つ(Tailwind の rotate 系ユーティリティと混ぜない):
* v4 の 2D rotate は transform ではなく rotate プロパティへ出るため、transform を書く3D側と
* 混在させると片方が無効化されたように見える。transition-transform だけクラスで掛ける。
* - inline style は motion-reduce: で打ち消せない。角度を JS で変える場合は matchMedia で
* prefers-reduced-motion を自分で読み、reduced なら最初から最終角度を入れて hover でも動かさない。
* - 目次のリーダー罫(点線)は items-baseline の flex に「空の flex-1 要素」を挿すだけで引ける:
* 空要素のベースラインは下端になるので、border-b-dotted がそのまま文字のベースライン上に乗る。
* 狭い幅では消す(title が折り返すと罫が行をまたいで残る)。
* - 送信ボタンの矢印は横ではなく下へ送る: 同じ overflow-hidden の入れ替えでも、
* translate-y にするだけで「進む」ではなく「落ちてくる=ダウンロード」の意味になる。
*
* 実案件への移植: handleSubmit の setSent(true) をフォームサービスへの POST に差し替える。
* 表紙のタイトル・目次・ページ数はすべて表示サンプル。
*/
/** 冊子の厚み(px)。背表紙の幅と紙束の translateZ をこの1値から導く */
const SPINE = 16;
/** 表紙の後ろに重ねる紙。小口(右下)だけ覗くよう表紙より少し大きく取る */
const SHEETS = [1, 2, 3];
/** 目次抜粋。番号は章番号、ページは掲載ページ */
const CONTENTS: { no: string; title: string; page: string }[] = [
{ no: "01", title: "現状を数字で捉える4項目", page: "p.04" },
{ no: "02", title: "直す順番の決め方(影響×手間)", page: "p.09" },
{ no: "03", title: "制作会社へ渡す要件の書き方", page: "p.14" },
{ no: "04", title: "公開後3か月の運用計画", page: "p.19" },
{ no: "05", title: "相見積もりの読み解き方", page: "p.22" },
];
/** 冊子の傾き。入場時がいちばん深く、通常、hover の順に正面へ起きる */
const ANGLE = { enter: 34, rest: 21, lift: 9 };
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function WhitepaperDownloadCta() {
const rootRef = useRef<HTMLElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef<HTMLDivElement>(null);
const [inView, setInView] = useState(false);
const [reduced, setReduced] = useState(false);
const [lifted, setLifted] = useState(false);
const [email, setEmail] = useState("");
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState<string | null>(null);
// inline style の transform は motion-reduce: で打ち消せないので自分で読む
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => setReduced(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
// 画面に入ったら1度だけリビール
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();
}, []);
// 送信完了パネルへ焦点を移す(フォームが外れて focus が body へ落ちるのを防ぐ)
useEffect(() => {
if (sent) doneRef.current?.focus();
}, [sent]);
const angle = reduced
? ANGLE.rest
: inView
? lifted
? ANGLE.lift
: ANGLE.rest
: ANGLE.enter;
// translate-y + opacity のスタッガー(transform / opacity のみ)
const rise = (delay: number) => ({
className: `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-2 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`,
style: { transitionDelay: inView ? `${delay}ms` : "0ms" },
});
const heading = rise(60);
const lead = rise(150);
const toc = rise(240);
const form = rise(340);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const value = email.trim();
if (!value) {
setError("メールアドレスを入力してください。");
inputRef.current?.focus();
return;
}
if (!EMAIL_RE.test(value)) {
setError("メールアドレスの形式をご確認ください(例: you@example.com)。");
inputRef.current?.focus();
return;
}
setError(null);
setSent(value);
};
return (
<section
ref={rootRef}
aria-labelledby="whitepaper-download-cta-heading"
className="w-full max-w-4xl border border-gray-200 bg-white"
>
{/* 配布物の書式。英字キッカーの代わりに「何が届くか」の実務情報を最上段へ置く */}
<div className="flex items-center justify-between gap-3 border-b border-gray-200 px-5 py-2.5 sm:px-8">
{/* 折り返すのは「項目のまとまり」単位。縦罫を含むグループを分けないと行末に線だけが余る */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[11px] text-gray-500">
<span className="font-bold tracking-[0.1em] text-gray-900">
ダウンロード資料
</span>
<span className="flex items-center gap-x-3">
<span className={`${montserrat.className} tabular-nums`}>PDF</span>
<span
aria-hidden="true"
className="h-2.5 w-px shrink-0 bg-gray-300"
/>
<span>
全<span className={`${montserrat.className} tabular-nums`}>24</span>
ページ
</span>
</span>
</div>
<span className="shrink-0 bg-amber-600 px-2 py-0.5 text-[10px] font-bold text-white">
無料
</span>
</div>
<div className="flex flex-col lg:flex-row lg:items-stretch">
{/* 左: 配布物の実体。CSSだけで組んだ立体の表紙を1点だけ置く台 */}
<div
className="relative flex shrink-0 items-center justify-center border-b border-gray-200 bg-gray-50 px-6 py-10 lg:w-[292px] lg:border-r lg:border-b-0 lg:py-12"
onMouseEnter={() => setLifted(true)}
onMouseLeave={() => setLifted(false)}
>
<div
aria-hidden="true"
className={`relative transition-[opacity,transform] duration-1000 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-3 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`}
style={{ perspective: "1100px" }}
>
{/* 接地影。3Dの外に平面で置く(立体側に影を付けると角度で歪む) */}
<span
className={`absolute -bottom-5 left-1/2 h-3 w-[78%] -translate-x-1/2 bg-gray-900/15 blur-md transition-transform duration-700 ease-out motion-reduce:transition-none ${
lifted && !reduced ? "scale-x-90 opacity-80" : ""
}`}
/>
<div
className="relative aspect-[210/297] w-[172px] transition-transform duration-700 ease-out motion-reduce:transition-none sm:w-[208px]"
style={{
transformStyle: "preserve-3d",
transform: `rotateX(3deg) rotateY(${angle}deg) translateY(${
lifted && !reduced ? "-6px" : "0px"
})`,
}}
>
{/* 中の紙束: 表紙より少し大きく取り、小口だけ覗かせる */}
{SHEETS.map((i) => (
<div
key={i}
className="absolute border border-gray-200 bg-white"
style={{
top: `${i * 1}px`,
left: 0,
right: `${-2 - i * 2}px`,
bottom: `${-1 - i * 1.5}px`,
transform: `translateZ(${-(SPINE / SHEETS.length) * i}px)`,
}}
/>
))}
{/* 背表紙: 左端に立てた面。origin=right + rotateY(-90deg) で奥へ折る */}
<div
className="absolute inset-y-0 flex flex-col justify-end gap-1 overflow-hidden bg-gradient-to-r from-amber-700 to-amber-600 px-1 pb-3"
style={{
width: `${SPINE}px`,
left: `${-SPINE}px`,
transformOrigin: "right center",
transform: "rotateY(-90deg)",
}}
>
{/* 背表紙は面積が sin(θ) 倍に潰れるので文字は置かない。帯だけで冊子の記号にする */}
<span className="block h-px w-full bg-white/70" />
<span className="block h-px w-full bg-white/40" />
</div>
{/* 表紙 */}
<div className="absolute inset-0 flex flex-col border border-gray-900/10 bg-white p-4 shadow-[0_10px_30px_-18px_rgba(17,17,17,0.5)] sm:p-5">
<span className="self-start bg-amber-600 px-1.5 py-0.5 text-[9px] font-bold tracking-[0.1em] text-white">
実務ガイド
</span>
<p className="mt-4 text-[17px] leading-[1.45] font-bold tracking-tight text-gray-900 sm:text-[19px]">
サイト改修の
<br />
優先順位
</p>
<span className="mt-3 block h-px w-8 bg-amber-600" />
<p className="mt-3 text-[10px] leading-[1.8] text-gray-500">
測る / 決める / 渡す
<br />
発注前に社内で合意する手順
</p>
<p
className={`${montserrat.className} mt-auto text-[9px] tracking-[0.14em] tabular-nums text-gray-400`}
>
PDF / 24P
</p>
{/* 綴じ側の陰。紙の折り返しを1本の細い面で示す */}
<span className="absolute inset-y-0 left-0 w-1.5 bg-gradient-to-r from-gray-900/12 to-transparent" />
</div>
</div>
</div>
</div>
{/* 右: 中身の予告(目次抜粋)と、欄が1つだけのフォーム */}
<div className="flex flex-1 flex-col justify-center px-5 py-8 sm:px-8 sm:py-10 lg:px-9">
<h2
id="whitepaper-download-cta-heading"
className={`text-xl leading-[1.5] font-bold tracking-tight text-gray-900 sm:text-2xl ${heading.className}`}
style={heading.style}
>
改修の優先順位を、
<br />
社内で決めきる一冊
</h2>
<p
className={`mt-3 max-w-md text-[13px] leading-[1.9] text-gray-600 ${lead.className}`}
style={lead.style}
>
どこから直すかで、同じ予算でも結果は変わります。判断の材料と進め方を、実際の順番どおりにまとめました。
</p>
{/* 目次抜粋: 箱で囲わず、上下の罫線と行間だけで束ねる */}
<div className={`mt-6 ${toc.className}`} style={toc.style}>
<p className="flex items-center gap-2 text-[11px] font-bold tracking-[0.12em] text-gray-900">
<span
aria-hidden="true"
className="inline-block size-1 shrink-0 bg-amber-600"
/>
収録内容から抜粋
</p>
<ol className="mt-2.5 divide-y divide-gray-100 border-y border-gray-200">
{CONTENTS.map((row) => (
<li
key={row.no}
className="flex items-baseline gap-x-2.5 py-2 text-[12.5px] text-gray-700 sm:gap-x-3"
>
<span
className={`${montserrat.className} shrink-0 text-[11px] tabular-nums text-amber-600`}
>
{row.no}
</span>
<span className="flex-1 leading-[1.7] sm:flex-none">
{row.title}
</span>
{/* 空要素のベースライン=下端。点線がそのまま文字のベースラインに乗る */}
<span
aria-hidden="true"
className="hidden flex-1 border-b border-dotted border-gray-300 sm:block"
/>
<span
className={`${montserrat.className} shrink-0 text-[11px] tabular-nums text-gray-400`}
>
{row.page}
</span>
</li>
))}
</ol>
<p className="mt-2 text-[11px] text-gray-500">
ほか、全<span className={`${montserrat.className} tabular-nums`}>7</span>
章。チェックリストの原本も付属します。
</p>
</div>
<div className={`mt-6 ${form.className}`} style={form.style}>
{sent ? (
<div
ref={doneRef}
tabIndex={-1}
className="border border-amber-600/40 bg-amber-50/60 px-4 py-4 outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
<p className="flex items-center gap-2 text-[13px] font-bold text-gray-900">
<span
aria-hidden="true"
className="inline-block size-1 shrink-0 bg-amber-600"
/>
ダウンロードリンクをお送りしました
</p>
<p className="mt-2 text-[12px] leading-[1.9] break-all text-gray-600">
{sent} 宛にお送りしています。数分経っても届かない場合は、迷惑メールフォルダをご確認ください。
</p>
<button
type="button"
onClick={() => {
setSent(null);
setEmail("");
window.setTimeout(() => inputRef.current?.focus(), 0);
}}
className="mt-3 text-[12px] font-semibold text-gray-900 underline underline-offset-4 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
別のアドレスで送り直す
</button>
</div>
) : (
<form onSubmit={handleSubmit} noValidate>
<label
htmlFor="whitepaper-download-cta-email"
className="block text-[11px] font-bold tracking-[0.08em] text-gray-900"
>
送付先メールアドレス
</label>
{/* 欄は1つだけ。入力欄と送信を罫線1本で継いで「1操作」に見せる */}
<div className="mt-2 flex flex-col sm:flex-row">
<input
ref={inputRef}
id="whitepaper-download-cta-email"
type="email"
name="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
aria-invalid={error ? true : undefined}
aria-describedby={
error
? "whitepaper-download-cta-error"
: "whitepaper-download-cta-note"
}
onChange={(e) => {
setEmail(e.target.value);
if (error) setError(null);
}}
className={`w-full min-w-0 flex-1 border bg-white px-3.5 py-2.5 text-sm text-gray-900 outline-none transition-colors placeholder:text-gray-400 ${
error
? "border-red-400 focus:border-red-500 focus:ring-2 focus:ring-red-100"
: "border-gray-300 focus:border-amber-500 focus:ring-2 focus:ring-amber-200"
}`}
/>
<button
type="submit"
className="group mt-2 flex shrink-0 items-center justify-center gap-2 bg-amber-600 px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 sm:mt-0 sm:-ml-px"
>
資料を受け取る
{/* 矢印は横ではなく下へ送って入れ替える=ダウンロードの意味 */}
<span
aria-hidden="true"
className="relative block size-4 shrink-0 overflow-hidden"
>
<span className="absolute inset-0 flex items-center justify-center transition-transform duration-300 ease-out group-hover:translate-y-4 group-focus-visible:translate-y-4 motion-reduce:transition-none">
↓
</span>
<span className="absolute inset-0 flex -translate-y-4 items-center justify-center transition-transform duration-300 ease-out group-hover:translate-y-0 group-focus-visible:translate-y-0 motion-reduce:transition-none">
↓
</span>
</span>
</button>
</div>
{error ? (
<p
id="whitepaper-download-cta-error"
role="alert"
className="mt-2 text-[12px] leading-[1.7] text-red-600"
>
{error}
</p>
) : null}
<p
id="whitepaper-download-cta-note"
className="mt-2.5 text-[11px] leading-[1.9] text-gray-500"
>
ご入力先にダウンロードリンクをお送りします。営業目的のご連絡はいたしません。
<a
href="#"
className="ms-1 text-gray-700 underline underline-offset-4 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
個人情報の取り扱い
</a>
</p>
</form>
)}
</div>
</div>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/whitepaper-download-cta.json