本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

更新の矢印のアイコンが回り、終わるとチェックに変わる

デモ

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

仕様

用途
アイコン
動作
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 倍に拡大して見せています。待っているあいだの記号がそのまま完了の印に変わるので、同期・再読み込み・一覧の更新のボタンに向いています。

コード

HTML
<!-- 押すと同期する。進み具合と結果は 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>

CSS
.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);
}

JavaScript
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");
  });
});

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

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