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:
- Use the browser's Web Speech API for zero-cost client-side transcription
- Be configurable via an attribute (e.g.,
<vanna-chat voice-input="true">)
- Include visual feedback during recording (pulsing animation, status text)
- Populate the input field with transcribed text for user review before sending
- Handle errors gracefully (permission denied, no speech detected, no microphone)
- Hide the button automatically on unsupported browsers (Firefox)
- Respect
prefers-reduced-motion for accessibility
Describe alternatives you've considered
-
External button with absolute positioning - Works but appears disconnected from the chat UI and requires careful CSS coordination
-
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
-
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>
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:
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:<vanna-chat voice-input="true">)prefers-reduced-motionfor accessibilityDescribe alternatives you've considered
External button with absolute positioning - Works but appears disconnected from the chat UI and requires careful CSS coordination
Shadow DOM injection via JavaScript - Current workaround that injects a button and styles into the component's Shadow DOM. This works but is fragile:
.chat-input-container,.message-input)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
Should add this next to send-button