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

電池のアイコンが左から満ちて色が移り、雷の印が跳ねる

デモ

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

仕様

用途
アイコン
動作
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 つ用意しておくと、充電の開始でも中断でも、そのときの値から続けて動きます。状態の文は読み上げに届く領域に書いてあるので、目で見ていない人にも完了が伝わります。しきい値と色、満ちる時間を変えれば、通信量や容量の使用率を見せる表示にも同じ形で使えます。

コード

HTML
<!-- 残量の表示。状態の文は 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>

CSS
.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;
}

JavaScript
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);
  });
});

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

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