About this snippet

Press and hold the mic button and expanding rings pulse out from its center. Release and the rings already in flight finish traveling and fade out.

A ripple spawns every 120 milliseconds while the button is held. Each one reads the button's live getBoundingClientRect for its center and starts at the button radius, so rings emerge from the edge of the button instead of a hardcoded point. That keeps them aligned after a resize or a scroll rather than drifting off.

setPointerCapture on pointerdown means the release still registers if you drag off the button first, and pointercancel is handled too, so a browser gesture cannot leave the button stuck in the listening state. Ring speed is randomized a little per ripple, which reads as sound modulation instead of a mechanical pulse.

Size
4.5 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
169
<div class="voice-app">
  <canvas id="ripple-canvas"></canvas>
  
  <div class="mic-container">
    <button class="mic-btn" id="mic-btn" aria-label="Microphone">
      <svg viewBox="0 0 24 24" width="36" height="36" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
        <path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
        <line x1="12" y1="19" x2="12" y2="23"></line>
        <line x1="8" y1="23" x2="16" y2="23"></line>
      </svg>
    </button>
    <div class="helper-text" id="help-text">Hold to speak</div>
  </div>
</div>
*, *::before, *::after {
  box-sizing: border-box;
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  margin: 0;
  background: #0f172a;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
}

.voice-app {
  position: relative;
  width: 100vw;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow: hidden;
}

#ripple-canvas {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  pointer-events: none;
}

.mic-container {
  position: relative;
  z-index: 10;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Shift slightly to ensure we test dynamic alignment */
  margin-top: -50px; 
}

.mic-btn {
  width: 120px;
  height: 120px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.05);
  backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.15);
  color: #ffffff;
  cursor: pointer;
  display: flex;
  justify-content: center;
  align-items: center;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.2s, box-shadow 0.2s;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
}

.mic-btn.active {
  transform: scale(0.9);
  background: #38bdf8;
  border-color: #7dd3fc;
  color: #0f172a;
  box-shadow: 0 0 50px rgba(56, 189, 248, 0.5);
}

.helper-text {
  margin-top: 30px;
  color: #94a3b8;
  font-size: 1.125rem;
  letter-spacing: 1px;
  transition: color 0.2s, text-shadow 0.2s;
}
const canvas = document.getElementById("ripple-canvas");
const ctx = canvas.getContext("2d");
const micBtn = document.getElementById("mic-btn");
const helpText = document.getElementById("help-text");

let width = window.innerWidth;
let height = window.innerHeight;
canvas.width = width;
canvas.height = height;

window.addEventListener("resize", function() {
  width = window.innerWidth;
  height = window.innerHeight;
  canvas.width = width;
  canvas.height = height;
});

const ripples = new Array();
let isListening = false;
let rippleTimer = null;

function spawnRipple() {
  /* Precisely calculate the button center directly from the DOM */
  const rect = micBtn.getBoundingClientRect();
  const centerX = rect.left + (rect.width / 2);
  const centerY = rect.top + (rect.height / 2);
  
  const r = new Object();
  r.x = centerX;
  r.y = centerY;
  /* Start the ripple exactly at the button edge */
  r.radius = rect.width / 2;
  r.alpha = 1;
  /* Add randomness to mimic sound modulation */
  r.speed = Math.random() * 2 + 3; 
  
  ripples.push(r);
}

micBtn.addEventListener("pointerdown", function(e) {
  isListening = true;
  micBtn.classList.add("active");
  micBtn.setPointerCapture(e.pointerId);
  
  helpText.innerText = "Listening...";
  helpText.style.color = "#38bdf8";
  helpText.style.textShadow = "0 0 10px rgba(56,189,248,0.5)";
  
  /* Spawn ripples rapidly while holding */
  rippleTimer = setInterval(spawnRipple, 120);
});

function stopListening() {
  isListening = false;
  micBtn.classList.remove("active");
  helpText.innerText = "Hold to speak";
  helpText.style.color = "#94a3b8";
  helpText.style.textShadow = "none";
  clearInterval(rippleTimer);
}

micBtn.addEventListener("pointerup", stopListening);
micBtn.addEventListener("pointercancel", stopListening);

function animate() {
  ctx.clearRect(0, 0, width, height);
  
  for (let i = 0; i < ripples.length; i++) {
    const r = ripples.at(i);
    
    r.radius += r.speed;
    r.alpha -= 0.015;
    
    if (r.alpha > 0) {
      ctx.beginPath();
      ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
      ctx.strokeStyle = "rgba(56, 189, 248, " + r.alpha + ")";
      ctx.lineWidth = 3;
      ctx.stroke();
    }
  }
  
  /* Clean up dead ripples safely */
  while (ripples.length > 0 && ripples.at(0).alpha <= 0) {
    ripples.shift();
  }
  
  window.requestAnimationFrame(animate);
}

animate();
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save