デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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" を付け、文を入れ替えて読み上げさせます。送信に失敗したときは、逆再生で元のボタンに戻し、案内文で知らせます。
弧の速さと長さ、完了の色で印象が変わります。このデモは少し置いてから元のボタンに戻していますが、実際は完了の表示のまま残し、二重に送らないようにします。お問い合わせ、申し込み、購入、保存のボタンに向いています。
コード
<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>
.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;
}
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);
});
});