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

アイコン

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

デモ

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

仕様

用途
アイコン
動作
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" の行に書いて読み上げに届けます。通信に失敗したときは、矢印のまま止めて「同期できませんでした」と表示します。

コード

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。