デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- DrawSVGPlugin
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
更新の矢印は、円弧と矢じりの 2 本の線です。押すと矢印全体が回り続け、同期が終わると減速して、ちょうど 1 周の位置で止まります。止まりぎわに円弧が後ろから縮みながらチェックへ流れ込み、ボタンが成功の緑に塗られます。少し見せてから、同じ線をたどって矢印に戻ります。
回り始めは power1.in で 120° だけ加速し、あとは毎秒 540° の一定の速さで回します。終わったら、今の角度から半周以上先の「ちょうど 1 周」の角度までを power1.out で減速させます。power1.out の出だしの速さは平均の 2 倍なので、長さを「残りの角度 × 2 ÷ 540」にすると、一定の速さからつなぎ目なく止まります。止める角度が 360° の倍数でないと、チェックが傾いたまま描かれます。
円弧とチェックは、円弧の終わりの点からそのままチェックへ折れる 1 本の線にしてあります。DrawSVGPlugin で、見せる区間を「0〜65.67%(円弧だけ)」から「65.67〜100%(チェックだけ)」へ 0.55 秒で滑らせると、円弧の尾が縮みながら、頭がチェックを描いていきます。65.67% は円弧の長さを線の全長で割った値なので、形を変えたら計算し直します。滑らせ始めるのは止まりきる 0.35 秒前で、チェックは最後の数十度を回りながら収まります。矢じりは円弧の終わりの点を中心に縮めて畳みます。回す中心も畳む中心も、svgOrigin で 24 のグリッドの座標のまま書けます。
同期している間は aria-disabled を付けて二度押しを受けず、進み具合と結果は role="status" の行に書いて読み上げに届けます。通信に失敗したときは、矢印のまま止めて「同期できませんでした」と出します。
動きを減らす設定では回さず、終わったらすぐチェックに切り替えます。回る速さと、チェックを見せておく時間(1.6 秒)で印象が変わります。このデモは 48px のボタンを 4 倍に拡大して見せています。待っているあいだの記号がそのまま完了の印に変わるので、同期・再読み込み・一覧の更新のボタンに向いています。
コード
<!-- 押すと同期する。進み具合と結果は 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");
});
});