デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 ÷ 描く大きさ」にします。ファイル一覧・添付ファイル・書き出しのボタンに向いています。
コード
<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>
.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; }
}
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");
});
});