本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

送信ボタンが丸くなって回り、チェックで完了する

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
使うもの
DrawSVGPlugin
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ボタンは、角丸を高さの半分にしたピル型にし、同じ幅の枠の中央に置きます。押したら文字を消しながら、幅を高さと同じ値まで縮めて丸にし、薄い輪の上で白い弧を回して送信中を示します。送信が終わると、弧が閉じて輪になり、ボタンが成功の緑に塗られて白いチェックが描かれ、外へ輪が広がって消えます。横の案内文も「送信しました。」に替わります。

文字は 0.15 秒で消し、幅は 0.45 秒の power3.inOut で縮めます。弧は円周の 28% の長さで、1 秒に 1.3 回転の速さで回します。完了では、弧を 0.25 秒で閉じ、チェックを 0.3 秒で描き、描き終わるころに外の輪を 1.7 倍まで広げて消します。

送信は、縮みきる前に終わることもあります。そのまま完了を流すと、縮む途中で緑に塗られて形が崩れるので、縮む動きが終わるのを待ってから完了を見せます。GSAP のタイムラインは await で終わりを待てます。

文字は透明度だけで消します。autoAlpha で消すと visibility まで隠れて、ボタンの名前が読み上げから消えます。結果は見た目だけでは伝わらないので、案内文の要素に role="status" を付け、文を入れ替えて読み上げさせます。送信に失敗したときは、逆再生で元のボタンに戻し、案内文で知らせます。

弧の速さと長さ、完了の色で印象が変わります。このデモは少し置いてから元のボタンに戻していますが、実際は完了の表示のまま残し、二重に送らないようにします。お問い合わせ、申し込み、購入、保存のボタンに向いています。

コード

HTML
<form class="contact" action="/contact/" method="post">
  <label class="contact__field">
    <span class="contact__label">お名前</span>
    <input class="contact__input" type="text" name="name" autocomplete="name" required />
  </label>
  <label class="contact__field">
    <span class="contact__label">メールアドレス</span>
    <input class="contact__input" type="email" name="email" autocomplete="email" required />
  </label>
  <label class="contact__field">
    <span class="contact__label">ご相談内容</span>
    <textarea class="contact__input" name="message" rows="4" required></textarea>
  </label>
  <div class="contact__foot">
    <!-- 結果は見た目だけでは伝わらないので、この文を入れ替えて読み上げさせる -->
    <p class="contact__status" role="status">1営業日以内にご返信します。</p>
    <!-- 縮んでも中心が動かないよう、ボタンと同じ幅の枠の中央に置く -->
    <div class="submit">
      <span class="submit__ping" aria-hidden="true"></span>
      <button class="submit__button" type="submit">
        <span class="submit__label">
          送信する
          <svg class="submit__arrow" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
        </span>
        <!-- 送信中の弧(薄い輪の上を回る)と、完了のチェック。どちらもボタンの中央に重ねる -->
        <svg class="submit__spinner" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
          <circle class="submit__track" cx="24" cy="24" r="19" />
          <circle class="submit__arc" cx="24" cy="24" r="19" transform="rotate(-90 24 24)" />
        </svg>
        <svg class="submit__check" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
          <path d="M13 25l7.5 7.5L35 17" />
        </svg>
      </button>
    </div>
  </div>
</form>

CSS
.contact {
  --ink: #262626;                  /* 文字とボタンの色 */
  --mute: #6b6b6b;                 /* ラベルと案内文の色 */
  --line: rgba(38, 38, 38, 0.12);  /* 欄の枠 */
  --on-button: #ffffff;            /* ボタンの上の文字・弧・チェック */
  --done: #4f8a64;                 /* 完了のボタンと、外へ広がる輪(成功の色) */
  --done-text: #3f7352;            /* 完了の案内文(小さい文字でも読める濃さ) */
  --button-width: 192px;
  --button-height: 56px;
  display: grid;
  gap: 24px;
  color: var(--ink);
}

.contact__field { display: grid; gap: 8px; }
.contact__label { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }

.contact__input {
  box-sizing: border-box;
  width: 100%;
  padding: 14px 16px;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: #ffffff;
  color: inherit;
  font: inherit;
  font-size: 16px;
}

.contact__input:focus { outline: none; border-color: var(--ink); }

.contact__foot { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.contact__status { margin: 0; color: var(--mute); font-size: 13px; letter-spacing: 0.04em; word-break: auto-phrase; }
.contact__status.is-done { color: var(--done-text); font-weight: 600; }

/* 縮んでも中心が動かないよう、ボタンと同じ幅の枠の中央に置く */
.submit {
  position: relative;
  flex: none;
  display: grid;
  place-items: center;
  width: var(--button-width);
  height: var(--button-height);
}

/* 角丸を高さの半分にしておくと、幅を高さまで縮めたときにちょうど丸になる */
.submit__button {
  position: relative;
  width: var(--button-width);
  height: var(--button-height);
  padding: 0;
  border: 0;
  border-radius: calc(var(--button-height) / 2);
  background-color: var(--ink);
  color: var(--on-button);
  font: inherit;
  cursor: pointer;
  overflow: hidden;
}

.submit__button[aria-disabled="true"] { cursor: default; }
.submit__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.submit__label { display: inline-flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
.submit__arrow { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }

/* 弧とチェックはボタンの中央に重ね、使うまで隠しておく */
.submit__spinner,
.submit__check {
  position: absolute;
  left: 50%;
  top: 50%;
  fill: none;
  stroke: var(--on-button);
  stroke-linecap: round;
  stroke-linejoin: round;
  visibility: hidden;
}

.submit__spinner { width: 34px; height: 34px; margin: -17px 0 0 -17px; stroke-width: 5; }
.submit__track { stroke: rgba(255, 255, 255, 0.22); }
.submit__check { width: 28px; height: 28px; margin: -14px 0 0 -14px; stroke-width: 5.5; }

/* 完了のときに外へ広がる輪。ボタンが縮んだ丸と同じ大きさから始める */
.submit__ping {
  position: absolute;
  width: var(--button-height);
  height: var(--button-height);
  box-sizing: border-box;
  border: 3px solid var(--done);
  border-radius: 50%;
  visibility: hidden;
}

JavaScript
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";

gsap.registerPlugin(DrawSVGPlugin);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える

document.querySelectorAll(".contact").forEach((form) => {
  const button = form.querySelector(".submit__button");
  const label = form.querySelector(".submit__label");
  const spinner = form.querySelector(".submit__spinner");
  const arc = form.querySelector(".submit__arc");
  const check = form.querySelector(".submit__check");
  const tick = check.querySelector("path"); // DrawSVG は svg 本体ではなく、線(path)にかける(svg にかけると描かれない)
  const ping = form.querySelector(".submit__ping");
  const status = form.querySelector(".contact__status");
  // 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
  const done = getComputedStyle(form).getPropertyValue("--done").trim();

  // 送信中: 文字が消え、幅が高さまで縮んで丸になり、弧が出る(逆再生で元のボタンに戻る)。
  // 文字は opacity だけで消す(autoAlpha だと visibility まで隠れ、ボタンの名前が読み上げから消える)
  const sending = gsap
    .timeline({ paused: true })
    .to(label, { opacity: 0, duration: 0.15, ease: "none" }, 0)
    .fromTo(button, { width: button.offsetWidth }, { width: button.offsetHeight, duration: 0.45, ease: "power3.inOut" }, 0.02)
    .fromTo(spinner, { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.2, ease: "none" }, 0.3)
    .timeScale(SPEED);
  // 弧は円周の 28% の長さで、ずっと回しておく(1 秒に約 1.3 回転)。完了で止める
  gsap.set(arc, { drawSVG: "0% 28%" });
  const spin = gsap.to(spinner, { rotation: "+=360", duration: 0.77, ease: "none", repeat: -1, paused: true });

  let busy = false;
  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    if (busy) return; // 二重に送らない
    busy = true;
    button.setAttribute("aria-disabled", "true");
    status.textContent = "送信しています…";
    sending.play();
    spin.play();

    let ok = false;
    try {
      const response = await fetch(form.action, { method: "POST", body: new FormData(form) });
      ok = response.ok;
    } catch {
      ok = false;
    }
    await sending; // 縮みきる前に終わったときは、縮みきってから結果を見せる

    if (!ok) {
      // 失敗: 元のボタンに戻して、もう一度押せるようにする
      spin.pause();
      sending.reverse();
      status.textContent = "送信できませんでした。時間をおいて、もう一度お試しください。";
      button.removeAttribute("aria-disabled");
      busy = false;
      return;
    }

    // 完了: 弧が閉じて輪になり、緑に塗られ、輪が消えてチェックが描かれ、外へ輪が広がる
    status.textContent = "送信しました。";
    status.classList.add("is-done");
    gsap
      .timeline()
      .to(arc, { drawSVG: "0% 100%", duration: 0.25, ease: "power2.inOut" }, 0)
      .to(button, { backgroundColor: done, duration: 0.25, ease: "none" }, 0.15)
      .to(spinner, { autoAlpha: 0, duration: 0.2, ease: "none", onComplete: () => spin.pause() }, 0.2)
      .set(check, { autoAlpha: 1 }, 0.25)
      .fromTo(tick, { drawSVG: "0%" }, { drawSVG: "100%", duration: 0.3, ease: "power2.out" }, 0.25)
      // 外へ広がる輪は、チェックが描き終わるころに出す
      .fromTo(ping, { autoAlpha: 0.6, scale: 1 }, { scale: 1.7, duration: 0.6, ease: "power2.out", immediateRender: false }, 0.45)
      .to(ping, { autoAlpha: 0, duration: 0.5, ease: "power1.in" }, 0.55)
      .timeScale(SPEED);
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。