デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
送信ボタンを押した直後、紙飛行機は一度後ろへ引いてから右上へ飛び去り、反対の左下から戻ってきて、少し行き過ぎて止まります。同時にボタンは緑になり、文字が下から上へ「招待を送る」から「送信しました」に入れ替わります。紙飛行機が止まって1.3秒たつと、色と文字は元に戻ります。
紙飛行機は、はみ出した部分を隠す32px四方の要素の中で動かしています。飛び去る動きは power3.in でだんだん速く、戻りは back.out(1.7) で行き過ぎてから止まるので、小さな範囲の移動でも「飛んでいく」と「戻ってくる」の区別がつきます。飛び去る距離は、要素の幅の1.44倍にしました。幅より短いと左下へ移る瞬間が要素の中に見えてしまうので、完全に外へ抜けきってから位置を移しています。
文字の入れ替えも、はみ出しを隠す要素の中で行います。2つの文言を同じ位置に重ね、要素の幅は長いほうに合わせています。片方を position: absolute にして重ねるのが手軽そうですが、それでは幅が短いほうの文言だけで決まり、「送信しました」の端が切れてしまいます。
送信を待つあいだと「送信しました」を見せているあいだは、aria-disabled を付けて押し直しを受け付けません。このとき、クリックの既定の動作も止めています。止めないと、送信後に空にした入力欄に対して、ブラウザの入力チェックのメッセージが表示されてしまいます。結果は role="status" の要素で知らせ、送れなかったときはその文を画面にも表示します。メールアドレスの入力欄に pattern を足してあるのは、ドメインにドットのないアドレスを弾くためです。
コード
<!-- 招待のフォーム。送ると紙飛行機が飛び、ボタンが「送信しました」に変わって、少し保ってから戻る -->
<form class="invite">
<div class="invite__head">
<span class="invite__badge" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false"><circle cx="9.5" cy="8" r="3.5" /><path d="M3 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6" /></svg>
</span>
<div>
<h2 class="invite__title">メンバーを招待</h2>
<p class="invite__lead">ここに説明文が入ります。</p>
</div>
</div>
<div class="invite__row">
<label class="invite__field">
<span class="invite__hidden">メールアドレス</span>
<!-- type="email" はドットの無いドメインも通すので、pattern で足す -->
<input class="invite__input" type="email" name="email" placeholder="sample@higashiyuki.com" autocomplete="email" required pattern="[^@\s]+@[^@\s]+\.[^@\s]+" />
</label>
<button class="send" type="submit">
<!-- 文字は 2 段を重ねたマスクの中で入れ替える。2 段目は読み上げない(結果は下の status が知らせる) -->
<span class="send__labels"><span class="send__label">招待を送る</span><span class="send__label" aria-hidden="true">送信しました</span></span>
<!-- 紙飛行機のマスク。飛行機はマスクの右上の外へ抜け、左下の外から戻ってくる -->
<span class="send__slot" aria-hidden="true">
<svg class="send__plane" viewBox="0 0 24 24" focusable="false"><g><path d="M20.5 3.5 10.8 13.2M20.5 3.5 14.2 20.5 10.8 13.2 3.5 9.8z" /></g></svg>
</span>
</button>
</div>
<!-- スクリーンリーダーへの通知。文は data-* から入れる -->
<p class="invite__status" role="status" data-sent="招待を送信しました" data-failed="送信できませんでした。時間をおいてもう一度お試しください。"></p>
</form>
.invite {
--bg: #111111; /* 入力欄の背景・プライマリボタンの文字 */
--surface: #262626; /* カードの背景 */
--ink: #eeece6; /* 文字・プライマリボタンの塗り */
--mute: #a8a6a0; /* 補足の文字 */
--line: rgba(238, 236, 230, 0.14); /* 枠線 */
--hover: #dedcd6; /* プライマリボタンにホバーしたとき */
--down: #c9c7c1; /* プライマリボタンを押している間 */
--success: #4f8a64; /* 送信したあとのボタン */
--on-success: #ffffff; /* 緑の背景の上の文字と紙飛行機 */
--error: #e07a62; /* 入力の誤り */
max-width: 720px;
box-sizing: border-box;
padding: clamp(24px, 6vw, 48px);
border-radius: 4px;
background: var(--surface);
color: var(--ink);
}
.invite__head { display: flex; align-items: center; gap: 16px; }
.invite__badge { display: grid; place-items: center; flex: none; width: 48px; height: 48px; box-sizing: border-box; border: 1px solid var(--line); border-radius: 4px; }
.invite__badge svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.invite__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.invite__lead { margin: 4px 0 0; color: var(--mute); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; word-break: auto-phrase; }
/* 狭い画面では、ボタンが欄の下へ回る */
.invite__row { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 32px; }
.invite__field { display: flex; flex: 1 1 220px; }
.invite__input {
flex: 1;
min-width: 0;
height: 56px;
box-sizing: border-box;
padding: 0 16px;
border: 0;
border-radius: 4px;
background: var(--bg);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
font: inherit;
font-size: 16px;
}
/* 枠線は、通常時が罫線の色、フォーカスが文字の色、誤りがエラーの色 */
.invite__input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--ink); }
.invite__input:user-invalid { box-shadow: inset 0 0 0 1px var(--error); }
/* プライマリボタン(暗い背景では明るいピル型に暗い文字)。送信したら成功の色で塗る */
.send {
display: flex;
flex: none;
align-items: center;
gap: 8px;
height: 56px;
padding: 0 16px 0 24px;
border: 0;
border-radius: 999px;
background-color: var(--ink);
color: var(--bg);
font: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s linear, color 0.15s linear;
}
.send:hover { background-color: var(--hover); }
.send:active { background-color: var(--down); transition-duration: 0.08s; }
.send.is-sent { background-color: var(--success); color: var(--on-success); transition: background-color 0.35s linear 0.25s, color 0.35s linear 0.25s; }
.send[aria-disabled="true"] { cursor: default; }
.send:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 文字のマスク。2 段を同じグリッドのセルに重ねて幅を長いほうに合わせ、2 段目はマスクの下に隠しておく */
.send__labels { display: grid; height: 1.3em; overflow: hidden; line-height: 1.3em; text-align: center; }
.send__label { grid-area: 1 / 1; }
.send__label + .send__label { position: relative; top: 110%; }
/* 紙飛行機のマスク。飛行機はマスクの外へ抜けると見えなくなる */
.send__slot { display: grid; place-items: center; flex: none; width: 32px; height: 32px; overflow: hidden; }
.send__plane { width: 24px; height: 24px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* 目には見せず、スクリーンリーダーにだけ届ける */
.invite__hidden, .invite__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* 送れなかったときの文は、目にも見せる */
.invite__status[data-error] { position: static; width: auto; height: auto; margin: 16px 0 0; clip-path: none; white-space: normal; color: var(--error); font-size: 14px; }
@media (prefers-reduced-motion: reduce) {
.send, .send.is-sent { transition: none; }
}
import { gsap } from "gsap";
const HOLD = 1.3; // 「送信しました」を見せておく秒数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
// 招待を送る処理に置き換える(fetch など)。失敗したら reject する
const sendInvite = async (email) => email;
document.querySelectorAll(".invite").forEach((form) => {
const button = form.querySelector(".send");
const plane = form.querySelector(".send__plane");
const tilt = plane.querySelector("g");
const [label, done] = form.querySelectorAll(".send__label");
const status = form.querySelector(".invite__status");
// マスクの外へ抜ける距離。マスクより大きくして、飛行機がマスクの外まで抜けきるようにする(32px のマスクで 46px)
const out = Math.round(form.querySelector(".send__slot").offsetWidth * 1.44);
gsap.set(tilt, { svgOrigin: "12 12" }); // 傾きと大きさは、紙飛行機の中心を軸にする
// 飛ぶ: 後ろへ引く → 右上の外へ(だんだん速く)→ 見えない間に左下の外へ移す → 戻って少し行き過ぎて止まる
const fly = gsap
.timeline({ paused: true })
.fromTo(plane, { x: 0, y: 0 }, { x: -4, y: 4, duration: 0.14, ease: "power2.out" }, 0)
.fromTo(tilt, { rotation: 0, scale: 1 }, { rotation: -6, scale: 0.94, duration: 0.14, ease: "power2.out" }, 0)
.to(plane, { x: out, y: -out, duration: 0.32, ease: "power3.in" }, 0.14)
.to(tilt, { rotation: -14, scale: 0.86, duration: 0.32, ease: "power3.in" }, 0.14)
.set(plane, { x: -out, y: out }, 0.48)
.set(tilt, { rotation: 10 }, 0.48)
.to(plane, { x: 0, y: 0, duration: 0.5, ease: "back.out(1.7)" }, 0.5)
.to(tilt, { rotation: 0, scale: 1, duration: 0.5, ease: "back.out(1.7)" }, 0.5);
// 送れた: 文字が上へ抜け、「送信しました」が下から入る(ボタンの色は CSS の .is-sent)
const sent = gsap
.timeline({ paused: true })
.to(label, { yPercent: -110, duration: 0.3, ease: "power2.in" }, 0.22)
.to(done, { yPercent: -110, duration: 0.35, ease: "power3.out" }, 0.4);
// 戻す: 「送信しました」が下へ抜け、元の文字が上から戻る
const back = gsap
.timeline({ paused: true })
.to(done, { yPercent: 0, duration: 0.25, ease: "power2.in" }, 0)
.to(label, { yPercent: 0, duration: 0.3, ease: "power3.out" }, 0.12);
// 送っている間と、知らせを見せている間は受けない(欄で Enter を押したときも、このクリックで止まる)
button.addEventListener("click", (event) => {
if (button.getAttribute("aria-disabled") === "true") event.preventDefault();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.setAttribute("aria-disabled", "true");
status.removeAttribute("data-error");
fly.timeScale(SPEED).restart(); // 押したらすぐ飛ばし、送るのと並べて待つ
try {
await sendInvite(form.elements.email.value);
} catch {
status.textContent = status.dataset.failed;
status.setAttribute("data-error", ""); // 失敗の文は目にも見せる
button.removeAttribute("aria-disabled");
return;
}
status.textContent = status.dataset.sent;
form.reset();
button.classList.add("is-sent");
await Promise.all([sent.timeScale(SPEED).restart(), fly]); // 飛行機が戻って止まってから、HOLD 秒保つ
await new Promise((resolve) => setTimeout(resolve, HOLD * 1000));
button.classList.remove("is-sent");
await back.timeScale(SPEED).restart();
status.textContent = ""; // 次に送ったときも、同じ文が読まれるように空けておく
button.removeAttribute("aria-disabled");
});
});