BLK-47Blocks
資料ダウンロードCTA(表紙モック+最小フォーム)
配布資料の受け取りを促すCTA。文とボタンだけで押すのではなく、渡すものの実体を左に置く。表紙は画像素材を使わず、perspective と preserve-3d の上に「表紙・中の紙束・背表紙」の3面を立てて組んだ立体モックで、入場で角度が起き、hover でさらに正面へ向く。右は目次抜粋(章番号・タイトル・点線のリーダー罫・掲載ページ)と、欄が1つだけのフォーム。最上段には英字キッカーではなく配布物の書式(PDF・全24ページ・無料)を実務情報として敷く。角丸はどこにも使わず、罫線と余白だけで束ねている。送信すると完了パネルへ差し替えてフォーカスを受け止め、送り直しでは入力欄へ戻す。掲載内容はすべて表示サンプル。
- 追加日:
- 2026-08-31
- 依存:
- なし
- tags
- #cta #lead-magnet #download #form #section #block #css-3d #hairline #tabular-nums #brand #motion #no-image
プレビュー
ダウンロード資料PDF全24ページ
無料改修の優先順位を、
社内で決めきる一冊
どこから直すかで、同じ予算でも結果は変わります。判断の材料と進め方を、実際の順番どおりにまとめました。
収録内容から抜粋
- 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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/whitepaper-download-cta.json