デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
電池の中の塗りは、四角形の width を残量から書き換えているだけです。clipPath もマスクも使わないので、同じページにいくつ置いても id がぶつからず、枠の形を変えてもそのまま動きます。充電をつなぐと、雷の印が少し傾いた向きから中へ跳ねて現れ(0.5 秒、back.out(2.4))、続けて塗りが左から満ちます。
色は、20% までがエラーの朱、40% までが橙、60% までが黄土、それ以上が成功の緑です。しきい値の手前までは同じ色を保ち、しきい値を越えてからの 10% だけで次の色へ移します。段ごとにいきなり切り替えると数字と色がずれて見え、0% から 100% まで通して補間すると、どの残量も中間色になって段が読めません。満ちるのは 2.3 秒で、満ちきったところで電池が 7% ふくらんで戻ります。
塗りの幅は、書くたびに画面の整数 px へ丸めます。丸めないと、まっすぐな縁が半端な位置に来て、描き直しのたびに端の 1 画素が変わります。ふくらみは、外側の箱ではなく SVG の中のグループの transform で拡大します。箱ごと拡大すると、中の線が拡大の倍率につられて太さを変えます。電池の図は右に端子の出っ張りがあるので、図全体で中央にそろえると本体が左に寄って見えます。viewBox の始まりを 2 だけ左へずらし、本体が枠の中央に来るようにしています。
残量の値は、実際には端末やアプリから届くものです。残量と充電中かどうかを渡す口を 1 つ用意しておくと、充電の開始でも中断でも、そのときの値から続けて動きます。状態の文は読み上げに届く領域に書いてあるので、目で見ていない人にも完了が伝わります。しきい値と色、満ちる時間を変えれば、通信量や容量の使用率を見せる表示にも同じ形で使えます。
コード
<!-- 残量の表示。状態の文は data-* に持ち、読み上げには role="status" で届ける -->
<figure class="battery" data-idle="電源につないでください" data-charging="充電しています" data-full="充電が完了しました">
<!-- viewBox の始まりを -2 にして、端子の出っ張りの分だけ図を右へずらす(電池の本体が枠の中央に来る) -->
<svg class="battery__icon" viewBox="-2 0 24 24" role="img" aria-labelledby="battery-level battery-state">
<g class="battery__body">
<!-- 塗りは幅だけを書き換える。clipPath もマスクも要らない -->
<rect class="battery__fill" x="3.5" y="8.5" width="0" height="7" rx="1" />
<path class="battery__bolt" d="M10.33 8.7L7.03 12.66L10 12.66L9.67 15.3L12.97 11.34L10 11.34Z" />
<rect class="battery__frame" x="1.5" y="6.5" width="17" height="11" rx="2.5" />
<path class="battery__frame" d="M21 10.5V13.5" />
</g>
</svg>
<p class="battery__level" id="battery-level">15%</p>
<p class="battery__state" id="battery-state" role="status">電源につないでください</p>
<!-- 実際の案件では、端末やアプリから届く値で setBattery を呼ぶ。このボタンは動きを試すためのもの -->
<button class="battery__plug" type="button" aria-pressed="false">充電をつなぐ</button>
</figure>
.battery {
--ground: #111111; /* 地の色(Night) */
--ink: #eeece6; /* 枠と雷の色 */
--mute: #898989; /* 状態の文 */
--low: #c4553d; /* 20% まで(エラーの色) */
--low-mid: #c07a3a; /* 40% まで */
--mid: #a98b2e; /* 60% まで */
--high: #4f8a64; /* それ以上(成功の色) */
--size: 96px; /* アイコンの大きさ */
display: grid;
justify-items: center;
gap: 12px;
margin: 0;
padding: 48px 24px;
border-radius: 4px;
background-color: var(--ground);
color: var(--ink);
text-align: center;
}
/* 24 のグリッドのうち電池は縦 13 ぶんなので、上下の空きを負の余白で詰める */
.battery__icon {
width: var(--size);
height: var(--size);
margin: calc(var(--size) * -0.23) 0;
overflow: visible;
}
.battery__bolt {
fill: var(--ink);
}
/* 24 グリッドで線幅 2(24px で描けば 2px)。線端と角は丸 */
.battery__frame {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.battery__level {
margin: 0;
font-size: 32px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.battery__state {
margin: 0;
color: var(--mute);
font-size: 15px;
line-height: 1.6;
letter-spacing: 0.04em;
}
/* 副ボタン(1px の線の丸帯) */
.battery__plug {
height: 48px;
padding: 0 32px;
border: 1px solid var(--ink);
border-radius: 999px;
background: none;
color: inherit;
font: inherit; font-weight: 600; font-size: 14px; line-height: 1;
letter-spacing: 0.04em;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.battery__plug:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
const START = 0.15; // つないでいないときの残量
const FILL_W = 13; // 枠の内側の塗りの幅(24 のグリッド)
const FILL_DUR = 2.3; // 空から満ちるまでの時間
// [残量, 色の番号]。しきい値の手前まで同じ色を保ち、越えてからの 10% で次の色へ移る
const STOPS = [[0, 0], [0.2, 0], [0.3, 1], [0.4, 1], [0.5, 2], [0.6, 2], [0.7, 3], [1, 3]];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => Math.round(v * 1000) / 1000;
document.querySelectorAll(".battery").forEach((box) => {
const body = box.querySelector(".battery__body");
const fill = box.querySelector(".battery__fill");
const bolt = box.querySelector(".battery__bolt");
const level = box.querySelector(".battery__level");
const state = box.querySelector(".battery__state");
const button = box.querySelector(".battery__plug");
// 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
const css = getComputedStyle(box);
const colors = ["--low", "--low-mid", "--mid", "--high"].map((name) => css.getPropertyValue(name).trim());
const ramp = (v) => {
let i = 1;
while (i < STOPS.length - 1 && STOPS[i][0] < v) i++;
const [at, from] = STOPS[i - 1];
const [to, next] = STOPS[i];
return gsap.utils.interpolate(colors[from], colors[next], to === at ? 1 : gsap.utils.clamp(0, 1, (v - at) / (to - at)));
};
// 見た目はこの 4 つの値から描く(残量・雷の大きさ・雷の傾き・電池のふくらみ)
const s = { level: START, bolt: 0, tilt: -16, pop: 1 };
const draw = () => {
fill.setAttribute("width", String(f(Math.max(0, s.level * FILL_W))));
fill.setAttribute("fill", ramp(s.level));
body.setAttribute("transform", `translate(12 12) scale(${f(s.pop)}) translate(-12 -12)`);
bolt.setAttribute("transform", `translate(10 12) rotate(${f(s.tilt)}) scale(${f(s.bolt)}) translate(-10 -12)`);
level.textContent = `${Math.round(s.level * 100)}%`;
};
draw();
let anim = null;
// 残量(0〜1)と充電中かどうかを渡すと、そこまで動かす。実際の案件では、端末やアプリの値でこれを呼ぶ
const setBattery = (to, charging) => {
const full = charging && to >= 1;
state.textContent = box.dataset[charging ? "charging" : "idle"];
anim?.kill(); // 途中で変わっても、今の値から次の動きへつなぐ
anim = gsap.timeline({ onUpdate: draw });
anim
.to(s, { bolt: charging ? 1 : 0, tilt: charging ? 0 : 12, duration: charging ? 0.5 : 0.24, ease: charging ? "back.out(2.4)" : "power2.in" }, 0)
.to(s, { level: to, duration: FILL_DUR * Math.abs(to - s.level), ease: charging ? "power1.inOut" : "power2.in" }, 0.06);
// 満ちきったところで、電池が一度ふくらみ、完了を知らせる
if (full) anim.to(s, { pop: 1.07, duration: 0.14, ease: "power2.out", onStart: () => (state.textContent = box.dataset.full) }).to(s, { pop: 1, duration: 0.44, ease: "power2.out" });
else anim.to(s, { pop: 1, duration: 0.2, ease: "power2.out" }, 0);
// 動きを減らす設定では、満ちるのを待たずに結果だけ見せる
if (reduceMotion) anim.progress(1);
};
button.addEventListener("click", () => {
const charging = button.getAttribute("aria-pressed") !== "true";
button.setAttribute("aria-pressed", String(charging));
setBattery(charging ? 1 : START, charging);
});
});