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

Wi-Fi のアイコンの弧が内側から順に立ち、つながると点が光る

デモ

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

仕様

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

しくみと調整

点と 3 本の弧は、形を変えずに濃さと大きさだけを動かします。探しているあいだは薄いまま少し縮み、1 秒に 1 回ゆっくり点滅します。つながると、下の点からいちばん外の弧まで 0.13 秒ずつずれて濃さと大きさが戻り、内側から順に立ち上がって見えます。

大きさは、弧の中心にあたる下の点を原点にして変えます。休んでいるあいだを 0.9 倍にしておくと、つながったときの back.out(2.2) の行き過ぎが、点から押し出されるような手応えになります。立ち終わると点から輪が広がり、細く薄くなって消えます。輪は広がりきったところで濃さが 0 に戻るので、消し忘れが残りません。

つながらなかったときは、弧を消さずに薄いままにして × を重ねます。消してしまうと「電波が届いていない」に見えて、伝わる意味が変わります。× は、地の色の太い線を下に敷いてから色の線を描くので、薄い弧の上でも形が読めます。

状態は、探している・つながった・つながらない、の 3 つだけです。接続の処理から状態を渡す口を 1 つ用意しておくと、途中で切り替わってもそのときの値から続けて動きます。状態の文は読み上げに届く領域に書いてあるので、結果が目で見ていない人にも伝わります。弧の数と半径、ずらす時間を変えれば、電波の強さの表示や、同期の進み具合にも同じ形で使えます。

コード

HTML
<!-- 状態の文は data-* に持ち、今の状態は role="status" で読み上げにも届ける -->
<figure class="wifi" data-searching="接続先を探しています" data-connected="接続しました" data-failed="接続できませんでした">
  <svg class="wifi__icon" viewBox="0 0 24 24" role="img" aria-labelledby="wifi-name wifi-state">
    <g class="wifi__body">
      <circle class="wifi__glow" cx="12" cy="18.5" r="1.6" />
      <circle class="wifi__part wifi__dot" cx="12" cy="18.5" r="1.5" />
      <path class="wifi__part wifi__arc" d="M8.284 15.154A5 5 0 0 1 15.716 15.154" />
      <path class="wifi__part wifi__arc" d="M4.94 12.143A9.5 9.5 0 0 1 19.06 12.143" />
      <path class="wifi__part wifi__arc" d="M1.596 9.132A14 14 0 0 1 22.404 9.132" />
      <!-- × は、地の色の太い線を下に敷いて弧の上でも読めるようにする -->
      <g class="wifi__cross">
        <path class="wifi__cross-back" d="M8.9 9.4L15.1 15.6M15.1 9.4L8.9 15.6" />
        <path class="wifi__cross-line" d="M8.9 9.4L15.1 15.6M15.1 9.4L8.9 15.6" />
      </g>
    </g>
  </svg>
  <figcaption class="wifi__name" id="wifi-name">Studio-2F</figcaption>
  <p class="wifi__state" id="wifi-state" role="status">接続先を探しています</p>
  <!-- 実際の案件では、接続の処理から setWifi を呼ぶ。この 2 つは動きを試すためのボタン -->
  <p class="wifi__actions">
    <button class="wifi__try" type="button">接続する</button>
    <button class="wifi__miss" type="button">つながらない場合</button>
  </p>
</figure>

CSS
.wifi {
  --ink: #eeece6;    /* 弧と点の色 */
  --ground: #111111; /* 地の色(× の下敷き) */
  --mute: #898989;   /* 状態の文 */
  --error: #e07a62;  /* つながらないときの × */
  --size: 96px;      /* アイコンの大きさ */
  display: grid;
  justify-items: center;
  gap: 8px;
  margin: 0;
  color: var(--ink);
  text-align: center;
}

/* 24 のグリッドのうち図形は縦 16 ぶんなので、上下の空きを負の余白で詰める */
.wifi__icon {
  width: var(--size);
  height: var(--size);
  margin: calc(var(--size) * -0.16) 0;
  overflow: visible;
}

.wifi__dot {
  fill: currentColor;
}

/* 24 グリッドで線幅 2(24px で描けば 2px)。線端は丸 */
.wifi__arc {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

/* つながった合図に、点から広がって消える輪 */
.wifi__glow {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.4;
  opacity: 0;
}

.wifi__cross-back {
  fill: none;
  stroke: var(--ground);
  stroke-width: 5.4;
  stroke-linecap: round;
}

.wifi__cross-line {
  fill: none;
  stroke: var(--error);
  stroke-width: 2.6;
  stroke-linecap: round;
}

.wifi__name {
  margin: 8px 0 0;
  font-size: 17px;
  font-weight: 700;
  letter-spacing: -0.01em;
}

.wifi__state {
  margin: 0;
  color: var(--mute);
  font-size: 15px;
  line-height: 1.6;
  letter-spacing: 0.04em;
}

/* 副ボタン(1px の線の丸帯) */
.wifi__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  margin: 8px 0 0;
}

.wifi__actions button {
  height: 48px;
  padding: 0 24px;
  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;
}

.wifi__actions button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

JavaScript
import { gsap } from "gsap";

const DIM = 0.28; // 探しているあいだの濃さ
const PEAK = 0.5; // 点滅で明るくなるところ
const SMALL = 0.9; // 探しているあいだの大きさ(点を中心に少し縮む)
const ORIGIN = [12, 18.5]; // 弧の中心(下の点の位置)
const STEP = 0.13; // 内側から順にずらす時間
const PULSE = 0.5; // 点滅の片道(目にやさしい 1 Hz)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => Math.round(v * 1000) / 1000;

document.querySelectorAll(".wifi").forEach((box) => {
  const shapes = [...box.querySelectorAll(".wifi__part")]; // 点 → 内の弧 → 外の弧の順
  const body = box.querySelector(".wifi__body");
  const glow = box.querySelector(".wifi__glow");
  const cross = box.querySelector(".wifi__cross");
  const state = box.querySelector(".wifi__state");

  // 点と弧ごとの値(濃さ・大きさ)と、全体の値
  const parts = shapes.map(() => ({ lit: DIM, rise: SMALL }));
  const view = { glow: 0, cross: 0, pop: 1 };
  const spot = (s) => `translate(${ORIGIN[0]} ${ORIGIN[1]}) scale(${f(s)}) translate(${-ORIGIN[0]} ${-ORIGIN[1]})`;
  const draw = () => {
    parts.forEach((part, i) => {
      shapes[i].style.opacity = String(f(part.lit));
      shapes[i].setAttribute("transform", spot(part.rise));
    });
    // 光の輪は、広がりながら細く薄くなって消える(0 と 1 のどちらでも見えない)
    glow.style.opacity = String(f(Math.sin(Math.PI * view.glow) * 0.6));
    glow.setAttribute("r", String(f(1.6 + view.glow * 5.2)));
    glow.style.strokeWidth = String(f(1.4 - view.glow * 0.8));
    cross.setAttribute("transform", `translate(12 12.5) scale(${f(view.cross)}) translate(-12 -12.5)`);
    body.setAttribute("transform", `translate(12 12) scale(${f(view.pop)}) translate(-12 -12)`);
  };
  draw();

  let anim = null;
  let blink = null;
  // 状態("searching" / "connected" / "failed")を渡すと、そこへ動かす。案件では接続の処理から呼ぶ
  const setWifi = (status) => {
    anim?.kill();
    blink?.kill();
    blink = null;
    state.textContent = box.dataset.searching;
    anim = gsap.timeline({ onUpdate: draw });
    if (status === "connected") {
      // いったん薄く縮めてから、内側から順に濃さと大きさを戻す
      anim
        .to(view, { cross: 0, pop: 1, duration: 0.2, ease: "power2.in" }, 0)
        .to(parts, { lit: DIM, rise: SMALL, duration: 0.16, ease: "power2.out" }, 0)
        .to(parts, { lit: 1, duration: 0.26, stagger: STEP, ease: "power2.out" }, 0.22)
        .to(parts, { rise: 1, duration: 0.44, stagger: STEP, ease: "back.out(2.2)" }, 0.22)
        .fromTo(view, { glow: 0 }, { glow: 1, duration: 0.6, ease: "power2.out" }, 0.72)
        .to(view, { pop: 1.06, duration: 0.14, ease: "power2.out" }, 0.82)
        .to(view, { pop: 1, duration: 0.44, ease: "power2.out" }, 0.96)
        .add(() => (state.textContent = box.dataset.connected), 0.87);
    } else {
      // 弧は消さずに薄く縮め、つながらないときだけ × を重ねる
      anim
        .to(parts, { lit: DIM, rise: SMALL, duration: 0.26, ease: "power2.out" }, 0)
        .to(view, { pop: 1, duration: 0.2, ease: "power2.out" }, 0)
        .to(view, { cross: status === "failed" ? 1 : 0, duration: 0.34, ease: "back.out(2)" }, 0.12);
      if (status === "failed") anim.add(() => (state.textContent = box.dataset.failed), 0.12);
      // 探している間は、薄いまま点滅させ続ける
      else anim.add(() => (blink = gsap.to(parts, { lit: PEAK, duration: PULSE, repeat: -1, yoyo: true, ease: "sine.inOut", onUpdate: draw })));
    }
    // 動きを減らす設定では、点滅させず、結果だけをすぐ見せる
    if (reduceMotion) {
      anim.progress(1);
      blink?.kill();
    }
  };

  box.querySelector(".wifi__try").addEventListener("click", () => setWifi("connected"));
  box.querySelector(".wifi__miss").addEventListener("click", () => setWifi("failed"));
  setWifi("searching");
});

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

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