First CH Designs
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

プレビュー

ダウンロード資料PDF24ページ
無料

改修の優先順位を、
社内で決めきる一冊

どこから直すかで、同じ予算でも結果は変わります。判断の材料と進め方を、実際の順番どおりにまとめました。

収録内容から抜粋

  1. 01現状を数字で捉える4項目p.04
  2. 02直す順番の決め方(影響×手間)p.09
  3. 03制作会社へ渡す要件の書き方p.14
  4. 04公開後3か月の運用計画p.19
  5. 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