Skip to content
This repository was archived by the owner on Mar 29, 2026. It is now read-only.
This repository was archived by the owner on Mar 29, 2026. It is now read-only.

Add native voice input support to vanna-chat component #1075

@lmangueira

Description

@lmangueira

Is your feature request related to a problem? Please describe.

Users who want to interact with the Vanna chat interface using voice input must implement custom solutions that inject into the Shadow DOM. This is fragile and can break when the component updates. Voice input is increasingly expected in modern chat interfaces, especially for:

  • Mobile users where typing is cumbersome
  • Hands-busy scenarios (industrial environments)
  • Accessibility needs (users with motor impairments)

Describe the solution you'd like

Add a native voice input button to the <vanna-chat> web component, positioned next to the send button. The feature should:

  1. Use the browser's Web Speech API for zero-cost client-side transcription
  2. Be configurable via an attribute (e.g., <vanna-chat voice-input="true">)
  3. Include visual feedback during recording (pulsing animation, status text)
  4. Populate the input field with transcribed text for user review before sending
  5. Handle errors gracefully (permission denied, no speech detected, no microphone)
  6. Hide the button automatically on unsupported browsers (Firefox)
  7. Respect prefers-reduced-motion for accessibility

Describe alternatives you've considered

  1. External button with absolute positioning - Works but appears disconnected from the chat UI and requires careful CSS coordination

  2. Shadow DOM injection via JavaScript - Current workaround that injects a button and styles into the component's Shadow DOM. This works but is fragile:

    • Depends on internal class names (.chat-input-container, .message-input)
    • May break on component updates
    • Requires users to maintain custom JavaScript
  3. Server-side transcription (Azure/Whisper) - Adds cost, latency, and backend complexity for a feature that can be handled client-side

Additional context

Browser support for Web Speech API covers ~88% of users (Chrome, Edge, Safari). Firefox users would simply not see the button.

I've implemented a working prototype that injects voice input into the Shadow DOM.

Script

// Voice input integration for vanna-chat component
// Finds the voice button inside vanna-chat Shadow DOM
document.addEventListener('DOMContentLoaded', () => {
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;

  // Wait for vanna-chat component to be defined and rendered
  const initVoice = () => {
    const vannaChat = document.querySelector('vanna-chat');
    if (!vannaChat?.shadowRoot) {
      setTimeout(initVoice, 100);
      return;
    }

    const btn = vannaChat.shadowRoot.querySelector('.voice-button');
    const input = vannaChat.shadowRoot.querySelector('.message-input');

    // Inject voice button CSS into shadow DOM if not already present
    if (!vannaChat.shadowRoot.querySelector('#voice-button-styles')) {
      const styleEl = document.createElement('style');
      styleEl.id = 'voice-button-styles';
      styleEl.textContent = `
        .voice-button {
          width: 48px;
          height: 48px;
          border-radius: 999px;
          border: 2px solid var(--vanna-outline-default, #e5e7eb);
          background: var(--vanna-background-root, #fff);
          color: var(--vanna-foreground-dimmer, #6b7280);
          display: inline-flex;
          align-items: center;
          justify-content: center;
          cursor: pointer;
          transition: all 0.2s ease;
        }
        .voice-button:hover {
          border-color: var(--vanna-accent-primary-default, #3877c6);
          background: var(--vanna-accent-primary-subtle, #eff6ff);
          color: var(--vanna-accent-primary-default, #3877c6);
        }
        .voice-button:disabled {
          opacity: 0.5;
          cursor: not-allowed;
        }
        .voice-button.recording {
          border-color: #ef4444;
          background: #fef2f2;
          color: #ef4444;
          animation: voice-pulse 1.5s ease-in-out infinite;
        }
        @keyframes voice-pulse {
          0%, 100% { transform: scale(1); }
          50% { transform: scale(1.05); }
        }
        .voice-status {
          font-size: 12px;
          padding: 4px 12px;
          border-radius: 4px;
          background: rgba(0, 0, 0, 0.75);
          color: #fff;
          text-align: center;
        }
        .voice-status.error {
          background: #dc2626;
        }
        @media (prefers-reduced-motion: reduce) {
          .voice-button.recording { animation: none; border-width: 3px; }
        }
      `;
      vannaChat.shadowRoot.appendChild(styleEl);
    }

    if (!SpeechRecognition || !btn) {
      if (btn) btn.style.display = 'none';
      return;
    }

    // Create status element inside shadow DOM
    const inputContainer = vannaChat.shadowRoot.querySelector('.chat-input-container');
    let status = vannaChat.shadowRoot.querySelector('.voice-status');
    if (!status && inputContainer) {
      status = document.createElement('span');
      status.className = 'voice-status';
      status.hidden = true;
      inputContainer.parentElement.insertBefore(status, inputContainer);
    }

    const recognition = new SpeechRecognition();
    recognition.lang = 'en-US';
    recognition.interimResults = false;

    let isRecording = false;
    let maxTimer = null;

    recognition.onresult = (e) => {
      const text = e.results[0][0].transcript;
      if (input) {
        input.value = text;
        input.dispatchEvent(new Event('input', { bubbles: true }));
        input.focus();
      }
      showStatus('Review and press Enter to send');
    };

    recognition.onerror = (e) => {
      const messages = {
        'not-allowed': 'Microphone access denied. Enable in browser settings.',
        'no-speech': 'No speech detected. Try again.',
        'audio-capture': 'No microphone found.',
      };
      showStatus(messages[e.error] || 'Error occurred. Try again.', true);
    };

    recognition.onend = () => {
      isRecording = false;
      btn.classList.remove('recording');
      btn.setAttribute('aria-pressed', 'false');
      clearTimeout(maxTimer);
    };

    btn.onclick = () => {
      if (isRecording) {
        recognition.stop();
      } else {
        recognition.start();
        isRecording = true;
        btn.classList.add('recording');
        btn.setAttribute('aria-pressed', 'true');
        showStatus('Listening...');
        // 60 second max recording
        maxTimer = setTimeout(() => recognition.stop(), 60000);
      }
    };

    function showStatus(msg, isError = false) {
      if (!status) return;
      status.textContent = msg;
      status.classList.toggle('error', isError);
      status.hidden = false;
      if (!isError) setTimeout(() => status.hidden = true, 4000);
    }
  };

  initVoice();
});

Should add this next to send-button

<button
  class="voice-button"
  type="button"
  aria-label="Voice input"
  aria-pressed="false"
  .disabled=${this.disabled}
>
  <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
    <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
    <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
  </svg>
</button>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions