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

ダウンロードのアイコンの矢印が下りてトレイに収まり、完了のチェックになる

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
使うもの
DrawSVGPlugin・MorphSVGPlugin
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

アイコンは、トレイ・軸・矢じりの 3 本の線です。押すと、軸と矢じりが下りてトレイに収まり、待つあいだはトレイの中で小さく弾みます。ボタンのまわりの輪は進み具合に合わせて伸び、終わると軸が下へ抜けて消え、矢じりが持ち上がりながらチェックに変わって、ボタンが成功の緑に塗られます。

下りるのは 0.35 秒(power2.in)で 3px です。軸は上から縮めて半分ほどの長さにし、トレイに収まって見せます。チェックへは 0.5 秒の back.out(1.8) で、少し行き過ぎてから収まります。矢じりとチェックは同じ 3 点の折れ線なので、MorphSVGPlugin で素直に形が変わります。軸と輪の伸び縮みは DrawSVGPlugin で描きます。

軸を下へ抜ききると、丸い線端の点がトレイの上に 1 つ残ります。抜ききる直前に opacity を 0 にします。完了の緑は、ボタンの子に重ねた丸で塗ります。子に置くと、ボタンの線の丸まで覆えます。

輪は、fetch で読みながら数えたバイト数で伸ばします。応答に Content-Length が無いと、読み終えるまで伸びません。見た目の % は細かく変わるので、読み上げには role="status" の別の要素へ、始まり・25% ごと・完了だけを書きます。落としているあいだは aria-disabled を付け、二度押しを受けません。

完了のチェックは 2 秒見せてから矢印に戻し、もう一度押せるようにします。動きを減らす設定では、ほぼ一瞬で切り替えます。下りる量・弾みの強さ・チェックを見せる時間で印象が変わります。アイコンの大きさを変えても線を 2px に保つには、24 のグリッドで線幅を「48 ÷ 描く大きさ」にします。ファイル一覧・添付ファイル・書き出しのボタンに向いています。

コード

HTML
<ul class="files">
  <li class="file">
    <span class="file__kind" data-kind="pdf" aria-hidden="true">PDF</span>
    <div class="file__text">
      <p class="file__name">ブランドガイドライン.pdf</p>
      <p class="file__meta">2.4MB・2日前に更新</p>
    </div>
    <!-- アイコンだけのボタンなので、名前は aria-label に書く。data-href が落とすファイル -->
    <button class="download" type="button" aria-label="ブランドガイドライン.pdfをダウンロード" data-href="/downloads/brand-guidelines.pdf">
      <span class="download__tint" aria-hidden="true"></span>
      <svg class="download__ring" viewBox="0 0 60 60" aria-hidden="true" focusable="false">
        <circle class="download__track" cx="30" cy="30" r="28" />
        <!-- 進み具合の輪。12 時の位置から描くよう、-90° 回しておく -->
        <circle class="download__bar" cx="30" cy="30" r="28" transform="rotate(-90 30 30)" />
      </svg>
      <svg class="download__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
        <path class="download__shaft" d="M12 4v11" />
        <!-- 矢じり。完了のチェックと同じ 3 点の折れ線 -->
        <path class="download__head" d="M7.5 10.5 L12 15 L16.5 10.5" />
      </svg>
    </button>
  </li>
  <li class="file">
    <span class="file__kind" data-kind="zip" aria-hidden="true">ZIP</span>
    <div class="file__text">
      <p class="file__name">ロゴデータ一式.zip</p>
      <p class="file__meta">18.2MB・先週更新</p>
    </div>
    <button class="download" type="button" aria-label="ロゴデータ一式.zipをダウンロード" data-href="/downloads/logo-pack.zip">
      <span class="download__tint" aria-hidden="true"></span>
      <svg class="download__ring" viewBox="0 0 60 60" aria-hidden="true" focusable="false">
        <circle class="download__track" cx="30" cy="30" r="28" />
        <circle class="download__bar" cx="30" cy="30" r="28" transform="rotate(-90 30 30)" />
      </svg>
      <svg class="download__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" />
        <path class="download__shaft" d="M12 4v11" />
        <path class="download__head" d="M7.5 10.5 L12 15 L16.5 10.5" />
      </svg>
    </button>
  </li>
</ul>

<!-- 読み上げへの知らせ(ページに 1 つ)。見た目の % は細かく変わるので、ここには始まり・25% ごと・完了だけを書く -->
<p class="download-status" role="status"></p>

CSS
.files {
  --ink: #262626;                 /* 文字・線・進み具合の輪 */
  --mute: #6b6b6b;                /* 補足の文字 */
  --line: rgba(38, 38, 38, 0.12); /* 区切り線と輪の溝 */
  --surface: #ffffff;             /* ボタンの地 */
  --hover: #f2f1ed;               /* ホバー中のボタンの地・種類の札 */
  --success: #4f8a64;             /* 完了の塗りと輪 */
  --success-text: #3f7352;        /* 完了の文字(16px 未満の文字でも読める濃さ) */
  --on-success: #ffffff;          /* 緑の地の上のアイコン */
  max-width: 640px;
  margin: 0;
  padding: 0;
  list-style: none;
  color: var(--ink);
}

.file {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px 6px 16px 0; /* 右は輪のはみ出すぶん */
  border-top: 1px solid var(--line);
}

/* 種類の札。最後の字の後ろにも付く字間のぶん、左に余白を足して中央にそろえる */
.file__kind {
  display: grid;
  place-items: center;
  flex: none;
  width: 48px;
  height: 48px;
  box-sizing: border-box;
  padding-left: 0.14em;
  border-radius: 4px;
  background-color: var(--kind-bg, var(--hover));
  color: var(--kind-ink, var(--ink));
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.14em;
}

/* 種類ごとの札の色(地は淡く、字は 4.5:1 に届く濃さ) */
.file__kind[data-kind="pdf"] { --kind-bg: #f0e4e3; --kind-ink: #875a56; }
.file__kind[data-kind="zip"] { --kind-bg: #ece7e1; --kind-ink: #75614a; }

.file__text { flex: 1; min-width: 0; }
.file__name { margin: 0; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }
.file__meta { margin: 4px 0 0; color: var(--mute); font-size: 13px; font-variant-numeric: tabular-nums; }

/* アイコンだけのボタンは線の丸 */
.download {
  position: relative;
  display: grid;
  place-items: center;
  flex: none;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1px var(--ink);
  background-color: var(--surface);
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.15s;
}

.download:hover { background-color: var(--hover); }
.download:focus-visible { outline: 2px solid var(--ink); outline-offset: 8px; }
.download[aria-disabled="true"] { cursor: progress; }

/* 完了の緑。子に置くと、ボタンの線の丸まで覆える */
.download__tint { position: absolute; inset: 0; border-radius: 50%; background-color: var(--success); opacity: 0; }

/* 輪はボタンの外へ 6px はみ出して描く */
.download__ring { position: absolute; inset: -6px; width: 60px; height: 60px; fill: none; stroke-width: 2; pointer-events: none; }
.download__track { stroke: var(--line); opacity: 0; }
.download__bar { stroke: var(--ink); stroke-linecap: round; opacity: 0; }

/* 24 グリッドで線幅 2。緑の地より上に描くため、位置を持たせる */
.download__icon {
  position: relative;
  width: 24px;
  height: 24px;
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* 目には見せず、読み上げにだけ届ける */
.download-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (prefers-reduced-motion: reduce) {
  .download { transition: none; }
}

JavaScript
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";

gsap.registerPlugin(DrawSVGPlugin, MorphSVGPlugin);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const HOLD = 2; // 完了や失敗の知らせを見せておく秒数
const CHECK = "M6.5 10 L10.5 14 L17.5 7"; // 矢じりと同じ 3 点の折れ線なので、素直に形が変わる
const status = document.querySelector(".download-status");

// fetch で読みながら進み具合(0〜1)を知らせ、読み終えたら保存させる。同じオリジンのファイルに使う
async function download(url, onProgress) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`${response.status}`);
  const total = Number(response.headers.get("Content-Length")); // 無いときは、読み終えるまで輪が伸びない
  const reader = response.body.getReader();
  const chunks = [];
  let loaded = 0;
  for (let part = await reader.read(); !part.done; part = await reader.read()) {
    chunks.push(part.value);
    loaded += part.value.length;
    if (total) onProgress(Math.min(0.99, loaded / total));
  }
  onProgress(1);
  const link = Object.assign(document.createElement("a"), { href: URL.createObjectURL(new Blob(chunks)), download: url.split("/").pop() });
  link.click();
  setTimeout(() => URL.revokeObjectURL(link.href), 1000);
}

document.querySelectorAll(".download").forEach((button) => {
  const file = button.closest(".file");
  const [name, meta] = [".file__name", ".file__meta"].map((s) => file.querySelector(s));
  const idle = meta.textContent;
  const [shaft, head] = button.querySelectorAll(".download__shaft, .download__head");
  const [icon, tint, track, bar] = [".download__icon", ".download__tint", ".download__track", ".download__bar"].map((s) => button.querySelector(s));
  const [ink, success, successText, onSuccess] = ["--ink", "--success", "--success-text", "--on-success"].map((v) => getComputedStyle(button).getPropertyValue(v).trim());

  // 押す → 矢印が下りてトレイに収まる(軸は上から縮む)
  const drop = gsap
    .timeline({ paused: true })
    .to(button, { scale: 0.92, duration: 0.08, ease: "power2.out" }, 0)
    .to(button, { scale: 1, duration: 0.4, ease: "back.out(3)" }, 0.08)
    .fromTo([shaft, head], { y: 0 }, { y: 3, duration: 0.35, ease: "power2.in" }, 0)
    .fromTo(shaft, { drawSVG: "0% 100%" }, { drawSVG: "45% 100%", duration: 0.35, ease: "power2.in" }, 0)
    .to(track, { opacity: 1, duration: 0.2 }, 0.15);
  // 待つあいだは、トレイの中で小さく弾む
  const bob = gsap.to([shaft, head], { y: 4.2, duration: 0.28, ease: "sine.inOut", yoyo: true, repeat: -1, paused: true });
  // 終わり: 軸を下へ抜いて消し、矢じりをチェックに変えながら持ち上げて、ボタンを緑で塗る
  const done = gsap
    .timeline({ paused: true })
    .to(shaft, { drawSVG: "100% 100%", duration: 0.2, ease: "power2.in" }, 0)
    .set(shaft, { opacity: 0 }, 0.18) // 丸い線端の点が残るので、抜ききる前に消す
    .to(head, { morphSVG: CHECK, y: 0, duration: 0.5, ease: "back.out(1.8)" }, 0.08)
    .to(icon, { color: onSuccess, duration: 0.25 }, 0.08)
    .to(tint, { opacity: 1, duration: 0.3 }, 0.08)
    .to(bar, { stroke: success, duration: 0.25 }, 0.08)
    .to(meta, { color: successText, duration: 0.25 }, 0.08);
  // 矢印に戻す(軸は上から伸び直す)
  const reset = gsap
    .timeline({ paused: true })
    .to(head, { morphSVG: head.getAttribute("d"), y: 0, duration: 0.4, ease: "power2.inOut" }, 0)
    .to(icon, { color: ink, duration: 0.25 }, 0)
    .to([tint, track, bar], { opacity: 0, duration: 0.3 }, 0)
    .set(shaft, { y: 0, drawSVG: "0% 0%", opacity: 1 }, 0.1)
    .to(shaft, { drawSVG: "0% 100%", duration: 0.35, ease: "power2.out" }, 0.1);

  let said = 0; // 読み上げに知らせた段(25% ごとに 1 段)
  const progress = (p) => {
    gsap.to(bar, { drawSVG: `${p * 100}%`, duration: 0.2 / SPEED, ease: "none", overwrite: "auto" });
    meta.textContent = `ダウンロード中…${Math.round(p * 100)}%`;
    const step = Math.floor(p * 4);
    if (step > said && step < 4) status.textContent = `${(said = step) * 25}%`;
  };

  button.addEventListener("click", async () => {
    if (button.getAttribute("aria-disabled") === "true") return; // 落としている間は受けない
    button.setAttribute("aria-disabled", "true");
    said = 0;
    status.textContent = `${name.textContent}をダウンロードしています`;
    gsap.set(bar, { drawSVG: "0%", opacity: 1, stroke: ink });
    progress(0);
    // 落とすのと矢印が下りるのは同時に始め、下りきってから弾ませる
    const job = download(button.dataset.href, progress).then(() => true, () => false);
    await drop.timeScale(SPEED).restart();
    if (!reduceMotion) bob.restart();
    const ok = await job;
    bob.pause();
    if (ok) done.timeScale(SPEED).invalidate().restart();
    meta.textContent = ok ? "ダウンロードしました" : "ダウンロードできませんでした";
    status.textContent = `${name.textContent}${ok ? "のダウンロードが完了しました" : "をダウンロードできませんでした"}`;
    await new Promise((resolve) => setTimeout(resolve, HOLD * 1000));
    await reset.timeScale(SPEED).invalidate().restart();
    gsap.set(meta, { clearProps: "color" });
    meta.textContent = idle;
    button.removeAttribute("aria-disabled");
  });
});

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

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