BLK-12Blocks
お問い合わせフォームセクション
既存の text-field(INP-01)の入力欄トークンをそのまま継承し、部品からセクションへ組み上げたお問い合わせフォーム。左に見出し+連絡手段(返信目安・受付時間・電話・費用)、右にフォーム本体を置き、外枠なし・border-y と lg:divide-x の罫線1本だけで束ねる(カード羅列にしない)。氏名/会社名/メール/本文/同意チェック/送信ボタンに必須・任意バッジを付け、送信時に全件検査 → エラーサマリへフォーカス移動 → 各行のボタンで該当欄へジャンプ、以後は入力に追随して再検査する。必須項目の充足はhairlineの進捗メーターで示し、送信後は完了パネルへ差し替えてフォーカスを受け止める。参考元は特定サイトではなく全案件の標準スコープ(検収チェックリストのフォーム項目)。画像素材ゼロ。
- 追加日:
- 2026-08-06
- 依存:
- なし
- tags
- #form #contact #section #block #validation #accessibility #split #hairline #brand #motion #no-image #ref:standard-acceptance-scope
プレビュー
お問い合わせ
見積もりの前に、
話を聞かせて
ください。
作り替えたい、更新が止まっている、何から手を付けるか決めかねている。どの段階でも構いません。いただいた内容は担当者が直接読み、そのままご返信します。
- ご返信
- 1営業日以内
- 受付
- 平日 10:00–19:00
- お電話
- 050-0000-0000
- 費用
- ご相談・お見積りまで無料
"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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/contact-form-block.json