デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
同期が終わるまで、矢印は回り続けます。終わった合図を受けると減速して、ちょうど元の向きで止まり、止まりぎわに円弧が後ろから縮んで、その先がそのままチェックを描きます。ボタンが緑に塗られ、少し見せたあとで、同じ線をたどって矢印に戻ります。
回り始めだけ power1.in で加速させ、あとは毎秒540°の一定の速さです。同期が終わったら、半周以上先にある360°の倍数の角度まで power1.out で減速させます。止める角度をこう限ったのは、半端な角度で止まるとチェックが傾いて描かれるからです。この曲線は出だしの速さが平均の倍なので、減速の長さを残りの角度から逆算すれば、一定の速さからつなぎ目なく止まれます。
円弧とチェックは、円弧の終わりからそのまま折れ曲がる1本の線です。DrawSVGPlugin で見せる区間を、円弧だけの範囲からチェックだけの範囲へ滑らせると、尾が縮むのと同時に先頭がチェックを描いていきます。境目の65.67%は円弧の長さを全長で割った値なので、線の形を変えたら計算し直してください。滑らせ始めるのは、止まりきる0.35秒前にしました。チェックは最後の数十度を回りながら形になり、そのまま収まります。矢じりは、円弧の終わりの点を中心に縮めて畳みます。
同期のあいだは aria-disabled で二度押しを受け付けず、進み具合と結果は role="status" の行に書いて読み上げに届けます。通信に失敗したときは、矢印のまま止めて「同期できませんでした」と表示します。
コード
<!-- 押すと同期する。進み具合と結果は role="status" の行で伝える -->
<div class="sync">
<button class="sync__button" type="button" aria-label="写真を同期する">
<svg class="sync__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<g class="sync__spin">
<!-- 円弧とチェックを 1 本の線でつなぐ。見せる区間を、円弧からチェックへ滑らせる -->
<path class="sync__line" d="M7.179 6.255A7.5 7.5 0 1 1 4.5 12L9.5 17L19.5 7" />
<!-- 矢じり。先は円弧の終わりの点 (4.5, 12) -->
<path class="sync__head" d="M1.5 15L4.5 12L7.5 15" />
</g>
</svg>
</button>
<div>
<p class="sync__title">写真ライブラリ</p>
<p class="sync__status" role="status">写真 1,284枚・動画 36本</p>
</div>
</div>
.sync {
--ink: #262626; /* 線と文字の色 */
--surface: #ffffff; /* ボタンの背景色 */
--hover: #f2f1ed; /* ホバーしたときのボタンの背景色 */
--mute: #6b6b6b; /* 件数の文字 */
--success: #4f8a64; /* 同期が終わったときのボタンの背景色 */
--on-success: #ffffff; /* 緑の背景の上のアイコン */
--success-text: #3f7352; /* 「同期しました」の文字 */
--error-text: #a8432d; /* 「同期できませんでした」の文字 */
display: flex;
align-items: center;
gap: 16px;
color: var(--ink);
}
.sync__button {
display: grid;
place-items: center;
flex: none;
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--ink);
border-radius: 50%;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.sync__button:hover {
background: var(--hover);
}
.sync__button[aria-disabled="true"] {
cursor: progress;
}
.sync__button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
}
/* 24 グリッドで線幅 2(24px で描けば 2px)。線端と角は丸 */
.sync__icon {
width: 24px;
height: 24px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.sync__title {
margin: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.4;
}
.sync__status {
margin: 2px 0 0;
font-size: 13px;
line-height: 1.6;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
color: var(--mute);
}
.sync__status[data-state="busy"] {
color: var(--ink);
}
.sync__status[data-state="done"] {
color: var(--success-text);
}
.sync__status[data-state="error"] {
color: var(--error-text);
}
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
gsap.registerPlugin(DrawSVGPlugin);
const SPLIT = 65.67; // 線のうち円弧の割合(%)。円弧の長さ ÷ 線の全長。残りがチェック
const SPEED = 540; // 回る速さ(度/秒)
const HOLD = 1.6; // チェックを見せておく秒数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const d = (seconds) => (reduceMotion ? 0 : seconds);
// 同期の処理。実際の通信(fetch など)に置き換える。失敗したら reject する
const sync = () => new Promise((resolve) => setTimeout(resolve, 1500));
document.querySelectorAll(".sync").forEach((root) => {
const button = root.querySelector(".sync__button");
const spin = root.querySelector(".sync__spin");
const line = root.querySelector(".sync__line");
const head = root.querySelector(".sync__head");
const status = root.querySelector(".sync__status");
const idleText = status.textContent;
const css = getComputedStyle(root);
const color = (name) => css.getPropertyValue(name).trim();
const setStatus = (text, state) => {
status.textContent = text;
status.dataset.state = state;
};
gsap.set(line, { drawSVG: `0% ${SPLIT}%` });
button.addEventListener("click", async () => {
if (button.getAttribute("aria-disabled") === "true") return; // 同期している間は受け付けない
button.setAttribute("aria-disabled", "true");
setStatus("同期しています…", "busy");
// 回り始めは加速し、あとは一定の速さで回り続ける(動きを減らす設定では回さない)
const loop = reduceMotion
? null
: gsap
.timeline()
.to(spin, { rotation: "+=120", svgOrigin: "12 12", duration: 240 / SPEED, ease: "power1.in" })
.to(spin, { rotation: "+=360", svgOrigin: "12 12", duration: 360 / SPEED, ease: "none", repeat: -1 });
const ok = await sync().then(() => true, () => false);
loop?.kill();
// 次のちょうど 1 周(半周以上先)まで、同じ速さから減速して止める。power1.out の出だしの速さは平均の 2 倍
const from = gsap.getProperty(spin, "rotation");
const to = reduceMotion ? from : Math.ceil((from + 180) / 360) * 360;
const tl = gsap.timeline({
onComplete() {
gsap.set(spin, { rotation: 0, svgOrigin: "12 12" }); // 何周しても見た目は同じ。数を 0 に戻す
button.removeAttribute("aria-disabled");
},
});
tl.to(spin, { rotation: to, svgOrigin: "12 12", duration: (2 * (to - from)) / SPEED, ease: "power1.out" });
if (!ok) {
tl.call(setStatus, ["同期できませんでした", "error"]);
return;
}
// 止まりきる少し前から、見せる区間を円弧からチェックへ滑らせ、矢じりを畳む
tl.to(line, { drawSVG: `${SPLIT}% 100%`, duration: d(0.55), ease: "power2.inOut" }, reduceMotion ? 0 : "-=0.35")
.to(head, { scale: 0, svgOrigin: "4.5 12", duration: d(0.15), ease: "power2.in" }, "<")
.to(button, { backgroundColor: color("--success"), borderColor: color("--success"), color: color("--on-success"), duration: d(0.3) }, "<0.3")
.call(setStatus, ["同期しました", "done"], "<")
// 少し保ってから、同じ線をたどって矢印に戻る。色は CSS の値に返す(:hover が効くように)
.addLabel("back", `+=${HOLD}`)
.to(line, { drawSVG: `0% ${SPLIT}%`, duration: d(0.45), ease: "power2.inOut" }, "back")
.to(head, { scale: 1, svgOrigin: "4.5 12", duration: d(0.2), ease: "power2.out" }, `back+=${d(0.25)}`)
.to(button, { backgroundColor: color("--surface"), borderColor: color("--ink"), color: color("--ink"), duration: d(0.3), clearProps: "backgroundColor,borderColor,color" }, "back")
.call(setStatus, [idleText, ""], "back");
});
});