First CH Designs
BLK-12Blocks

Contact Form Section

A contact section built by composing the text-field element (INP-01) into a full block: heading and contact channels on the left, the form on the right, bound by a single hairline rule rather than cards. Name / company / email / message / consent each carry an explicit required-or-optional badge. Submitting validates everything at once, moves focus to an error summary whose rows jump to the offending field, and re-validates on input from then on. A hairline meter tracks required-field completion, and a success panel replaces the form and catches focus. Derived from the standard acceptance-checklist scope rather than any single reference site. Zero image assets.

Added:
2026-08-06
Dependencies:
None
tags
#form #contact #section #block #validation #accessibility #split #hairline #brand #motion #no-image #ref:standard-acceptance-scope

Preview

お問い合わせ

見積もりの前に、
話を聞かせて
ください。

作り替えたい、更新が止まっている、何から手を付けるか決めかねている。どの段階でも構いません。いただいた内容は担当者が直接読み、そのままご返信します。

ご返信
1営業日以内
受付
平日 10:00–19:00
お電話
050-0000-0000
費用
ご相談・お見積りまで無料

必須項目

必須
任意

個人のお客様は空欄のままで構いません。

必須

お返事はこのアドレスに送ります。

必須

10文字以上・1000文字まで。箇条書きでも構いません。

送信した時点では費用は発生しません。

"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",
});

/**
 * お問い合わせフォームセクション / Contact Form Section
 * 参考元: 特定サイトの模倣ではなく、全案件で共通して求められる標準スコープ
 * (検収チェックリストの「フォーム」項目)をセクション1枚に畳んだもの。
 * 既存の text-field(INP-01)の入力欄トークンをそのまま継承し、
 * 「部品」から「セクション」へ組み上げた段。
 *
 * 構図: 左に見出し+連絡手段、右にフォームのスプリット。カードを並べず、
 * 外枠なし・上下の border-y と中央の lg:divide-x(罫線1本)だけで束ねる。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - <form noValidate> にする: ブラウザ既定のバリデーション気泡が先に出ると、
 *   自前のエラー表示・フォーカス移動・エラーサマリが一切走らない。noValidate を
 *   付けたうえで required / type="email" は残す(支援技術には属性が届く)。
 * - 検査のタイミングは「送信で全件 → 以後は入力に追随」: 入力途中で赤くしない。
 *   一度でも送信を試みた(attempted)後だけ onChange で再検査すると、
 *   「打っている最中に怒られる」不快さなしに、直した瞬間に赤が消える。
 * - エラーサマリは live region にせず focus を移す: role="alert" と focus() を
 *   併用すると読み上げが二重になる。tabIndex={-1} のサマリへ focus() だけを送り、
 *   各行は <button> で該当欄へ focus + scrollIntoView する(<a href="#id"> だと
 *   URL にハッシュが残り、カード内プレビューのようなリンク配下でも入れ子リンクになる)。
 * - scrollIntoView は reduced-motion を自分で見る: behavior:"smooth" は
 *   prefers-reduced-motion を自動では尊重しない。matchMedia で分岐する。
 * - 和文の段落は JSX でも1行に詰めて書く: JSX は要素に隣接しない改行を半角スペースへ
 *   畳むため、ソースの見た目のために行を割ると「。 ど」のように句点の後だけ
 *   不自然に空く(和文には単語間スペースが無いので欧文より露骨に見える)。
 *   改行を制御したいときだけ <br> を明示的に置く。
 * - 必須/任意は色でなく語で示す: アンバー地の「必須」/罫線だけの「任意」バッジを
 *   ラベル行に置く。色だけの区別は色覚特性・モノクロ印刷で消える。
 * - 同意チェックの <label> にリンクを入れない: ラベル内のリンクを押すと
 *   チェックが道連れでトグルする。ラベルは「同意します。」までにし、
 *   プライバシーポリシーへのリンクは直下の注記へ逃がす。
 * - 送信後は focus の行き先を作る: フォームを外して完了パネルへ差し替えると
 *   focus が body へ落ちる。パネルに tabIndex={-1} を付けて focus() で受け止め、
 *   戻る操作では先頭の入力欄へ返す。
 * - 進捗メーターは装飾として aria-hidden: 必須4件の充足を hairline のセグメントで
 *   出すと入力の見通しが立つ。動かすのは origin-left の scale-x だけ。
 *   状態は本文(エラー文・必須バッジ)で既に伝わるので読み上げには載せない。
 * - 文字数カウンタも aria-hidden: 1文字ごとに読み上げると入力が妨害される。
 *   上限は静的なヘルパーテキスト側に書く。
 *
 * 実案件への移植: handleSubmit の setSent(true) を fetch(...) / フォームサービスへの
 * POST に差し替える。入力欄のクラスは text-field(INP-01)と同一なので、
 * 単票の入力欄を足すときはそちらをコピーすれば見た目が揃う。
 */

type FieldName = "name" | "company" | "email" | "message" | "consent";

type Values = {
  name: string;
  company: string;
  email: string;
  message: string;
  consent: boolean;
};

const EMPTY: Values = {
  name: "",
  company: "",
  email: "",
  message: "",
  consent: false,
};

/** 検査順=画面上の並び順。サマリの並びと最初のフォーカス先はこの順で決まる */
const ORDER: FieldName[] = ["name", "email", "message", "consent"];

const MESSAGE_MIN = 10;
const MESSAGE_MAX = 1000;

function validate(v: Values): Partial<Record<FieldName, string>> {
  const e: Partial<Record<FieldName, string>> = {};
  if (!v.name.trim()) e.name = "お名前を入力してください。";
  if (!v.email.trim()) {
    e.email = "メールアドレスを入力してください。";
  } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email.trim())) {
    e.email = "メールアドレスの形式をご確認ください(例: you@example.com)。";
  }
  if (!v.message.trim()) {
    e.message = "お問い合わせ内容を入力してください。";
  } else if (v.message.trim().length < MESSAGE_MIN) {
    e.message = `お問い合わせ内容は${MESSAGE_MIN}文字以上で入力してください。`;
  }
  if (!v.consent) e.consent = "個人情報の取り扱いへの同意が必要です。";
  return e;
}

/** 左カラムの連絡手段。数字だけ Montserrat + tabular-nums に切り替える */
const CHANNELS: { term: string; text: string | null; nums: string | null }[] = [
  { term: "ご返信", text: "1営業日以内", nums: null },
  { term: "受付", text: "平日", nums: "10:00–19:00" },
  { term: "お電話", text: null, nums: "050-0000-0000" },
  { term: "費用", text: "ご相談・お見積りまで無料", nums: null },
];

/** 入力欄の見た目は text-field(INP-01)と同一。エラー時だけ赤系へ差し替える */
const inputClass = (invalid: boolean) =>
  `block w-full rounded-lg border bg-white px-3.5 py-2.5 text-sm text-gray-900 shadow-sm outline-none transition-colors placeholder:text-gray-400 ${
    invalid
      ? "border-red-300 focus:border-red-400 focus:ring-2 focus:ring-red-100"
      : "border-gray-300 focus:border-amber-500 focus:ring-2 focus:ring-amber-200"
  }`;

function RequirementBadge({ required }: { required: boolean }) {
  return required ? (
    <span className="rounded-[3px] bg-amber-600 px-1.5 py-px text-[10px] leading-[1.7] font-bold tracking-wide text-white">
      必須
    </span>
  ) : (
    <span className="rounded-[3px] border border-gray-300 px-1.5 py-px text-[10px] leading-[1.7] font-bold tracking-wide text-gray-500">
      任意
    </span>
  );
}

function FieldError({ id, message }: { id: string; message?: string }) {
  if (!message) return null;
  return (
    <p
      id={id}
      className="mt-1.5 flex items-start gap-1.5 text-xs leading-[1.7] text-red-600 animate-[cf-error-in_0.35s_ease-out] motion-reduce:animate-none"
    >
      <span
        aria-hidden="true"
        className="mt-[0.15em] flex size-3.5 shrink-0 items-center justify-center rounded-full bg-red-100 text-[9px] font-bold text-red-600"
      >
        !
      </span>
      {message}
    </p>
  );
}

export default function ContactFormBlock() {
  const rootRef = useRef<HTMLElement>(null);
  const summaryRef = useRef<HTMLDivElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const fieldRefs = useRef<Partial<Record<FieldName, HTMLElement | null>>>({});
  const restoreFocus = useRef(false);

  const [inView, setInView] = useState(false);
  const [values, setValues] = useState<Values>(EMPTY);
  const [errors, setErrors] = useState<Partial<Record<FieldName, string>>>({});
  const [attempted, setAttempted] = useState(false);
  const [sent, setSent] = useState(false);

  // 画面に入ったら1度だけリビール(reduced-motion 時は motion-reduce: で即時表示)
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    if (typeof IntersectionObserver === "undefined") {
      const id = requestAnimationFrame(() => setInView(true));
      return () => cancelAnimationFrame(id);
    }
    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) {
      panelRef.current?.focus();
      return;
    }
    if (restoreFocus.current) {
      restoreFocus.current = false;
      fieldRefs.current.name?.focus();
    }
  }, [sent]);

  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 label = rise(0);
  const heading = rise(110);
  const lead = rise(220);
  const channels = rise(320);
  const formRise = rise(200);

  // 表示中のエラー(errors)とは別に、進捗メーター用の充足判定をその場で取る
  const liveErrors = validate(values);
  const filledCount = ORDER.filter((f) => !liveErrors[f]).length;
  const listed = ORDER.filter((f) => errors[f]);

  const update = (patch: Partial<Values>) => {
    const next = { ...values, ...patch };
    setValues(next);
    // 一度送信を試みた後だけ追随再検査する(入力途中で赤くしない)
    if (attempted) setErrors(validate(next));
  };

  const focusField = (field: FieldName) => {
    const el = fieldRefs.current[field];
    if (!el) return;
    el.focus();
    // smooth は prefers-reduced-motion を自動では尊重しないので自分で見る
    const reduce =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    el.scrollIntoView({ block: "center", behavior: reduce ? "auto" : "smooth" });
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const next = validate(values);
    setErrors(next);
    setAttempted(true);
    if (Object.keys(next).length > 0) {
      // live region にはせず focus だけを送る(二重読み上げを避ける)
      requestAnimationFrame(() => summaryRef.current?.focus());
      return;
    }
    // 実案件ではここを fetch(...) / フォームサービスへの POST に置き換える
    setSent(true);
  };

  const reset = () => {
    setValues(EMPTY);
    setErrors({});
    setAttempted(false);
    restoreFocus.current = true;
    setSent(false);
  };

  const describedBy = (field: FieldName, help: string | null) =>
    [help, errors[field] ? `cf-${field}-error` : null].filter(Boolean).join(" ") ||
    undefined;

  return (
    <section
      ref={rootRef}
      aria-labelledby="contact-form-heading"
      className="w-full max-w-4xl bg-white"
    >
      <div className="grid grid-cols-1 gap-10 border-y border-gray-200 py-10 lg:grid-cols-12 lg:gap-0 lg:divide-x lg:divide-gray-200 lg:py-12">
        {/* 左: 見出しと連絡手段。色も面も使わず余白で押す */}
        <div className="lg:col-span-5 lg:sticky lg:top-10 lg:self-start lg:pr-10">
          <p
            className={`flex items-center gap-2 text-[11px] font-bold tracking-[0.14em] text-gray-900 ${label.className}`}
            style={label.style}
          >
            <span
              aria-hidden="true"
              className="inline-block size-1 shrink-0 bg-amber-600"
            />
            お問い合わせ
          </p>

          {/* 和文は語境界で折り返さないため、行の切れ目は <br> で決める。
              1行が最も狭い環境(≒271px)でも収まる長さに割ってある */}
          <h2
            id="contact-form-heading"
            className={`mt-4 text-[clamp(1.4rem,3vw,1.9rem)] leading-[1.5] font-bold tracking-tight text-gray-900 ${heading.className}`}
            style={heading.style}
          >
            見積もりの前に、
            <br />
            <span className="relative inline-block whitespace-nowrap">
              話を聞かせて
              <span
                aria-hidden="true"
                className={`absolute -bottom-0.5 left-0 h-[3px] w-full origin-left bg-amber-600/70 transition-transform duration-700 ease-out motion-reduce:transition-none ${
                  inView ? "scale-x-100" : "scale-x-0 motion-reduce:scale-x-100"
                }`}
                style={{ transitionDelay: inView ? "430ms" : "0ms" }}
              />
            </span>
            <br />
            ください。
          </h2>

          <p
            className={`mt-5 max-w-md text-[13px] leading-[1.9] text-gray-600 ${lead.className}`}
            style={lead.style}
          >
            {/* 和文は1行に詰めて書く: JSX は改行を半角スペースへ畳むため、
                行を割ると「。 ど」のように句点の後だけ不自然に空く */}
            作り替えたい、更新が止まっている、何から手を付けるか決めかねている。どの段階でも構いません。いただいた内容は担当者が直接読み、そのままご返信します。
</p>

          <dl
            className={`mt-8 divide-y divide-gray-100 border-t border-gray-100 ${channels.className}`}
            style={channels.style}
          >
            {CHANNELS.map((c) => (
              <div key={c.term} className="grid grid-cols-[4rem_1fr] gap-x-3 py-2.5">
                <dt className="pt-[0.2em] text-[11px] font-bold tracking-[0.12em] whitespace-nowrap text-gray-500">
                  {c.term}
                </dt>
                <dd className="text-[13px] leading-[1.8] font-medium text-gray-900">
                  {c.text}
                  {c.nums ? (
                    <>
                      {c.text ? " " : null}
                      <span className={`${montserrat.className} tabular-nums`}>
                        {c.nums}
                      </span>
                    </>
                  ) : null}
                </dd>
              </div>
            ))}
          </dl>
        </div>

        {/* 右: フォーム本体 */}
        <div
          className={`lg:col-span-7 lg:pl-10 ${formRise.className}`}
          style={formRise.style}
        >
          {sent ? (
            <div
              ref={panelRef}
              tabIndex={-1}
              className="flex flex-col items-start border-l-2 border-amber-600 bg-amber-50/60 px-5 py-7 outline-none animate-[cf-panel-in_0.5s_ease-out] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:animate-none sm:px-7"
            >
              <span
                aria-hidden="true"
                className="flex size-9 items-center justify-center rounded-full bg-amber-600 text-white"
              >
                <svg viewBox="0 0 14 14" className="size-4">
                  <path
                    d="M2.5 7.3 5.6 10.3 11.5 4"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="1.9"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              </span>
              <p className="mt-4 text-base font-bold tracking-tight text-gray-900">
                送信しました。
              </p>
              <p className="mt-2 max-w-sm text-[13px] leading-[1.9] text-gray-600">
                ご入力のメールアドレス宛に、受付内容の控えをお送りしました。1営業日以内に担当者よりご返信します。
</p>
              <button
                type="button"
                onClick={reset}
                className="group mt-5 inline-flex items-center gap-2 text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
              >
                <span
                  aria-hidden="true"
                  className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1 motion-reduce:transition-none"
                >
                  ←
                </span>
                <span className="relative">
                  もう一度入力する
                  <span
                    aria-hidden="true"
                    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 motion-reduce:transition-none"
                  />
                  <span
                    aria-hidden="true"
                    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 motion-reduce:transition-none"
                  />
                </span>
              </button>
            </div>
          ) : (
            /* noValidate: ブラウザ既定の気泡が出ると自前のサマリ・フォーカス移動が走らない */
            <form noValidate onSubmit={handleSubmit}>
              {/* 必須項目の充足メーター(装飾なので aria-hidden) */}
              <div className="flex items-center justify-between gap-4 border-b border-gray-200 pb-3">
                <p className="text-[11px] font-bold tracking-[0.12em] text-gray-500">
                  必須項目
                </p>
                <span aria-hidden="true" className="flex items-center gap-2">
                  <span className="flex gap-1">
                    {ORDER.map((f, i) => (
                      <span
                        key={f}
                        className="h-1 w-4 overflow-hidden rounded-full bg-gray-200 sm:w-5"
                      >
                        <span
                          className={`block h-full w-full origin-left rounded-full bg-amber-600 transition-transform duration-500 ease-out motion-reduce:transition-none ${
                            liveErrors[f] ? "scale-x-0" : "scale-x-100"
                          }`}
                          style={{ transitionDelay: `${i * 40}ms` }}
                        />
                      </span>
                    ))}
                  </span>
                  <span
                    className={`${montserrat.className} text-[11px] font-bold tabular-nums text-gray-900`}
                  >
                    {filledCount}/{ORDER.length}
                  </span>
                </span>
              </div>

              {/* エラーサマリ: live region にせず focus を受ける器にする */}
              {listed.length > 0 && (
                <div
                  ref={summaryRef}
                  tabIndex={-1}
                  aria-labelledby="cf-summary-title"
                  className="mt-5 border-l-2 border-red-500 bg-red-50/70 px-4 py-3.5 outline-none animate-[cf-error-in_0.35s_ease-out] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 motion-reduce:animate-none"
                >
                  <p
                    id="cf-summary-title"
                    className="text-[13px] font-bold text-red-700"
                  >
                    入力内容をご確認ください(
                    <span className={`${montserrat.className} tabular-nums`}>
                      {listed.length}
                    </span>
                    件)
                  </p>
                  <ul className="mt-2 space-y-1.5">
                    {listed.map((f) => (
                      <li key={f}>
                        {/* <a href="#id"> にしないのは、URL にハッシュが残るのと、
                            リンク配下に置かれた場合に入れ子リンクになるため */}
                        <button
                          type="button"
                          onClick={() => focusField(f)}
                          className="text-left text-[12px] leading-[1.7] text-red-700 underline underline-offset-2 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
                        >
                          {errors[f]}
                        </button>
                      </li>
                    ))}
                  </ul>
                </div>
              )}

              <div className="mt-6 flex flex-col gap-5">
                {/* お名前(必須) */}
                <div>
                  <div className="mb-1.5 flex items-center gap-2">
                    <label
                      htmlFor="cf-name"
                      className="text-sm font-medium text-gray-800"
                    >
                      お名前
                    </label>
                    <RequirementBadge required />
                  </div>
                  <input
                    ref={(el) => {
                      fieldRefs.current.name = el;
                    }}
                    id="cf-name"
                    name="name"
                    type="text"
                    autoComplete="name"
                    placeholder="山田 太郎"
                    value={values.name}
                    onChange={(e) => update({ name: e.target.value })}
                    aria-invalid={errors.name ? true : undefined}
                    aria-describedby={describedBy("name", null)}
                    className={inputClass(Boolean(errors.name))}
                  />
                  <FieldError id="cf-name-error" message={errors.name} />
                </div>

                {/* 会社名(任意): 必須バッジとの対比を作るために置く */}
                <div>
                  <div className="mb-1.5 flex items-center gap-2">
                    <label
                      htmlFor="cf-company"
                      className="text-sm font-medium text-gray-800"
                    >
                      会社名
                    </label>
                    <RequirementBadge required={false} />
                  </div>
                  <input
                    id="cf-company"
                    name="company"
                    type="text"
                    autoComplete="organization"
                    placeholder="First CH合同会社"
                    value={values.company}
                    onChange={(e) => update({ company: e.target.value })}
                    aria-describedby="cf-company-help"
                    className={inputClass(false)}
                  />
                  <p
                    id="cf-company-help"
                    className="mt-1.5 text-xs leading-[1.7] text-gray-500"
                  >
                    個人のお客様は空欄のままで構いません。
                  </p>
                </div>

                {/* メールアドレス(必須) */}
                <div>
                  <div className="mb-1.5 flex items-center gap-2">
                    <label
                      htmlFor="cf-email"
                      className="text-sm font-medium text-gray-800"
                    >
                      メールアドレス
                    </label>
                    <RequirementBadge required />
                  </div>
                  <input
                    ref={(el) => {
                      fieldRefs.current.email = el;
                    }}
                    id="cf-email"
                    name="email"
                    type="email"
                    inputMode="email"
                    autoComplete="email"
                    placeholder="you@example.com"
                    value={values.email}
                    onChange={(e) => update({ email: e.target.value })}
                    aria-invalid={errors.email ? true : undefined}
                    aria-describedby={describedBy("email", "cf-email-help")}
                    className={inputClass(Boolean(errors.email))}
                  />
                  <FieldError id="cf-email-error" message={errors.email} />
                  <p
                    id="cf-email-help"
                    className="mt-1.5 text-xs leading-[1.7] text-gray-500"
                  >
                    お返事はこのアドレスに送ります。
                  </p>
                </div>

                {/* お問い合わせ内容(必須) */}
                <div>
                  <div className="mb-1.5 flex items-center gap-2">
                    <label
                      htmlFor="cf-message"
                      className="text-sm font-medium text-gray-800"
                    >
                      お問い合わせ内容
                    </label>
                    <RequirementBadge required />
                  </div>
                  <textarea
                    ref={(el) => {
                      fieldRefs.current.message = el;
                    }}
                    id="cf-message"
                    name="message"
                    rows={5}
                    maxLength={MESSAGE_MAX}
                    placeholder="現在のサイトのURL、ご希望の時期、おおよそのご予算などがあれば助かります。"
                    value={values.message}
                    onChange={(e) => update({ message: e.target.value })}
                    aria-invalid={errors.message ? true : undefined}
                    aria-describedby={describedBy("message", "cf-message-help")}
                    className={`${inputClass(Boolean(errors.message))} resize-y leading-[1.9]`}
                  />
                  <FieldError id="cf-message-error" message={errors.message} />
                  {/* 狭い環境ではカウンタを横に置かない。ヘルパー文が回り込んで
                      「0/1000 だけが1行目に残る」形になり、行末に数字が浮く */}
                  <div className="mt-1.5 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
                    <p
                      id="cf-message-help"
                      className="text-xs leading-[1.7] text-gray-500"
                    >
                      {MESSAGE_MIN}文字以上・{MESSAGE_MAX}文字まで。箇条書きでも構いません。
                    </p>
                    {/* 1文字ごとに読み上げると入力の妨げになるので装飾扱いにする */}
                    <p
                      aria-hidden="true"
                      className="shrink-0 text-[11px] text-gray-400 sm:pt-[0.15em]"
                    >
                      <span className={`${montserrat.className} tabular-nums`}>
                        {values.message.length}
                      </span>
                      <span className="mx-0.5">/</span>
                      <span className={`${montserrat.className} tabular-nums`}>
                        {MESSAGE_MAX}
                      </span>
                    </p>
                  </div>
                </div>

                {/* 同意チェック(必須): 罫線1本で「同意と送信」の段へ切り替える */}
                <div className="border-t border-gray-100 pt-5">
                  <div className="flex items-start gap-3">
                    <span className="relative mt-0.5 flex size-[18px] shrink-0 items-center justify-center">
                      <input
                        ref={(el) => {
                          fieldRefs.current.consent = el;
                        }}
                        id="cf-consent"
                        name="consent"
                        type="checkbox"
                        checked={values.consent}
                        onChange={(e) => update({ consent: e.target.checked })}
                        aria-invalid={errors.consent ? true : undefined}
                        aria-describedby={describedBy("consent", "cf-consent-help")}
                        className={`peer absolute inset-0 size-full cursor-pointer appearance-none rounded-[5px] border bg-white outline-none transition-colors checked:border-amber-600 checked:bg-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
                          errors.consent ? "border-red-300" : "border-gray-300"
                        }`}
                      />
                      <svg
                        aria-hidden="true"
                        viewBox="0 0 12 12"
                        className="pointer-events-none relative size-3 scale-0 text-white transition-transform duration-200 ease-out peer-checked:scale-100 motion-reduce:transition-none"
                      >
                        <path
                          d="M2 6.2 4.7 8.9 10 3.1"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="1.8"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    </span>
                    <div>
                      {/* ラベルの中にリンクを入れない(押すとチェックが道連れでトグルする) */}
                      <label
                        htmlFor="cf-consent"
                        className="text-[13px] leading-[1.8] font-medium text-gray-800"
                      >
                        個人情報の取り扱いに同意します。
                      </label>
                      <p
                        id="cf-consent-help"
                        className="mt-1 text-xs leading-[1.8] text-gray-500"
                      >
                        いただいた内容はお問い合わせへの回答にのみ使用します。詳しくは
                        <a
                          href="#"
                          className="mx-0.5 font-medium text-gray-800 underline underline-offset-2 hover:no-underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                        >
                          プライバシーポリシー
                        </a>
                        をご覧ください。
                      </p>
                    </div>
                  </div>
                  <FieldError id="cf-consent-error" message={errors.consent} />
                </div>

                {/* 送信 */}
                <div className="flex flex-col items-start gap-3">
                  <button
                    type="submit"
                    className="group flex w-full items-center justify-center gap-3 rounded-lg bg-amber-600 px-6 py-3.5 text-sm font-bold text-white transition-transform duration-300 ease-out hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:w-auto sm:min-w-[15rem]"
                  >
                    この内容で送信する
                    {/* 矢印を送り出して入れ替える(opacity を触らないので下地が濁らない) */}
                    <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-x-4 group-focus-visible:translate-x-4 motion-reduce:transition-none">
                        →
                      </span>
                      <span className="absolute inset-0 flex -translate-x-4 items-center justify-center transition-transform duration-300 ease-out group-hover:translate-x-0 group-focus-visible:translate-x-0 motion-reduce:transition-none">
                        →
                      </span>
                    </span>
                  </button>
                  <p className="text-[11px] leading-[1.8] text-gray-500">
                    送信した時点では費用は発生しません。
                  </p>
                </div>
              </div>
            </form>
          )}
        </div>
      </div>

      {/* 入場アニメーション(このブロック内で完結・transform と opacity のみ) */}
      <style>{`
        @keyframes cf-error-in {
          from { opacity: 0; transform: translateY(-3px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes cf-panel-in {
          from { opacity: 0; transform: translateY(6px); }
          to   { opacity: 1; transform: translateY(0); }
        }
      `}</style>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/contact-form-block.json