デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 で伝え、ボタンの名前は「ミュート」のまま変えません。自動で流れる動画は止められるよう、再生と一時停止のボタンも置きます。動きを減らす設定では、動画を最初から止め、アイコンの切り替えもほぼ一瞬にします。
波の数と半径、ずらす時間、中央の表示を保つ時間で印象が変わります。動画や音声のプレーヤー、自動再生で消音している動画の解除、通話のマイクの切り替えに向いています。
コード
<!-- 動画のプレーヤー。操作は 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>
.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; }
}
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");
}
});