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

ダウンロードボタンを押すと進捗のバーになり、満ちると完了に変わる

デモ

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

仕様

用途
ボタン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

押すと文字を消し、ボタンの右端を止めたまま左へ伸ばして、高さを少し下げます。地は白い溝に変わり、そのまま進捗のバーになります。中では墨色の塗りが左から満ち、中央の % の数字が進みます。満ちきると、バーは元の幅へ縮みながら成功の緑に変わり、チェックと「完了」が出ます。

形を変えるのは 0.5 秒の power3.inOut で、幅と高さをそのまま補間します。角を 999px の丸にしておくと、どの幅と高さでも両端が丸いままです。% の数字は同じものを 2 枚重ね、白い方を塗りと同じ幅だけ clip-path で見せます。塗りの上では白、溝の上では墨色になり、境目では 1 字の中が 2 色に割れて見えます。

文字を autoAlpha で消すと、visibility まで隠れて、ボタンの名前が読み上げから消えます。透明度だけで消します。見た目の % は細かく変わるので、読み上げには role="status" の別の要素へ、始まり・25% ごと・完了だけを書きます。落としている間は aria-disabled を付けて、二度押しを受けません。

進み具合は、fetch で読みながら数えたバイト数から出します。応答に Content-Length が無いと、読み終えるまで塗りが伸びません。バーはボタンの左に置いた知らせ(ファイル名と大きさ)の上へ伸びるので、伸び始めに知らせを消し、縮んだあとに「ダウンロードしました」に替えて出します。

バーの幅と高さ、完了を見せておく時間(ここでは 2 秒)、完了の色で印象が変わります。輪で進み具合を見せるアイコンのボタンより数字が大きく読めるので、大きなファイルや、ボタンの横に場所のある所に向いています。資料・素材集・書き出しのダウンロードのボタンに。

コード

HTML
<div class="download-row">
  <p class="download-row__note" id="download-note">ブランド素材一式.zip・24.6MB</p>
  <!-- data-href が落とすファイル。押すとボタンが左へ伸びて、そのまま進捗のバーになる -->
  <button class="download-bar" type="button" data-href="/downloads/brand-assets.zip" aria-describedby="download-note">
    <span class="download-bar__fill" aria-hidden="true"></span>
    <span class="download-bar__label">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3" /></svg>
      ダウンロード
    </span>
    <!-- 同じ数字を 2 枚重ね、白い方を塗りと同じ幅だけ見せる -->
    <span class="download-bar__pct" aria-hidden="true">0%</span>
    <span class="download-bar__pct download-bar__pct--on" aria-hidden="true">0%</span>
    <span class="download-bar__done" aria-hidden="true">
      <svg viewBox="0 0 24 24" focusable="false"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>
      完了
    </span>
  </button>
  <!-- 読み上げへの知らせ。見た目の % は細かく変わるので、始まり・25% ごと・完了だけを書く -->
  <p class="download-row__status" role="status"></p>
</div>

CSS
.download-row {
  --ink: #262626;                 /* ボタンと塗りの色 */
  --track: #ffffff;               /* 進捗のバーの溝 */
  --line: rgba(38, 38, 38, 0.12); /* 溝のふち */
  --mute: #6b6b6b;                /* ボタンの左の知らせ */
  --success: #4f8a64;             /* 完了のボタン */
  --success-text: #3f7352;        /* 完了の知らせ(小さな文字でも読める濃さ) */
  --bar-height: 40px;             /* バーになったときの高さ */
  position: relative;
  display: flex;
  align-items: center;
  justify-content: flex-end; /* ボタンを右端にそろえ、バーは左へ伸ばす */
  max-width: 560px;
  min-height: 48px;
  color: var(--ink);
}

.download-row__note {
  position: absolute;
  left: 0;
  right: 200px; /* ボタンの幅と間の分。狭い画面では、ボタンに重ならずに折り返す */
  top: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  margin: 0;
  color: var(--mute);
  font-size: 13px;
  line-height: 1.5;
  letter-spacing: 0.04em;
  word-break: auto-phrase; /* 折り返すなら文節で */
}

.download-row__note.is-done { color: var(--success-text); font-weight: 600; }

.download-bar {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  height: 48px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px; /* どの幅と高さでも、両端が丸いまま */
  overflow: hidden;
  background-color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--line);
  color: #ffffff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
}

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

.download-bar__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background-color: var(--ink); }

.download-bar__label,
.download-bar__pct,
.download-bar__done {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.download-bar__label { position: relative; }

.download-bar__pct,
.download-bar__done { position: absolute; inset: 0; opacity: 0; font-weight: 700; }

.download-bar__pct { color: var(--ink); font-variant-numeric: tabular-nums; }
.download-bar__pct--on { color: #ffffff; clip-path: inset(0 100% 0 0); }

.download-bar svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }

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

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const HOLD = 2; // 完了や失敗の知らせを見せておく秒数
const pause = (seconds) => new Promise((resolve) => gsap.delayedCall(seconds, resolve));

// 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));
  }
  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-row").forEach((row) => {
  const button = row.querySelector(".download-bar");
  const [note, status] = [".download-row__note", ".download-row__status"].map((s) => row.querySelector(s));
  const [fill, label, pct, pctOn, done] = ["fill", "label", "pct", "pct--on", "done"].map((s) => button.querySelector(`.download-bar__${s}`));
  const [track, ink, success] = ["--track", "--ink", "--success"].map((v) => getComputedStyle(row).getPropertyValue(v).trim());
  const barHeight = parseFloat(getComputedStyle(row).getPropertyValue("--bar-height"));
  const idleNote = note.textContent;
  const state = { p: 0 };
  let said = 0; // 読み上げに知らせた段(25% ごとに 1 段)
  let busy = false;

  // 塗りの幅・数字・白い数字の切り抜きを、進み具合から描く
  const render = () => {
    fill.style.width = `${state.p * 100}%`;
    pctOn.style.clipPath = `inset(0 ${(1 - state.p) * 100}% 0 0)`;
    pct.textContent = pctOn.textContent = `${Math.round(state.p * 100)}%`;
  };
  const show = (p) => {
    const step = Math.floor(p * 4);
    if (step > said && step < 4) status.textContent = `${(said = step) * 25}%`;
    return gsap.to(state, { p, duration: 0.25 / SPEED, ease: "none", overwrite: true, onUpdate: render });
  };

  button.addEventListener("click", async () => {
    if (busy) return; // 落としている間は受けない
    busy = true;
    said = 0;
    button.setAttribute("aria-disabled", "true");
    status.textContent = "ダウンロードしています";
    const [width, height] = [button.offsetWidth, button.offsetHeight];
    state.p = 0;
    render();
    const job = download(button.dataset.href, show).then(() => true, () => false);
    // ボタンの形のまま、左へ伸びて進捗のバーになる。文字は透明度だけで消す(名前を読み上げから消さない)
    await gsap
      .timeline()
      .to([label, note], { opacity: 0, duration: 0.15, ease: "none" }, 0)
      .fromTo(button, { width, height }, { width: row.clientWidth, height: barHeight, backgroundColor: track, duration: 0.5, ease: "power3.inOut" }, 0)
      .to([pct, pctOn], { opacity: 1, duration: 0.2, ease: "none" }, 0.35)
      .timeScale(SPEED);
    const ok = await job;
    if (ok) await show(1);
    // 元の幅へ縮みながら、完了なら緑に塗ってチェックと「完了」を出す
    note.textContent = ok ? "ダウンロードしました" : "ダウンロードに失敗しました";
    note.classList.toggle("is-done", ok);
    status.textContent = ok ? "ダウンロードが完了しました" : "ダウンロードできませんでした";
    const end = gsap
      .timeline()
      .to([pct, pctOn], { opacity: 0, duration: 0.15, ease: "none" }, 0)
      .to(button, { width, height, duration: 0.5, ease: "power3.inOut" }, 0)
      .to(note, { opacity: 1, duration: 0.25, ease: "none" }, 0.35);
    if (ok) end.to(fill, { backgroundColor: success, duration: 0.3, ease: "none" }, 0.05).to(done, { opacity: 1, duration: 0.3, ease: "none" }, 0.3);
    else end.to(button, { backgroundColor: ink, duration: 0.3, ease: "none" }, 0).to(fill, { opacity: 0, duration: 0.3, ease: "none" }, 0).to(label, { opacity: 1, duration: 0.25 }, 0.3);
    await end.timeScale(SPEED);
    await pause(HOLD);
    // 元のボタンに戻す。CSS の値と同じところまで動かしてから、書き込んだ style を消す
    await gsap
      .timeline()
      .to([done, note], { opacity: 0, duration: 0.2, ease: "none" }, 0)
      .to(fill, { backgroundColor: ink, duration: 0.3, ease: "none" }, 0)
      .to(label, { opacity: 1, duration: 0.25, ease: "none" }, 0.25)
      .timeScale(SPEED);
    gsap.set([button, fill, label, pct, pctOn, done, note], { clearProps: "all" });
    note.textContent = idleNote;
    note.classList.remove("is-done");
    gsap.from(note, { opacity: 0, duration: 0.25 / SPEED });
    button.removeAttribute("aria-disabled");
    busy = false;
  });
});

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

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