Sentence-Builder Two-Step CTA
A two-step CTA where the choices assemble the request itself. The lead element is not the button but a fill-in-the-blank sentence — "I want to move ahead with (what) (when)" — whose blanks sit on dashed grey rules until answered and switch to solid amber ones once filled. Two radio groups (new build / rebuild / improvement, and now / within three months / still researching) lift each word up into its slot. Hovering or keyboard-focusing a candidate previews the wording in place before committing, so the finished sentence is readable ahead of the click. The choice also swaps the downstream panel — the three things asked first, the first-reply window, and what gets sent back — while the submit label runs from "pick two to send" through "one more to send" to "send the (topic) enquiry", staying disabled at a fixed size until both slots are full. The visible sentence is aria-hidden and only committed choices reach an sr-only live region, so previews never spam the screen reader. No rounded corners, no Latin kicker, no arrow links, no entrance animation — hairline rules and spacing do the binding. Options and reply windows are display samples.
- Added:
- 2026-09-08
- Dependencies:
- None
- tags
- #cta #form #radio #two-step #microcopy #sentence #accessibility #no-image
Preview
相談したいことを1文にする
最初に伺うこと
内容を選ぶと、こちらから最初にお聞きする3点を表示します。
送信後のこと
初回の返信の目安と、お送りするものを表示します。
2つのうち0つ選択済み
表示している選択肢・返信の目安はサンプルです。実際の内容は案件に合わせて差し替えてください。
"use client";
import { useId, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 依頼文を組み立てる2段CTA / Sentence-Builder Two-Step CTA
* 既存の二段CTAは「在庫(日時のマトリクス)から1枠を選ぶ」表の操作なので、こちらは
* 選ぶ対象を依頼の中身に変え、選択がそのまま穴埋め文の空欄を埋める型にした。
* 候補にホバー/フォーカスすると文が先に書き換わって見え、埋まった文がそのまま送信ボタンの文言になる。
* 選択に応じて「最初に伺うこと」と初回返信の目安も入れ替わるため、選ぶ前に送信後の段取りまで読める。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 選択式CTAは「選択の要約」ではなく「穴埋め文」に出す: 選んだ値を箇条書きで確認させるより、
* 1文の空欄が埋まるほうが未完了が一目で分かる。空欄は下に点線罫(border-dashed)、
* 埋まったら実線のアンバー罫に変えると、入力欄の比喩を保ったまま状態が読める。
* - 候補のホバー/フォーカスで文を先に書き換える(プレビュー): 押す前に結果が見えるので選択の往復が減る。
* プレビューは選択と同じ場所に描き、確定値と区別するため色を薄く・罫を点線のままにする。
* onPointerEnter だけでなく onFocus にも同じハンドラを付けないとキーボードで同じ体験にならない。
* - 読み上げは見た目と分ける: プレビューが aria-live に流れると候補を移動するたびに喋る。
* 見える文は aria-hidden にし、確定値だけを sr-only の aria-live="polite" に別途書き出す。
* - ラジオは <input type="radio"> の sr-only + peer で組む: 矢印キー移動・グループのフォーカス管理が
* ブラウザ任せになる。見た目は label 側に peer-checked: / peer-focus-visible: で当てる。
* div + role="radio" で作り直すと roving tabindex を自前で書く羽目になる。
* - 送信ボタンの文言は組み上がった文から作る: 「送信する」ではなく「◯◯の相談を送る」にすると、
* ボタン単体でも何が送られるか読める。未完了のときは残り件数を文言にして disabled のまま置き、
* 寸法を変えないので活性化でレイアウトが動かない。
* - 掲載している内容・返信目安はすべて架空の表示サンプル。
*/
type Intent = {
key: string;
label: string;
/** 文中での言い回し(ラベルと分ける) */
sentence: string;
note: string;
/** 選択後に出す「最初に伺うこと」 */
asks: readonly string[];
reply: string;
};
const INTENTS: readonly Intent[] = [
{
key: "new",
label: "新規制作",
sentence: "サイトの新規制作",
note: "これから立ち上げる",
asks: [
"公開したい時期と、決まっている予算の幅",
"載せたい情報(会社案内・実績・採用など)",
"参考にしたいサイトが2〜3件あれば",
],
reply: "1営業日以内",
},
{
key: "renew",
label: "リニューアル",
sentence: "いまのサイトのリニューアル",
note: "作り直して整える",
asks: [
"現在のサイトのURLと、公開した時期",
"作り直したい理由(見た目・更新性・集客)",
"引き継ぐページと、やめるページの見当",
],
reply: "1営業日以内",
},
{
key: "improve",
label: "改善",
sentence: "公開中のサイトの改善",
note: "部分的に直す",
asks: [
"直したいページと、気になっている箇所",
"更新の権限(管理画面・コードの所在)",
"見ている数字があれば、その指標",
],
reply: "2営業日以内",
},
];
type Timing = {
key: string;
label: string;
sentence: string;
/** 選択後に出す返信の中身 */
follow: string;
};
const TIMINGS: readonly Timing[] = [
{
key: "now",
label: "今すぐ",
sentence: "今すぐ",
follow: "着手できる枠から先にお伝えします",
},
{
key: "quarter",
label: "3か月以内",
sentence: "3か月以内に",
follow: "概算と進行スケジュールをまとめてお送りします",
},
{
key: "research",
label: "検討段階",
sentence: "まずは相談から",
follow: "近い事例と費用感の資料をお送りします",
},
];
/** 穴埋め文の空欄1つ。確定値・プレビュー・未選択の3状態を同じ場所に描く */
function Slot({
value,
preview,
placeholder,
}: {
value: string | null;
preview: string | null;
placeholder: string;
}) {
const shown = preview ?? value;
const isPreview = preview !== null;
return (
<span
className={`mx-0.5 inline-block border-b-2 px-1 pb-0.5 transition-colors duration-200 motion-reduce:transition-none ${
value && !isPreview
? "border-amber-600 border-solid text-amber-700"
: isPreview
? "border-amber-400 border-dashed text-amber-600/60"
: "border-gray-300 border-dashed text-gray-400"
}`}
>
{/* 値が変わるたびに key を変えて、文字だけを持ち上げて差し替える */}
<span
key={shown ?? "empty"}
className="inline-block motion-safe:animate-[twoStepSlotIn_260ms_ease-out]"
>
{shown ?? placeholder}
</span>
</span>
);
}
export default function TwoStepPlanCta() {
const baseId = useId();
const [intent, setIntent] = useState<Intent | null>(null);
const [timing, setTiming] = useState<Timing | null>(null);
const [intentPreview, setIntentPreview] = useState<Intent | null>(null);
const [timingPreview, setTimingPreview] = useState<Timing | null>(null);
const [sent, setSent] = useState(false);
const chosen = (intent ? 1 : 0) + (timing ? 1 : 0);
const ready = intent !== null && timing !== null;
const buttonLabel = ready
? `${intent.label}の相談を送る`
: chosen === 0
? "2つ選ぶと送信できます"
: "あと1つ選ぶと送信できます";
const optionClass =
"flex cursor-pointer items-baseline gap-2.5 px-5 py-3 transition-colors duration-200 hover:bg-amber-50/60 peer-checked:bg-amber-50 peer-focus-visible:outline-2 peer-focus-visible:-outline-offset-2 peer-focus-visible:outline-amber-600 motion-reduce:transition-none sm:px-6";
const markClass =
"mt-[0.35rem] inline-block size-2 shrink-0 border border-gray-300 bg-white transition-transform duration-200 ease-out peer-checked:scale-125 peer-checked:border-amber-600 peer-checked:bg-amber-600 motion-reduce:transition-none";
return (
<section
aria-labelledby={`${baseId}-heading`}
className="w-full max-w-3xl border border-gray-200 bg-white"
>
{/* 値の差し替えは transform と opacity だけで作る */}
<style>{`@keyframes twoStepSlotIn{from{opacity:0;transform:translateY(0.3em)}to{opacity:1;transform:translateY(0)}}`}</style>
<div className="flex items-center gap-2 border-b border-gray-200 px-5 py-3 sm:px-6">
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
<h2
id={`${baseId}-heading`}
className="text-[13px] leading-none font-bold tracking-tight text-gray-900 sm:text-sm"
>
相談したいことを1文にする
</h2>
</div>
{/* 1段目の成果物: 選択が埋まっていく穴埋め文 */}
<p
aria-hidden="true"
className="px-5 pt-6 pb-5 text-[19px] leading-[2.1] font-bold text-gray-900 sm:px-6 sm:pt-8 sm:text-[26px] sm:leading-[2]"
>
<Slot
value={intent?.sentence ?? null}
preview={intentPreview?.sentence ?? null}
placeholder="(内容)"
/>
を
<Slot
value={timing?.sentence ?? null}
preview={timingPreview?.sentence ?? null}
placeholder="(時期)"
/>
{/* 述語は語中で折り返さない(375pxで「進/めたい。」と割れるため) */}
<span className="inline-block">進めたい。</span>
</p>
{/* 見える文はプレビューで揺れるので、確定した内容だけを読み上げに流す */}
<p aria-live="polite" className="sr-only">
{ready
? `${intent.sentence}を${timing.sentence}進めたい。送信できます。`
: chosen === 1
? "1つ選択しました。あと1つ選ぶと送信できます。"
: ""}
</p>
<div className="grid border-t border-gray-200 sm:grid-cols-2">
<fieldset className="border-b border-gray-200 py-4 sm:border-b-0 sm:border-r">
<legend className="flex items-baseline gap-2 px-5 pb-2 sm:px-6">
<span
className={`${montserrat.className} text-[11px] leading-none font-bold text-amber-600 tabular-nums`}
>
01
</span>
<span className="text-[12px] leading-none font-bold text-gray-900">
相談したい内容
</span>
</legend>
{INTENTS.map((it) => (
<div key={it.key} onPointerLeave={() => setIntentPreview(null)}>
<input
type="radio"
id={`${baseId}-intent-${it.key}`}
name={`${baseId}-intent`}
checked={intent?.key === it.key}
onChange={() => {
setIntent(it);
setSent(false);
}}
onPointerEnter={() => setIntentPreview(it)}
onFocus={() => setIntentPreview(it)}
onBlur={() => setIntentPreview(null)}
className="peer sr-only"
/>
<label
htmlFor={`${baseId}-intent-${it.key}`}
onPointerEnter={() => setIntentPreview(it)}
className={optionClass}
>
<span aria-hidden="true" className={markClass} />
<span className="min-w-0">
<span className="block text-[13.5px] leading-tight font-bold text-gray-900">
{it.label}
</span>
<span className="mt-1 block text-[11.5px] leading-tight text-gray-500">
{it.note}
</span>
</span>
</label>
</div>
))}
</fieldset>
<fieldset className="py-4">
<legend className="flex items-baseline gap-2 px-5 pb-2 sm:px-6">
<span
className={`${montserrat.className} text-[11px] leading-none font-bold text-amber-600 tabular-nums`}
>
02
</span>
<span className="text-[12px] leading-none font-bold text-gray-900">
進めたい時期
</span>
</legend>
{TIMINGS.map((tm) => (
<div key={tm.key} onPointerLeave={() => setTimingPreview(null)}>
<input
type="radio"
id={`${baseId}-timing-${tm.key}`}
name={`${baseId}-timing`}
checked={timing?.key === tm.key}
onChange={() => {
setTiming(tm);
setSent(false);
}}
onPointerEnter={() => setTimingPreview(tm)}
onFocus={() => setTimingPreview(tm)}
onBlur={() => setTimingPreview(null)}
className="peer sr-only"
/>
<label
htmlFor={`${baseId}-timing-${tm.key}`}
onPointerEnter={() => setTimingPreview(tm)}
className={optionClass}
>
<span aria-hidden="true" className={markClass} />
<span className="min-w-0">
<span className="block text-[13.5px] leading-tight font-bold text-gray-900">
{tm.label}
</span>
<span className="mt-1 block text-[11.5px] leading-tight text-gray-500">
{tm.key === "now"
? "今月中に動きたい"
: tm.key === "quarter"
? "時期は決まっている"
: "情報を集めている"}
</span>
</span>
</label>
</div>
))}
</fieldset>
</div>
{/* 選択に応じて中身が入れ替わる段取り。送信前に「次に何が起きるか」まで読める */}
<div className="grid border-t border-gray-200 sm:grid-cols-2">
<div className="border-b border-gray-100 px-5 py-4 sm:border-b-0 sm:border-r sm:border-gray-200 sm:px-6">
<p className="text-[11px] leading-none font-bold text-gray-500">
最初に伺うこと
</p>
{intent ? (
<ol className="mt-2.5 space-y-1.5">
{intent.asks.map((ask, i) => (
<li
key={ask}
className="flex gap-2 text-[12px] leading-[1.7] text-gray-700 motion-safe:animate-[twoStepSlotIn_320ms_ease-out] motion-safe:[animation-fill-mode:backwards]"
style={{ animationDelay: `${i * 70}ms` }}
>
<span
className={`${montserrat.className} shrink-0 pt-[0.15rem] text-[10.5px] leading-[1.7] font-semibold text-amber-600 tabular-nums`}
>
{`0${i + 1}`}
</span>
{ask}
</li>
))}
</ol>
) : (
<p className="mt-2.5 text-[12px] leading-[1.7] text-gray-400">
内容を選ぶと、こちらから最初にお聞きする3点を表示します。
</p>
)}
</div>
<div className="px-5 py-4 sm:px-6">
<p className="text-[11px] leading-none font-bold text-gray-500">
送信後のこと
</p>
{intent || timing ? (
<div className="mt-2.5 space-y-1.5">
{intent && (
<p className="flex items-baseline gap-2 text-[12px] leading-[1.7] text-gray-700">
<span className="shrink-0 text-gray-500">初回の返信</span>
<span
className={`${montserrat.className} text-[15px] leading-none font-bold text-gray-900`}
>
{intent.reply}
</span>
</p>
)}
{timing && (
<p className="text-[12px] leading-[1.7] text-gray-700">
{timing.follow}
</p>
)}
</div>
) : (
<p className="mt-2.5 text-[12px] leading-[1.7] text-gray-400">
初回の返信の目安と、お送りするものを表示します。
</p>
)}
</div>
</div>
{/* 2段目: 文が埋まって初めて押せる送信 */}
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:px-6">
{sent ? (
<p className="flex items-center gap-2 text-[12.5px] leading-[1.8] font-bold text-gray-900">
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
この内容でお預かりしました
</p>
) : (
<p className="flex items-center gap-2 text-[11.5px] leading-none text-gray-500">
<span aria-hidden="true" className="flex gap-1">
{[0, 1].map((i) => (
<span
key={i}
className={`inline-block size-1.5 ${
i < chosen ? "bg-amber-600" : "bg-gray-300"
}`}
/>
))}
</span>
2つのうち{chosen}つ選択済み
</p>
)}
{sent ? (
<button
type="button"
onClick={() => setSent(false)}
className="self-start text-[12.5px] font-bold 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 sm:self-auto"
>
書き直す
</button>
) : (
<button
type="button"
disabled={!ready}
onClick={() => setSent(true)}
className="w-full shrink-0 bg-amber-600 px-6 py-3 text-[13px] 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 disabled:cursor-default disabled:bg-gray-200 disabled:text-gray-400 disabled:hover:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:w-auto"
>
{buttonLabel}
</button>
)}
</div>
<p className="border-t border-gray-100 px-5 py-3 text-[10.5px] leading-[1.9] text-gray-400 sm:px-6">
表示している選択肢・返信の目安はサンプルです。実際の内容は案件に合わせて差し替えてください。
</p>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/two-step-plan-cta.json