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

音量のアイコンの波が順に出て、ミュートで × に変わる

デモ

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

仕様

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

しくみと調整

スピーカーの箱と円錐、音の波 3 本(円錐の先を中心にした半径 4・7・10 の弧)、× を線で描きます。音を出すと、× が回りながら縮んで消え、波が内側から 1 本ずつ広がります。消すときは同じタイムラインを逆に再生するので、波が外側から順に縮み、× が回って戻ります。押すたびに画面の中央へ大きなアイコンと音量の帯が浮かび、「ミュート中」の札も一緒に出入りします。

× は 90° 回しながら 0.22 秒(power2.in)で消します。波は DrawSVGPlugin で弧の真ん中から両端へ伸ばし(0.32 秒、power2.out)、1 本ごとに 0.09 秒ずらします。ずらしが短すぎると 3 本が一度に出て、音が広がっていく感じが消えます。中央の表示は 0.3 秒で下から浮かべ、0.9 秒保ってから消します。

長さ 0 の弧でも、丸い線端のせいで点が 1 つ描かれます。伸び始めるまでは opacity を 0 にして隠します。× は SVG の中の図形なので、回転の軸は svgOrigin で × の中心に置きます。

見た目の切り替えは、ボタンのクリックではなく video の volumechange で行います。音量のつまみを 0 まで下げて消音したときも、アイコンと札がそろいます。状態は aria-pressed で伝え、ボタンの名前は「ミュート」のまま変えません。自動で流れる動画は止められるよう、再生と一時停止のボタンも置きます。動きを減らす設定では、動画を最初から止め、アイコンの切り替えもほぼ一瞬にします。

波の数と半径、ずらす時間、中央の表示を保つ時間で印象が変わります。動画や音声のプレーヤー、自動再生で消音している動画の解除、通話のマイクの切り替えに向いています。

コード

HTML
<!-- 動画のプレーヤー。操作は video の muted・volume・再生を書き換え、見た目は video のイベントでそろえる -->
<div class="player">
  <video class="player__video" src="/video/golden-hour.mp4" muted autoplay loop playsinline></video>

  <!-- 消音中の札。状態は音量のボタンが伝えるので、読み上げからは外す -->
  <p class="player__muted" aria-hidden="true">
    <svg viewBox="0 0 24 24" focusable="false">
      <path d="M11 5 6.5 9H4a1.5 1.5 0 0 0-1.5 1.5v3A1.5 1.5 0 0 0 4 15h2.5l4.5 4z" />
      <path d="M15.5 9.5 20.5 14.5M20.5 9.5 15.5 14.5" />
    </svg>
    ミュート中
  </p>

  <!-- 押したとき中央に大きく出す表示。アイコンは下のボタンと同じもの -->
  <div class="player__hud" aria-hidden="true">
    <svg class="volume-icon" viewBox="0 0 24 24" focusable="false">
      <path d="M11 5 6.5 9H4a1.5 1.5 0 0 0-1.5 1.5v3A1.5 1.5 0 0 0 4 15h2.5l4.5 4z" />
      <path class="volume-icon__wave" d="M14.15 9.54A4 4 0 0 1 14.15 14.46" />
      <path class="volume-icon__wave" d="M15.95 7.05A7 7 0 0 1 15.95 16.95" />
      <path class="volume-icon__wave" d="M17.69 4.57A10 10 0 0 1 17.69 19.43" />
      <path class="volume-icon__cross" d="M15.5 9.5 20.5 14.5M20.5 9.5 15.5 14.5" />
    </svg>
    <span class="player__hud-level"></span>
  </div>

  <div class="player__bar">
    <!-- 状態は aria-pressed で伝え、名前は変えない(押されている = 止まっている・消音中) -->
    <button class="player__play" type="button" aria-pressed="false" aria-label="一時停止">
      <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path class="player__pause-icon" d="M7 5.5h2.5v13H7zM14.5 5.5H17v13h-2.5z" />
        <path class="player__play-icon" d="M8 5.5v13l10.5-6.5z" />
      </svg>
    </button>
    <button class="player__volume" type="button" aria-pressed="true" aria-label="ミュート">
      <svg class="volume-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M11 5 6.5 9H4a1.5 1.5 0 0 0-1.5 1.5v3A1.5 1.5 0 0 0 4 15h2.5l4.5 4z" />
        <path class="volume-icon__wave" d="M14.15 9.54A4 4 0 0 1 14.15 14.46" />
        <path class="volume-icon__wave" d="M15.95 7.05A7 7 0 0 1 15.95 16.95" />
        <path class="volume-icon__wave" d="M17.69 4.57A10 10 0 0 1 17.69 19.43" />
        <path class="volume-icon__cross" d="M15.5 9.5 20.5 14.5M20.5 9.5 15.5 14.5" />
      </svg>
    </button>
    <!-- value が音を出したときの音量(0〜1)。消音中のつまみは 0 に見せる -->
    <input class="player__slider" type="range" min="0" max="1" step="0.01" value="0.7" aria-label="音量">
  </div>
</div>

CSS
.player {
  --ink: #ffffff;                    /* 動画の上の文字と操作 */
  --track: rgba(255, 255, 255, 0.3); /* 音量の帯の地 */
  --line: rgba(255, 255, 255, 0.2);  /* 中央の表示の縁 */
  --level: 0;                        /* 見せている音量(0〜1)。script.js が書く */
  position: relative;
  max-width: 1040px;
  aspect-ratio: 16 / 9;
  container-type: size; /* 中央の表示の大きさを、プレーヤーの高さ(cqh)から決める */
  overflow: hidden;
  border-radius: 4px;
  background-color: #111111;
  color: var(--ink);
}

.player__video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }

/* 消音中の札 */
.player__muted {
  position: absolute; top: 24px; right: 32px;
  display: flex; align-items: center; gap: 8px;
  height: 32px; margin: 0; padding: 0 12px; border-radius: 999px;
  background-color: rgba(0, 0, 0, 0.45);
  font-size: 13px; font-weight: 600; line-height: 1; letter-spacing: 0.06em; white-space: nowrap;
}

.player__muted svg { flex: none; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }

/* 中央の表示。ふだんは隠し、押したときだけ出す(script.js)。上下の中央より少し上(高さの 44%)に置く */
.player__hud {
  position: absolute; inset: 0 0 12% 0; margin: auto;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9%;
  width: min(176px, 30cqh); height: min(176px, 30cqh);
  border-radius: 4px; background-color: rgba(0, 0, 0, 0.5); box-shadow: inset 0 0 0 1px var(--line);
  opacity: 0; visibility: hidden;
}

.player__hud .volume-icon { width: 55%; height: 55%; }
.player__hud-level { width: 55%; height: 6px; border-radius: 6px; background: linear-gradient(90deg, var(--ink) calc(var(--level) * 100%), var(--track) 0); }

.player__bar {
  position: absolute; inset: auto 0 0;
  display: flex; align-items: center; gap: 16px;
  padding: 56px 32px 24px;
  background: linear-gradient(0deg, rgba(0, 0, 0, 0.62), rgba(0, 0, 0, 0));
}

/* アイコンだけのボタン。重ねると淡い地を敷く */
.player__play,
.player__volume {
  position: relative; display: grid; place-items: center; flex: none;
  width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%;
  background-color: transparent; color: inherit; cursor: pointer;
  transition: background-color 0.15s;
}

.player__play:hover,
.player__volume:hover { background-color: rgba(255, 255, 255, 0.16); }
.player :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* 押した瞬間に明るくする地(script.js が --flash を 0 → 1 → 0 と動かす) */
.player__volume::before { content: ""; position: absolute; inset: 0; border-radius: inherit; background-color: rgba(255, 255, 255, 0.22); opacity: var(--flash, 0); }

.player__bar svg { position: relative; width: 24px; height: 24px; }
.player__play svg { fill: currentColor; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
.player__play[aria-pressed="false"] .player__play-icon,
.player__play[aria-pressed="true"] .player__pause-icon { display: none; }

/* 24 グリッドで線幅 2(24px で描けば 2px。中央の表示では同じ比率で太くなる)。波は伸び始めるまで隠す */
.volume-icon { overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.volume-icon__wave { opacity: 0; }

/* 音量のつまみ。塗りはつまみの中心まで */
.player__slider { width: 128px; height: 14px; margin: 0; background: none; cursor: pointer; -webkit-appearance: none; appearance: none; }
.player__slider::-webkit-slider-runnable-track { height: 4px; border-radius: 4px; background: linear-gradient(90deg, var(--ink) calc(7px + (100% - 14px) * var(--level)), var(--track) 0); }
.player__slider::-moz-range-track { height: 4px; border-radius: 4px; background: linear-gradient(90deg, var(--ink) calc(7px + (100% - 14px) * var(--level)), var(--track) 0); }
.player__slider::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -5px; border-radius: 50%; background-color: var(--ink); -webkit-appearance: none; appearance: none; }
.player__slider::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background-color: var(--ink); }

@media (max-width: 600px) {
  .player__muted { top: 12px; right: 12px; }
  .player__bar { gap: 8px; padding: 32px 12px 8px; }
}

@media (prefers-reduced-motion: reduce) {
  .player__play, .player__volume { transition: none; }
}

JavaScript
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";

gsap.registerPlugin(DrawSVGPlugin);

const STAGGER = 0.09; // 波を 1 本ずつずらす秒数
const HOLD = 0.9; // 中央の表示を出しておく秒数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".player").forEach((player) => {
  const video = player.querySelector(".player__video");
  const [play, volume] = player.querySelectorAll(".player__play, .player__volume");
  const [slider, hud, chip] = [".player__slider", ".player__hud", ".player__muted"].map((s) => player.querySelector(s));

  // 音を出す: × が回りながら消え、波が内側から 1 本ずつ広がる。消すのは同じタイムラインの reverse
  const sound = gsap.timeline({ paused: true });
  player.querySelectorAll(".volume-icon").forEach((icon) => {
    const cross = icon.querySelector(".volume-icon__cross");
    sound.fromTo(cross, { scale: 1, rotation: 0, opacity: 1, svgOrigin: "18 12" }, { scale: 0, rotation: 90, opacity: 0, svgOrigin: "18 12", duration: 0.22, ease: "power2.in" }, 0);
    icon.querySelectorAll(".volume-icon__wave").forEach((wave, i) => {
      const at = 0.12 + i * STAGGER;
      sound
        .fromTo(wave, { opacity: 0 }, { opacity: 1, duration: 0.04, ease: "none" }, at)
        // 弧の真ん中から両端へ伸ばす
        .fromTo(wave, { drawSVG: "50% 50%" }, { drawSVG: "0% 100%", duration: 0.32, ease: "power2.out" }, at);
    });
  });
  sound.fromTo(chip, { autoAlpha: 1, y: 0 }, { autoAlpha: 0, y: -8, duration: 0.25, ease: "power2.in" }, 0.05);
  // 動きを減らす設定では、ほぼ一瞬で切り替える
  if (reduceMotion) sound.timeScale(20);

  // 押すたびに中央の表示が下から浮かんで出て、少し保ってから消える
  const pop = gsap
    .timeline({ paused: true })
    .fromTo(hud, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.3, ease: "power3.out" })
    .to(hud, { autoAlpha: 0, y: -10, duration: 0.3, ease: "power1.in" }, 0.3 + HOLD);
  // 押した手応えは、ボタンの地を一瞬明るくして出す
  const flash = gsap
    .timeline({ paused: true })
    .fromTo(volume, { "--flash": 0 }, { "--flash": 1, duration: 0.06, ease: "none" })
    .to(volume, { "--flash": 0, duration: 0.3, ease: "power1.out" });

  // 見せている音量(つまみと中央の表示の帯)。消音中は 0 に見せる
  const shown = { level: 0 };
  const paint = () => {
    slider.value = shown.level;
    player.style.setProperty("--level", shown.level);
  };
  let glide = null;
  const showLevel = (level) => {
    glide?.kill();
    glide = gsap.to(shown, { level, duration: reduceMotion ? 0 : 0.45, ease: "power3.out", onUpdate: paint });
  };

  // 最初は動かさずに合わせる。音を出したときの音量は、HTML に書いたつまみの value
  video.volume = Number(slider.value);
  let muted = video.muted;
  sound.progress(muted ? 0 : 1);
  shown.level = muted ? 0 : video.volume;
  paint();
  volume.setAttribute("aria-pressed", String(muted));

  // ほかの操作で変わったときもそろうよう、見た目は video の volumechange で切り替える
  video.addEventListener("volumechange", () => {
    if (video.muted === muted) return;
    muted = video.muted;
    volume.setAttribute("aria-pressed", String(muted));
    if (muted) sound.reverse();
    else sound.play();
    showLevel(muted ? 0 : video.volume);
    flash.restart();
    if (!reduceMotion) pop.restart();
  });
  volume.addEventListener("click", () => {
    // 音量 0 のまま解除しても聞こえるよう、HTML に書いた音量に戻す
    if (video.muted && video.volume === 0) video.volume = Number(slider.defaultValue);
    video.muted = !video.muted;
  });
  // つまみを動かしたら、その音量で鳴らす。0 まで下げたら消音にする
  slider.addEventListener("input", () => {
    glide?.kill();
    shown.level = Number(slider.value);
    paint();
    video.volume = shown.level;
    video.muted = shown.level === 0;
  });

  // 5 秒より長く動く動画は、止められるようにする。動きを減らす設定では、最初から止めておく
  play.addEventListener("click", () => (video.paused ? video.play().catch(() => {}) : video.pause()));
  video.addEventListener("play", () => play.setAttribute("aria-pressed", "false"));
  video.addEventListener("pause", () => play.setAttribute("aria-pressed", "true"));
  if (reduceMotion) {
    video.autoplay = false;
    video.pause();
    play.setAttribute("aria-pressed", "true");
  }
});

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

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