# Voice Chat Card

> A Dynamic Island–style voice chat card with live waveform motion — compact presence for realtime audio UI.

**Definition:** Voice Chat Card is a cards UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Cards
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-01
- Canonical page: https://html.bosconvertpro.com/collection/voice-chat-card
- Full-page preview: https://html.bosconvertpro.com/collection/voice-chat-card/full
- Live demo: https://html.bosconvertpro.com/demos/voice-chat-card/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/voice-chat-card/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/voice-chat-card/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/voice-chat-card.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Voice Chat Card?

Voice Chat Card is a cards UI animation from collections.html. A Dynamic Island–style voice chat card with live waveform motion — compact presence for realtime audio UI.

### What is Voice Chat Card built with?

Voice Chat Card is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use Voice Chat Card in my project?

Open the animation page (https://html.bosconvertpro.com/collection/voice-chat-card), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/voice-chat-card/index.html.

### Is Voice Chat Card free to use?

Yes. The Voice Chat Card demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Voice Chat Card</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main class="page">
      <div
        class="vc"
        id="vc"
        data-state="closed"
        role="button"
        tabindex="0"
        aria-expanded="false"
        aria-label="Voice chat, 7 people. Click to expand"
      >
        <div class="vc__shell" id="shell">
          <!-- Clip layer: morph content only. Badges use overflow:visible shell. -->
          <div class="vc__clip" id="clip">
            <!-- ========== CLOSED PILL ========== -->
            <div class="vc-closed" id="closedView">
              <div class="vc-closed__live wave-badge" aria-hidden="true">
                <span class="wave wave--live" data-wave="live">
                  <span class="wave__bar"></span>
                  <span class="wave__bar"></span>
                  <span class="wave__bar"></span>
                  <span class="wave__bar"></span>
                </span>
              </div>

              <div class="vc-closed__stack">
                <div class="av av--sm">
                  <img src="https://i.pravatar.cc/96?img=12" alt="" draggable="false" />
                </div>
                <div class="av av--sm av--ring">
                  <img src="https://i.pravatar.cc/96?img=33" alt="" draggable="false" />
                </div>
                <div class="av av--sm">
                  <img src="https://i.pravatar.cc/96?img=47" alt="" draggable="false" />
                  <span class="av__play av__play--sm" aria-hidden="true">
                    <svg viewBox="0 0 10 10" fill="currentColor">
                      <path d="M2.4 1.3a.55.55 0 0 1 .85-.46l5.4 3.5a.55.55 0 0 1 0 .94l-5.4 3.5a.55.55 0 0 1-.85-.47V1.3Z" />
                    </svg>
                  </span>
                </div>
                <div class="av av--sm">
                  <img src="https://i.pravatar.cc/96?img=68" alt="" draggable="false" />
                </div>
              </div>

              <span class="vc-closed__more">
                +3
                <svg viewBox="0 0 12 12" fill="none" aria-hidden="true">
                  <path
                    d="M3.2 4.6 6 7.4l2.8-2.8"
                    stroke="currentColor"
                    stroke-width="1.4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </span>
            </div>

            <!-- ========== OPEN CARD ========== -->
            <div class="vc-open" id="openView" aria-hidden="true">
              <header class="vc-open__header">
                <h1 class="vc-open__title">Voice Chat</h1>
                <button
                  type="button"
                  class="vc-open__close"
                  id="closeBtn"
                  aria-label="Close"
                  tabindex="-1"
                >
                  <svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
                    <path
                      d="M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6"
                      stroke="currentColor"
                      stroke-width="1.5"
                      stroke-linecap="round"
                    />
                  </svg>
                </button>
              </header>

              <div class="vc-open__body">
                <!-- Row 1: 4 people -->
                <div class="vc-row">
                  <div class="person">
                    <div class="av av--lg">
                      <img src="https://i.pravatar.cc/120?img=12" alt="" draggable="false" />
                      <span class="av__speak wave-badge" aria-label="Speaking">
                        <span class="wave wave--speak" data-wave="speak">
                          <span class="wave__bar"></span>
                          <span class="wave__bar"></span>
                          <span class="wave__bar"></span>
                          <span class="wave__bar"></span>
                        </span>
                      </span>
                    </div>
                    <span class="person__name">Oğuz</span>
                  </div>

                  <div class="person">
                    <div class="av av--lg av--ring">
                      <img src="https://i.pravatar.cc/120?img=33" alt="" draggable="false" />
                    </div>
                    <span class="person__name">Ashish</span>
                  </div>

                  <div class="person">
                    <div class="av av--lg">
                      <img src="https://i.pravatar.cc/120?img=47" alt="" draggable="false" />
                      <span class="av__play av__play--lg" aria-hidden="true">
                        <svg viewBox="0 0 10 10" fill="currentColor">
                          <path d="M2.4 1.3a.55.55 0 0 1 .85-.46l5.4 3.5a.55.55 0 0 1 0 .94l-5.4 3.5a.55.55 0 0 1-.85-.47V1.3Z" />
                        </svg>
                      </span>
                    </div>
                    <span class="person__name">Mariana</span>
                  </div>

                  <div class="person">
                    <div class="av av--lg">
                      <img src="https://i.pravatar.cc/120?img=68" alt="" draggable="false" />
                    </div>
                    <span class="person__name">MDS</span>
                  </div>
                </div>

                <!-- Row 2: 3 people centered -->
                <div class="vc-row vc-row--3">
                  <div class="person">
                    <div class="av av--lg">
                      <img src="https://i.pravatar.cc/120?img=5" alt="" draggable="false" />
                    </div>
                    <span class="person__name">Ana</span>
                  </div>

                  <div class="person">
                    <div class="av av--lg">
                      <img src="https://i.pravatar.cc/120?img=52" alt="" draggable="false" />
                      <span class="av__speak wave-badge" aria-label="Speaking">
                        <span class="wave wave--speak" data-wave="speak">
                          <span class="wave__bar"></span>
                          <span class="wave__bar"></span>
                          <span class="wave__bar"></span>
                          <span class="wave__bar"></span>
                        </span>
                      </span>
                    </div>
                    <span class="person__name">Natko</span>
                  </div>

                  <div class="person">
                    <div class="av av--lg">
                      <img src="https://i.pravatar.cc/120?img=15" alt="" draggable="false" />
                    </div>
                    <span class="person__name">Afshin</span>
                  </div>
                </div>
              </div>

              <footer class="vc-open__footer">
                <button type="button" class="join" id="joinBtn" tabindex="-1">
                  Join Now
                </button>
                <p class="vc-open__hint">Mic will be muted initially.</p>
              </footer>
            </div>
          </div>
        </div>
      </div>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/CustomEase.min.js"></script>
    <script src="script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* ============================================
   Voice Chat Card — premium Dynamic Island UI
   Motion is 100% GSAP timeline
   ============================================ */

:root {
  --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;

  --bg: #f5f5f7;
  --shell: #ffffff;
  --ink: #1d1d1f;
  --title: #1d1d1f;
  --name: #86868b;
  --more: #86868b;
  --hint: #aeaeb2;
  --close: #aeaeb2;
  --badge: #1c1c1e;
  --line: rgba(0, 0, 0, 0.06);

  /* Closed island (horizontal capsule) */
  --pill-w: 232px;
  --pill-h: 52px;
  /* Real radius = half height — never use 999 during morph */
  --r-pill: 26px;

  /* Open card */
  --card-w: 300px;
  --card-h: 360px;
  --r-card: 24px;

  /* Join button — soft rect, not full pill */
  --r-btn: 14px;

  --av-sm: 36px;
  --av-lg: 52px;
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  height: 100%;
}

body {
  font-family: var(--font);
  color: var(--ink);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

button {
  font: inherit;
  color: inherit;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  pointer-events: none;
}

/* ---------- Page ---------- */

.page {
  min-height: 100%;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: 2rem 1rem;
  background:
    radial-gradient(ellipse 70% 50% at 50% 45%, #ffffff 0%, transparent 70%),
    var(--bg);
}

/* ---------- Widget root ---------- */

.vc {
  position: relative;
  width: var(--pill-w);
  height: var(--pill-h);
  outline: none;
  cursor: pointer;
  will-change: width, height;
  transform-origin: center center;
  /* GPU layer for smoother size morph */
  transform: translateZ(0);
  backface-visibility: hidden;
}

.vc[data-state="open"] {
  cursor: default;
}

.vc__shell {
  position: absolute;
  inset: 0;
  background: var(--shell);
  border-radius: var(--r-pill);
  /* visible by default so speak / live badges aren't clipped.
     GSAP toggles to hidden only while the island is morphing. */
  overflow: visible;
  will-change: border-radius, box-shadow;
  border: 1px solid rgba(255, 255, 255, 0.8);
  box-shadow:
    0 0 0 1px rgba(0, 0, 0, 0.03),
    0 2px 4px rgba(0, 0, 0, 0.02),
    0 8px 20px rgba(0, 0, 0, 0.05),
    0 20px 48px rgba(0, 0, 0, 0.06);
}

/* Inner clip — clips morph content; GSAP sets overflow visible
   when idle so speak / live badges are never cut off */
.vc__clip {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  overflow: hidden;
  /* isolate painting so bars animate cleanly */
  transform: translateZ(0);
}

/* ---------- Avatars ---------- */

.av {
  position: relative;
  border-radius: 50%;
  flex-shrink: 0;
  background: #e8e8ed;
  overflow: visible;
}

.av img {
  border-radius: 50%;
}

.av--sm {
  width: var(--av-sm);
  height: var(--av-sm);
  border: 2px solid #fff;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}

.av--lg {
  width: var(--av-lg);
  height: var(--av-lg);
}

.av--lg > img {
  width: var(--av-lg);
  height: var(--av-lg);
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
}

.av--ring {
  background: conic-gradient(
    from 160deg,
    #ff5f6d,
    #ffc371,
    #47e0a0,
    #4facfe,
    #a18cd1,
    #ff5f6d
  );
  padding: 2px;
  border: none;
  box-shadow: 0 0 0 2px #fff;
}

.av--sm.av--ring {
  width: var(--av-sm);
  height: var(--av-sm);
}

.av--lg.av--ring {
  width: var(--av-lg);
  height: var(--av-lg);
  padding: 2.5px;
  box-shadow: none;
}

.av--ring img {
  width: 100%;
  height: 100%;
  box-shadow: none;
}

.av__play {
  position: absolute;
  inset: 0;
  margin: auto;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.95);
  color: #1c1c1e;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.14);
  pointer-events: none;
}

.av__play--sm {
  width: 16px;
  height: 16px;
}

.av__play--sm svg {
  width: 7px;
  height: 7px;
  margin-left: 0.5px;
}

.av__play--lg {
  width: 24px;
  height: 24px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
}

.av__play--lg svg {
  width: 9px;
  height: 9px;
  margin-left: 1px;
}

/* Speaking badge — corner of avatar; stays inside card padding */
.av__speak {
  position: absolute;
  top: -2px;
  right: -3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--badge);
  color: #fff;
  display: grid;
  place-items: center;
  box-shadow: 0 0 0 2px #fff, 0 1px 3px rgba(0, 0, 0, 0.12);
  z-index: 6;
  overflow: visible;
}

/* ---------- Animated voice waveform ---------- */

.wave-badge {
  display: grid;
  place-items: center;
}

.wave {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1.5px;
  height: 10px;
  pointer-events: none;
}

.wave--live {
  height: 11px;
  gap: 1.6px;
}

.wave--speak {
  height: 9px;
  gap: 1.4px;
}

.wave__bar {
  display: block;
  width: 1.6px;
  height: 100%;
  border-radius: 99px;
  background: currentColor;
  transform: scaleY(0.4);
  transform-origin: center center;
  will-change: transform;
  /* ensure scaleY is never clipped by parent radius */
  backface-visibility: hidden;
}

.wave--live .wave__bar {
  width: 1.7px;
}

.wave--speak .wave__bar {
  width: 1.5px;
}

/* ---------- Closed island ---------- */

.vc-closed {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: 0 14px 0 10px;
  z-index: 2;
  /* Pill content is one layer */
}

.vc-closed__live {
  position: absolute;
  top: 2px;
  left: 5px;
  z-index: 8;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--badge);
  color: #fff;
  display: grid;
  place-items: center;
  box-shadow:
    0 0 0 2.5px #fff,
    0 1px 3px rgba(0, 0, 0, 0.12);
  overflow: visible;
}

.vc-closed__stack {
  display: flex;
  align-items: center;
  margin-left: 2px;
}

.vc-closed__stack .av {
  margin-left: -9px;
}

.vc-closed__stack .av:first-child {
  margin-left: 0;
}

.vc-closed__more {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 1px;
  font-size: 13.5px;
  font-weight: 500;
  color: var(--more);
  letter-spacing: -0.015em;
  padding-left: 8px;
  line-height: 1;
  user-select: none;
}

.vc-closed__more svg {
  width: 11px;
  height: 11px;
  color: #c7c7cc;
  position: relative;
  top: 0.5px;
}

/* ---------- Open card ---------- */

.vc-open {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  padding: 16px 18px 18px;
  z-index: 1;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  /* Whole card content is one layer — no per-item animation */
}

.vc[data-state="open"] .vc-open {
  pointer-events: auto;
}

.vc-open__header {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 24px;
  margin-bottom: 18px;
  flex-shrink: 0;
}

.vc-open__title {
  font-size: 15px;
  font-weight: 550;
  color: var(--title);
  letter-spacing: -0.025em;
  line-height: 1;
}

.vc-open__close {
  position: absolute;
  right: -4px;
  top: 50%;
  transform: translateY(-50%);
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  color: var(--close);
  border-radius: 8px;
}

.vc-open__close:hover {
  color: #636366;
  background: rgba(0, 0, 0, 0.04);
}

.vc-open__close svg {
  width: 13px;
  height: 13px;
}

.vc-open__body {
  display: flex;
  flex-direction: column;
  gap: 16px;
  flex: 1;
  overflow: visible;
}

.vc-row {
  display: flex;
  justify-content: space-between;
  width: 100%;
  padding: 0 2px;
  overflow: visible;
}

.vc-row--3 {
  justify-content: center;
  gap: 28px;
  padding: 0;
}

.person {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  width: 60px;
  overflow: visible;
}

.person__name {
  font-size: 12px;
  font-weight: 500;
  color: var(--name);
  letter-spacing: -0.01em;
  line-height: 1.15;
  text-align: center;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ---------- Footer ---------- */

.vc-open__footer {
  margin-top: auto;
  padding-top: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

/*
 * Join button — soft rectangle (NOT full pill)
 * Premium 3D black gradient
 */
.join {
  width: 100%;
  height: 46px;
  border-radius: var(--r-btn);
  color: #fff;
  font-size: 14.5px;
  font-weight: 500;
  letter-spacing: -0.015em;
  position: relative;
  overflow: hidden;

  background: linear-gradient(
    180deg,
    #3e3e40 0%,
    #2e2e30 12%,
    #1c1c1e 42%,
    #121214 72%,
    #0a0a0c 100%
  );

  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.18),
    inset 0 -1px 0 rgba(0, 0, 0, 0.4),
    0 1px 1px rgba(0, 0, 0, 0.08),
    0 4px 12px rgba(0, 0, 0, 0.16),
    0 12px 28px rgba(0, 0, 0, 0.1);
}

.join::before {
  content: "";
  position: absolute;
  left: 8%;
  right: 8%;
  top: 1px;
  height: 40%;
  border-radius: calc(var(--r-btn) - 2px);
  background: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.16) 0%,
    rgba(255, 255, 255, 0) 100%
  );
  pointer-events: none;
}

.join:hover {
  filter: brightness(1.1);
}

.join:active {
  filter: brightness(0.95);
  transform: translateY(0.5px);
}

.vc-open__hint {
  font-size: 12px;
  font-weight: 400;
  color: var(--hint);
  letter-spacing: -0.01em;
  text-align: center;
  line-height: 1.3;
}

/* ---------- Focus ---------- */

.vc:focus-visible .vc__shell {
  outline: 2px solid #1c1c1e;
  outline-offset: 3px;
}

.vc[data-state="open"]:focus-visible .vc__shell {
  outline: none;
}

.vc-open__close:focus-visible,
.join:focus-visible {
  outline: 2px solid #1c1c1e;
  outline-offset: 2px;
}

@media (max-width: 360px) {
  :root {
    --card-w: min(300px, calc(100vw - 28px));
    --pill-w: min(232px, calc(100vw - 36px));
  }
}
````

## Source — script.js
````javascript
/**
 * Voice Chat Card — jh3yy-style Dynamic Island
 *
 * One reversible timeline. Shape + content move together.
 * No staggered avatars. Content is unmasked by the shell.
 */

(() => {
  "use strict";

  if (typeof gsap === "undefined") {
    console.error("GSAP is required");
    return;
  }

  /* Smooth Apple-like curves (jh3yy / iOS feel) */
  if (typeof CustomEase !== "undefined") {
    CustomEase.create("island", "0.32, 0.72, 0, 1");
  }
  const ease = typeof CustomEase !== "undefined" ? "island" : "power3.out";

  const vc = document.getElementById("vc");
  const shell = document.getElementById("shell");
  const clip = document.getElementById("clip");
  const closedView = document.getElementById("closedView");
  const openView = document.getElementById("openView");
  const closeBtn = document.getElementById("closeBtn");
  const joinBtn = document.getElementById("joinBtn");

  if (!vc || !shell) return;

  const root = getComputedStyle(document.documentElement);
  const px = (name, fb) => parseFloat(root.getPropertyValue(name)) || fb;

  const PILL_W = px("--pill-w", 232);
  const PILL_H = px("--pill-h", 52);
  const CARD_W = px("--card-w", 300);
  const CARD_H = px("--card-h", 360);
  const R_PILL = px("--r-pill", PILL_H / 2);
  const R_CARD = px("--r-card", 24);

  const SHADOW_CLOSED =
    "0 0 0 1px rgba(0,0,0,0.03), 0 2px 4px rgba(0,0,0,0.02), 0 8px 20px rgba(0,0,0,0.05), 0 20px 48px rgba(0,0,0,0.06)";
  const SHADOW_OPEN =
    "0 0 0 1px rgba(0,0,0,0.04), 0 4px 8px rgba(0,0,0,0.03), 0 16px 40px rgba(0,0,0,0.08), 0 32px 72px rgba(0,0,0,0.07)";

  const DUR = 0.6;

  const liveBars = gsap.utils.toArray('[data-wave="live"] .wave__bar');
  const speakWaves = gsap.utils.toArray('[data-wave="speak"]');

  let isOpen = false;

  function setMorphClip(active) {
    const v = active ? "hidden" : "visible";
    shell.style.overflow = v;
    if (clip) clip.style.overflow = v;
  }

  function setA11y(open) {
    vc.dataset.state = open ? "open" : "closed";
    vc.setAttribute("aria-expanded", String(open));
    vc.setAttribute(
      "aria-label",
      open ? "Voice chat open" : "Voice chat, 7 people. Click to expand"
    );
    openView.setAttribute("aria-hidden", String(!open));
    closedView.setAttribute("aria-hidden", String(open));
    closeBtn.tabIndex = open ? 0 : -1;
    joinBtn.tabIndex = open ? 0 : -1;
  }

  /* ---------- Speaking waves ---------- */

  function animateBar(bar, opts = {}) {
    const min = opts.min ?? 0.28;
    const max = opts.max ?? 1;
    const speed = opts.speed ?? 1;

    const tick = () => {
      if (!bar.isConnected) return;
      gsap.to(bar, {
        scaleY: gsap.utils.random(min, max),
        duration: gsap.utils.random(0.12, 0.28) / speed,
        ease: "sine.inOut",
        overwrite: true,
        onComplete: tick,
      });
    };

    gsap.delayedCall(gsap.utils.random(0, 0.28), () => {
      if (bar.isConnected) tick();
    });
  }

  function startLiveWave() {
    liveBars.forEach((bar, i) => {
      gsap.killTweensOf(bar);
      gsap.set(bar, {
        scaleY: gsap.utils.random(0.35, 0.85),
        transformOrigin: "center center",
      });
      animateBar(bar, { min: 0.28, max: 1, speed: 1 + i * 0.06 });
    });
  }

  function startSpeakWaves() {
    speakWaves.forEach((wave, wi) => {
      gsap.utils.toArray(wave.querySelectorAll(".wave__bar")).forEach((bar, i) => {
        gsap.killTweensOf(bar);
        gsap.set(bar, {
          scaleY: gsap.utils.random(0.3, 0.9),
          transformOrigin: "center center",
        });
        animateBar(bar, {
          min: 0.22,
          max: 1,
          speed: 0.92 + wi * 0.1 + i * 0.04,
        });
      });
    });
  }

  function stopSpeakWaves() {
    speakWaves.forEach((wave) => {
      const bars = wave.querySelectorAll(".wave__bar");
      gsap.killTweensOf(bars);
      gsap.set(bars, { scaleY: 0.32 });
    });
  }

  startLiveWave();

  /* ---------- Initial state ---------- */

  setMorphClip(false);
  gsap.set(vc, { width: PILL_W, height: PILL_H, force3D: true });
  gsap.set(shell, {
    borderRadius: R_PILL,
    boxShadow: SHADOW_CLOSED,
    force3D: true,
  });
  gsap.set(closedView, { autoAlpha: 1 });
  gsap.set(openView, { autoAlpha: 0 });
  gsap.set(".wave__bar", { transformOrigin: "center center" });

  /* =========================================================
   * Single reversible master timeline
   * play() → open · reverse() → close
   * ========================================================= */

  const tl = gsap.timeline({
    paused: true,
    defaults: { ease, overwrite: "auto" },
    onReverseComplete() {
      isOpen = false;
      setA11y(false);
      setMorphClip(false);
      stopSpeakWaves();
      vc.focus({ preventScroll: true });
    },
    onComplete() {
      isOpen = true;
      setA11y(true);
      setMorphClip(false);
      startSpeakWaves();
      closeBtn.focus({ preventScroll: true });
    },
  });

  /*
   * All shape props share the same duration + ease.
   * Layers crossfade early so the expanding shell unmasks the card.
   * No stagger. No per-avatar steps.
   */
  tl
    // Layers — quick crossfade only
    .to(closedView, { autoAlpha: 0, duration: 0.16, ease: "power2.out" }, 0)
    .to(openView, { autoAlpha: 1, duration: 0.2, ease: "power2.out" }, 0.05)

    // Shape — one body, one motion
    .to(
      vc,
      {
        width: CARD_W,
        height: CARD_H,
        duration: DUR,
      },
      0
    )
    .to(
      shell,
      {
        borderRadius: R_CARD,
        boxShadow: SHADOW_OPEN,
        duration: DUR,
      },
      0
    );

  /* ---------- Controls ---------- */

  function open() {
    if (tl.progress() === 1 && !tl.isActive()) return;
    setMorphClip(true);
    tl.play();
  }

  function close() {
    if (tl.progress() === 0 && !tl.isActive()) return;
    setMorphClip(true);
    stopSpeakWaves();
    tl.reverse();
  }

  function toggle() {
    tl.progress() > 0.5 ? close() : open();
  }

  vc.addEventListener("click", () => {
    if (tl.progress() < 0.05) open();
  });

  vc.addEventListener("keydown", (e) => {
    if ((e.key === "Enter" || e.key === " ") && tl.progress() < 0.05) {
      e.preventDefault();
      open();
    }
    if (e.key === "Escape" && tl.progress() > 0) {
      e.preventDefault();
      close();
    }
  });

  closeBtn.addEventListener("click", (e) => {
    e.stopPropagation();
    close();
  });

  joinBtn.addEventListener("click", (e) => {
    e.stopPropagation();
    if (joinBtn.dataset.locked) return;
    joinBtn.dataset.locked = "1";
    const prev = joinBtn.textContent;

    gsap
      .timeline({
        onComplete() {
          joinBtn.textContent = prev;
          delete joinBtn.dataset.locked;
        },
      })
      .to(joinBtn, { scale: 0.98, duration: 0.07, ease: "power2.in" })
      .to(joinBtn, {
        scale: 1,
        duration: 0.16,
        ease: "power2.out",
        onStart() {
          joinBtn.textContent = "Joined";
        },
      })
      .to({}, { duration: 1.1 });
  });

  document.addEventListener("pointerdown", (e) => {
    if (tl.progress() < 0.5) return;
    if (!vc.contains(e.target)) close();
  });

  window.voiceChat = { open, close, toggle };
})();
````
