デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
マイクのボタンを押したままにすると、ボタンがひとまわり大きくなり、周りの輪が声の大きさに合わせて広がったり縮んだりします。そのあいだ入力欄は、赤い点と経過秒数、「離すと送信」の案内に変わります。指を離せばマイクが紙飛行機になって右上へ飛び、波形と長さの付いた音声メッセージの吹き出しが下から入ってきます。
輪は、内側の淡く塗った円と外側の細い線でできています。どちらも scale では広げず、circle の半径 r を毎フレーム書き換えました。拡大で広げると線まで一緒に太くなって、声に合わせて輪郭が太ったりやせたりして見えるからです。声の大きさはマイクから取らず、1秒に8個の決まった値を並べた列を、あいだをなめらかにつなぎながら読んでいます。実際のサイトでは、ここを Web Audio の AnalyserNode で取った音量に置き換えます。
押している長さが、そのまま録音の長さになります。うっかり触れただけで送られては困るので、0.5秒に満たないときは何も送りません。タッチ端末では、長押しでメニューが開かないよう contextmenu を止め、touch-action を none にしました。キーボードなら Space か Enter を押しているあいだが録音で、keydown と keyup の両方で既定の動きを止めて、押した瞬間に click が起きないようにしています。
紙飛行機は、-40°傾いた小さな姿から回りながら現れ、0.32秒の power2.in で加速して右上へ抜けていきます。飛び去ったあとはマイクを back.out で少し弾ませて戻し、送れた手応えが残るようにしました。録音の始まりと送信は、画面には見えない role="status" の要素で、スクリーンリーダーにも知らせています。
コード
<section class="voice-chat" aria-label="佐藤 美咲さんとのチャット">
<div class="voice-chat__head">
<span class="voice-chat__avatar" aria-hidden="true"></span>
<p class="voice-chat__name">佐藤 美咲</p>
</div>
<div class="voice-chat__log" role="log">
<div class="voice-chat__list">
<p class="voice-chat__bubble">資料の確認、終わりましたか?</p>
<p class="voice-chat__bubble voice-chat__bubble--mine">いま見ています。</p>
</div>
</div>
<!-- 送った音声メッセージの吹き出し。JS が複製して、波形の棒と長さを入れる -->
<template class="voice-chat__voice">
<div class="voice-chat__bubble voice-chat__bubble--mine voice-chat__bubble--voice">
<svg class="voice-chat__play" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.5v15l12-7.5z" /></svg>
<span class="voice-chat__wave" aria-hidden="true"></span>
<span class="voice-chat__length"></span>
</div>
</template>
<div class="voice-chat__bar">
<div class="voice-chat__field">
<input class="voice-chat__input" type="text" placeholder="メッセージを入力" aria-label="メッセージ">
<!-- 録音中だけ、プレースホルダーの代わりに表示する -->
<div class="voice-chat__rec" aria-hidden="true">
<span class="voice-chat__dot"></span>
<span class="voice-chat__time">0:00</span>
<span class="voice-chat__hint">離すと送信</span>
</div>
</div>
<div class="voice-chat__mic-wrap">
<svg class="voice-chat__rings" viewBox="-80 -80 160 160" aria-hidden="true">
<circle class="voice-chat__disc" r="28" />
<circle class="voice-chat__ring" r="28" />
</svg>
<button class="voice-chat__mic" type="button" aria-label="長押しで音声メッセージを録音">
<svg viewBox="0 0 24 24" aria-hidden="true">
<g class="voice-chat__icon-mic"><rect x="9" y="2.5" width="6" height="11.5" rx="3" /><path d="M5.5 10.5a6.5 6.5 0 0 0 13 0M12 17v4.5" /></g>
<g class="voice-chat__icon-send"><path d="M21 3 10.5 13.5M21 3l-6.5 18-4-7.5L3 9.5z" /></g>
</svg>
</button>
</div>
</div>
<!-- スクリーンリーダーにだけ、録音の始まりと送信を伝える -->
<p class="voice-chat__status" role="status" data-recording="録音しています。離すと送信します。" data-sent="音声メッセージを送信しました"></p>
</section>
.voice-chat {
--bg: #f2f1ed; /* 入力欄と、相手の吹き出し */
--surface: #ffffff;
--ink: #262626; /* ボタン・自分の吹き出し・輪 */
--mute: #898989;
--mute-small: #6b6b6b;
--line: rgba(38, 38, 38, 0.12);
--recording: #c4553d; /* 録音中の点 */
--button: 56px;
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(520px, 100%);
height: 480px;
overflow: clip;
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
color: var(--ink);
letter-spacing: 0.04em;
}
.voice-chat__head { display: flex; align-items: center; gap: 12px; height: 64px; padding: 0 20px; box-shadow: inset 0 -1px 0 var(--line); }
.voice-chat__avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(140deg, #d9a39d 0%, #b9726b 100%); }
.voice-chat__name { margin: 0; font-size: 15px; font-weight: 700; }
.voice-chat__log { display: flex; flex-direction: column; justify-content: flex-end; overflow: hidden auto; padding: 16px 20px; }
.voice-chat__list { display: flex; flex-direction: column; gap: 12px; }
.voice-chat__bubble { align-self: flex-start; max-width: 320px; margin: 0; padding: 10px 16px; border-radius: 18px 18px 18px 4px; background: var(--bg); font-size: 15px; line-height: 1.6; }
.voice-chat__bubble--mine { align-self: flex-end; border-radius: 18px 18px 4px 18px; background: var(--ink); color: #ffffff; }
.voice-chat__bubble--voice { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 16px 0 14px; box-sizing: border-box; }
.voice-chat__play { width: 16px; height: 16px; fill: currentColor; stroke: currentColor; stroke-width: 2; stroke-linejoin: round; }
.voice-chat__wave { display: flex; align-items: center; gap: 2px; height: 24px; }
.voice-chat__wave i { width: 3px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.85); }
.voice-chat__length { font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
.voice-chat__bar { display: flex; align-items: center; gap: 12px; padding: 16px 20px 20px; } /* 下と右の余白は、いちばん広がった輪が収まる大きさ */
.voice-chat__field { position: relative; flex: 1; min-width: 0; }
.voice-chat__input { width: 100%; height: 56px; padding: 0 24px; box-sizing: border-box; border: 0; border-radius: 999px; background: var(--bg); color: var(--ink); font: inherit; font-size: 15px; }
.voice-chat__input::placeholder { color: var(--mute); }
.voice-chat__input:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.voice-chat.is-recording .voice-chat__input::placeholder { color: transparent; }
.voice-chat__rec { position: absolute; inset: 0 24px; display: flex; align-items: center; gap: 10px; visibility: hidden; opacity: 0; pointer-events: none; }
.voice-chat__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--recording); }
.voice-chat__time { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
.voice-chat__hint { margin-left: auto; color: var(--mute-small); font-size: 13px; }
.voice-chat__mic-wrap { position: relative; flex: none; width: var(--button); height: var(--button); }
/* 輪はボタンの後ろに重ねる */
.voice-chat__rings { position: absolute; left: 50%; top: 50%; width: calc(var(--button) / 56 * 160); translate: -50% -50%; overflow: visible; pointer-events: none; }
.voice-chat__disc { fill: var(--ink); fill-opacity: 0; }
.voice-chat__ring { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-opacity: 0; }
.voice-chat__mic {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 100%;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--ink);
color: #ffffff;
cursor: pointer;
touch-action: none; /* 押しているあいだ、タッチでページがスクロールしないように */
-webkit-touch-callout: none;
user-select: none;
}
.voice-chat__mic svg { width: 24px; height: 24px; overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.voice-chat__mic:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.voice-chat__icon-send { opacity: 0; } /* JS が大きさを 0 にしてから表示する */
.voice-chat__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
import { gsap } from "gsap";
// 声の大きさ(0〜1)。1 秒に 8 個、3 秒で 1 巡する決まった値の列。実際のサイトでは、Web Audio の AnalyserNode で取った音量に替える
const LEVELS = [0.15, 0.45, 0.8, 0.6, 0.3, 0.7, 0.95, 0.55, 0.25, 0.1, 0.35, 0.75, 0.9, 0.65, 0.4, 0.2, 0.5, 0.85, 0.7, 0.45, 0.15, 0.3, 0.6, 0.4];
const RATE = 8;
const BARS = 24; // 吹き出しの波形の本数
const MIN = 0.5; // これより短く押しただけなら送らない(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const isKey = (event) => event.key === " " || event.key === "Enter";
// 経過秒数 s の声の大きさ。値の列の間をなめらかにつなぐ
const levelAt = (s) => {
const x = s * RATE, i = Math.floor(x), a = LEVELS[i % LEVELS.length], b = LEVELS[(i + 1) % LEVELS.length];
return a + (b - a) * (0.5 - 0.5 * Math.cos(Math.PI * (x - i)));
};
const clock = (s) => `0:${String(Math.floor(s)).padStart(2, "0")}`;
document.querySelectorAll(".voice-chat").forEach((chat) => {
const $ = (name) => chat.querySelector(`.voice-chat__${name}`);
const [button, disc, ring, rec, dot, time, list, log, status, mic, send] = ["mic", "disc", "ring", "rec", "dot", "time", "list", "log", "status", "icon-mic", "icon-send"].map($);
const template = $("voice");
const state = { hold: 0 }; // 録音中の表示(0..1)。輪の広がりと入力欄の表示を、この値でまとめて出し入れする
let start = null, ramp, blink, iconTimeline;
gsap.set([mic, send], { svgOrigin: "12 12" }); // 回す・縮める軸はアイコンの中心
gsap.set(send, { scale: 0, opacity: 1 });
// 毎フレーム、経過秒数と声の大きさから輪の半径を決める(scale で広げると線まで太くなるので、半径を書き換える)
const base = () => button.offsetWidth / 2;
const draw = () => {
const elapsed = start === null ? 0 : gsap.ticker.time - start;
const level = reduceMotion ? 0.4 : levelAt(elapsed);
disc.setAttribute("r", base() + state.hold * (3 + 10 * level));
ring.setAttribute("r", base() + state.hold * (6 + 12 * level));
disc.style.fillOpacity = 0.14 * state.hold;
ring.style.strokeOpacity = 0.4 * state.hold;
if (start !== null) time.textContent = clock(elapsed);
};
const begin = () => {
if (start !== null) return;
start = gsap.ticker.time;
time.textContent = clock(0);
chat.classList.add("is-recording");
status.textContent = status.dataset.recording;
iconTimeline?.progress(1); // 前の送信の動きが残っていれば終わらせる
gsap.ticker.add(draw);
ramp?.kill();
ramp = gsap.timeline()
.to(state, { hold: 1, duration: 0.25, ease: "power2.out" }, 0)
.to(rec, { autoAlpha: 1, duration: 0.15, ease: "none" }, 0.1)
.to(button, { scale: reduceMotion ? 1 : 64 / 56, duration: 0.3, ease: "back.out(2)" }, 0); // 56px → 64px
if (!reduceMotion) blink = gsap.fromTo(dot, { opacity: 1 }, { opacity: 0.2, duration: 0.5, ease: "sine.inOut", repeat: -1, yoyo: true }); // 1 秒で 1 往復、ゆっくり明滅する
};
const end = () => {
if (start === null) return;
const length = gsap.ticker.time - start;
start = null;
chat.classList.remove("is-recording");
blink?.kill();
ramp?.kill();
ramp = gsap.timeline({ onComplete: () => { draw(); gsap.ticker.remove(draw); } })
.to(state, { hold: 0, duration: 0.25, ease: "power2.out" }, 0)
.to(rec, { autoAlpha: 0, duration: 0.12, ease: "none" }, 0)
.to(button, { scale: 1, duration: 0.25, ease: "power2.out" }, 0);
if (length < MIN) { status.textContent = ""; return; }
sendVoice(length);
};
// 送る: マイクが紙飛行機に変わって右上へ飛び、マイクが戻る。吹き出しは下から入り、前のメッセージを押し上げる
const sendVoice = (length) => {
const bubble = template.content.firstElementChild.cloneNode(true);
bubble.querySelector(".voice-chat__length").textContent = clock(length);
const wave = bubble.querySelector(".voice-chat__wave");
const heights = Array.from({ length: BARS }, (_, i) => 4 + Math.round(18 * levelAt((i / BARS) * length)));
heights.forEach(() => wave.append(document.createElement("i")));
list.append(bubble);
log.scrollTop = log.scrollHeight;
status.textContent = `${status.dataset.sent}(${clock(length)})。`;
const bars = [...wave.children];
if (reduceMotion) { bars.forEach((bar, i) => { bar.style.height = `${heights[i]}px`; }); return; }
iconTimeline = gsap.timeline()
.to(mic, { scale: 0, duration: 0.3, ease: "power2.out" }, 0.02)
.fromTo(send, { scale: 0, rotation: -40, x: 0, y: 0 }, { scale: 1, rotation: 0, duration: 0.3, ease: "power2.out" }, 0.02)
.to(send, { x: 14, y: -14, scale: 0, duration: 0.32, ease: "power2.in" }, 0.42)
.to(mic, { scale: 1, duration: 0.35, ease: "back.out(2)" }, 0.62);
gsap.fromTo(list, { y: bubble.offsetHeight + 12 }, { y: 0, duration: 0.5, delay: 0.2, ease: "power2.out" });
gsap.fromTo(bubble, { opacity: 0 }, { opacity: 1, duration: 0.5, delay: 0.2, ease: "power2.out" });
gsap.fromTo(bars, { height: 4 }, { height: (i) => heights[i], duration: 0.28, delay: 0.42, stagger: 0.006, ease: "power2.out" });
};
button.addEventListener("pointerdown", (event) => { button.setPointerCapture(event.pointerId); begin(); });
button.addEventListener("pointerup", end);
button.addEventListener("pointercancel", end);
button.addEventListener("contextmenu", (event) => event.preventDefault()); // タッチの長押しでメニューを出さない
// キーボードでは、Space か Enter を押しているあいだ録音する(keydown と keyup の両方で既定の動きを止めて、click を起こさない)
button.addEventListener("keydown", (event) => { if (!isKey(event)) return; event.preventDefault(); if (!event.repeat) begin(); });
button.addEventListener("keyup", (event) => { if (!isKey(event)) return; event.preventDefault(); end(); });
button.addEventListener("blur", end);
});