First CH Designs
BLK-54Blocks

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文にする

01相談したい内容
02進めたい時期

最初に伺うこと

内容を選ぶと、こちらから最初にお聞きする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