デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
点と 3 本の弧は、形を変えずに濃さと大きさだけを動かします。探しているあいだは薄いまま少し縮み、1 秒に 1 回ゆっくり点滅します。つながると、下の点からいちばん外の弧まで 0.13 秒ずつずれて濃さと大きさが戻り、内側から順に立ち上がって見えます。
大きさは、弧の中心にあたる下の点を原点にして変えます。休んでいるあいだを 0.9 倍にしておくと、つながったときの back.out(2.2) の行き過ぎが、点から押し出されるような手応えになります。立ち終わると点から輪が広がり、細く薄くなって消えます。輪は広がりきったところで濃さが 0 に戻るので、消し忘れが残りません。
つながらなかったときは、弧を消さずに薄いままにして × を重ねます。消してしまうと「電波が届いていない」に見えて、伝わる意味が変わります。× は、地の色の太い線を下に敷いてから色の線を描くので、薄い弧の上でも形が読めます。
状態は、探している・つながった・つながらない、の 3 つだけです。接続の処理から状態を渡す口を 1 つ用意しておくと、途中で切り替わってもそのときの値から続けて動きます。状態の文は読み上げに届く領域に書いてあるので、結果が目で見ていない人にも伝わります。弧の数と半径、ずらす時間を変えれば、電波の強さの表示や、同期の進み具合にも同じ形で使えます。
コード
<!-- 状態の文は 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>
.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;
}
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");
});