Use the browser's built-in Web Speech API inside a JS Query to capture voice input. Create a SpeechRecognition instance, set onresult to update a Temporary State with the transcript, and call recognition.start() on button click. Bind the Text Input's default value to the state variable. Works in Chrome and Edge; Safari/Firefox have limited support.
| Fact | Value |
|---|---|
| Tool | Retool |
| Difficulty | Beginner |
| Time required | 20-25 min |
| Compatibility | Retool Cloud and Self-hosted (Chrome and Edge only — Web Speech API not supported in Firefox or Safari) |
| Last updated | March 2026 |
Adding Voice-to-Text Input to Retool Apps
The Web Speech API is a browser-native API that provides speech recognition without any third-party service. Because Retool apps run in the browser, JS Queries have full access to browser APIs including SpeechRecognition. This means you can add voice input to any Retool form without external API keys or services.
The pattern works as follows: a button triggers a JS Query that creates a SpeechRecognition instance. As the user speaks, the API fires onresult events with live transcripts. The JS Query writes the transcript into a Temporary State variable. A Text Input component reads from that state variable, allowing the user to review and edit before submitting.
Chrome and Edge have full support. Firefox does not implement the Web Speech API. Safari has partial support behind a flag. For cross-browser support, consider a Custom Component wrapping the AssemblyAI or Whisper API instead.
Prerequisites
- A Retool app with at least one Text Input that should accept voice input
- Chrome or Edge browser (Web Speech API not available in Firefox/Safari)
- Microphone access on the device running the Retool app
Step-by-step guide
Create a Temporary State to hold the transcript
Voice transcripts are delivered asynchronously via browser events, so you need a Temporary State variable to bridge the async callback and the Retool component. In the left panel, click + State or go to the State tab in the Debug Panel. Create a new Temporary State named 'voiceTranscript' with a default value of an empty string. You will write the recognized speech into this state from the JS Query, and the Text Input component will read from it.
Expected result: Temporary State 'voiceTranscript' created with default value ''.
Add a second Temporary State for recording status
You also need to track whether recording is currently active so the UI can show a visual indicator. Create a second Temporary State named 'isRecording' with a default value of false. You will use this to change the button label and color between 'Start Recording' and 'Stop Recording' states, giving the user clear feedback.
Expected result: Temporary State 'isRecording' created with default value false.
Create the startVoiceInput JS Query
Create a new JS Query named 'startVoiceInput'. This query initializes the SpeechRecognition API and wires up event handlers to update the Temporary States. The key callbacks are: onstart (set isRecording to true), onresult (update voiceTranscript with the latest transcript), onerror (handle permission denied and other errors), and onend (set isRecording back to false).
1// JS Query: startVoiceInput2// Check browser support3if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {4 utils.showNotification({5 title: 'Browser not supported',6 description: 'Voice input requires Chrome or Edge. Your current browser does not support the Web Speech API.',7 notificationType: 'error',8 });9 return;10}1112// Create SpeechRecognition instance13const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;14const recognition = new SpeechRecognition();1516// Configuration17recognition.lang = 'en-US'; // Language — change to 'es-ES', 'fr-FR', etc. as needed18recognition.interimResults = true; // Show in-progress text while still speaking19recognition.maxAlternatives = 1; // Return only the top recognition result20recognition.continuous = false; // Stop automatically after a pause in speech2122// Store recognition on window so stopVoiceInput query can access it23window._retoolSpeechRecognition = recognition;2425recognition.onstart = () => {26 isRecording.setValue(true);27 utils.showNotification({28 title: 'Listening...',29 description: 'Speak now. Recording will stop automatically after a pause.',30 notificationType: 'info',31 duration: 3,32 });33};3435recognition.onresult = (event) => {36 let interimTranscript = '';37 let finalTranscript = '';3839 // Iterate over all results40 for (let i = event.resultIndex; i < event.results.length; i++) {41 const transcript = event.results[i][0].transcript;42 if (event.results[i].isFinal) {43 finalTranscript += transcript;44 } else {45 interimTranscript += transcript;46 }47 }4849 // Update state with current transcript (final preferred, interim as fallback)50 const currentText = voiceTranscript.value || '';51 const newText = currentText + (finalTranscript || interimTranscript);52 voiceTranscript.setValue(newText);53};5455recognition.onerror = (event) => {56 isRecording.setValue(false);5758 const errorMessages = {59 'not-allowed': 'Microphone access denied. Click the camera/mic icon in your browser address bar to allow microphone access.',60 'no-speech': 'No speech detected. Make sure your microphone is working and try again.',61 'network': 'Network error during speech recognition. Check your internet connection.',62 'aborted': 'Recording was cancelled.',63 };6465 const message = errorMessages[event.error] || `Speech recognition error: ${event.error}`;66 utils.showNotification({67 title: 'Voice input error',68 description: message,69 notificationType: 'error',70 });71};7273recognition.onend = () => {74 isRecording.setValue(false);75};7677// Start recording78recognition.start();Expected result: JS Query created. Running it starts the microphone and begins updating voiceTranscript with recognized speech.
Create the stopVoiceInput JS Query
Create a second JS Query named 'stopVoiceInput'. This query retrieves the SpeechRecognition instance stored on window in the startVoiceInput query and calls recognition.stop(). The onend callback in startVoiceInput will automatically set isRecording to false when the recognition stops.
1// JS Query: stopVoiceInput2// Stops the active SpeechRecognition session3const recognition = window._retoolSpeechRecognition;45if (recognition) {6 recognition.stop();7 window._retoolSpeechRecognition = null;8} else {9 utils.showNotification({10 title: 'Not recording',11 description: 'No active recording session to stop.',12 notificationType: 'warning',13 });14}Expected result: stopVoiceInput query stops the active recording session when triggered.
Configure the microphone button
Add a Button component to your app. Set the button's label using a conditional expression: {{ isRecording.value ? 'Stop Recording' : 'Start Recording' }}. Set the button color to red when recording: use the button's Style tab to set background color using {{ isRecording.value ? '#ef4444' : '#6366f1' }}. Add an event handler for the onClick event: when isRecording is false, trigger startVoiceInput; when isRecording is true, trigger stopVoiceInput. Use two separate event handlers with 'Only run when' conditions.
1// Button label expression:2{{ isRecording.value ? '🎙 Stop Recording' : '🎤 Start Recording' }}34// Event Handler 1: Start recording5// Trigger: startVoiceInput6// Only run when: {{ !isRecording.value }}78// Event Handler 2: Stop recording9// Trigger: stopVoiceInput10// Only run when: {{ isRecording.value }}Expected result: Button shows 'Start Recording' by default. Clicking starts recognition. Button turns red and shows 'Stop Recording'. Clicking again stops recording.
Bind the Text Input to the voiceTranscript state
Select your Text Input component. In the Inspector, set the Default value to {{ voiceTranscript.value }}. This makes the text input reflect whatever the voice recognition has transcribed. Because it's a Text Input (not read-only), users can also edit the transcribed text before submitting the form — this is important since speech recognition is rarely 100% accurate.
Expected result: Text Input shows the live transcription as the user speaks and remains editable for corrections.
Add a language selector for multi-language support
If your app needs to support multiple languages, add a Select component that lets users choose their language before recording. Store the selection in a Temporary State named 'recognitionLang' with default value 'en-US'. In the startVoiceInput query, change the hardcoded 'en-US' to {{ recognitionLang.value }}. Common language codes: 'en-US' (English US), 'en-GB' (English UK), 'es-ES' (Spanish), 'fr-FR' (French), 'de-DE' (German), 'ja-JP' (Japanese), 'zh-CN' (Mandarin).
1// In startVoiceInput, replace the lang line with:2recognition.lang = recognitionLang.value || 'en-US';34// Select component options:5// [6// { label: 'English (US)', value: 'en-US' },7// { label: 'English (UK)', value: 'en-GB' },8// { label: 'Spanish', value: 'es-ES' },9// { label: 'French', value: 'fr-FR' },10// { label: 'German', value: 'de-DE' },11// { label: 'Japanese', value: 'ja-JP' }12// ]Expected result: Language selector controls which language the speech recognizer targets.
Handle continuous recording mode for long inputs
By default, recognition.continuous = false stops automatically after a pause. For use cases like voice notes or long dictation, set continuous to true. In continuous mode, the recognition does not stop after a pause — you must call recognition.stop() explicitly. Update the startVoiceInput query to use continuous = true for long-form input, and instruct users to click 'Stop Recording' when finished.
1// For long-form dictation, change:2recognition.continuous = true; // Don't stop on pause3recognition.interimResults = true;45// In onresult, accumulate results across multiple speech segments:6recognition.onresult = (event) => {7 let fullTranscript = '';8 for (let i = 0; i < event.results.length; i++) {9 if (event.results[i].isFinal) {10 fullTranscript += event.results[i][0].transcript + ' ';11 }12 }13 voiceTranscript.setValue(fullTranscript.trim());14};Expected result: In continuous mode, recognition continues across multiple speech segments until the user manually stops it.
Complete working example
1// JS Query: startVoiceInput2// Complete voice input implementation with language selection,3// interim results, error handling, and continuous mode toggle45// --- Configuration ---6const USE_CONTINUOUS_MODE = false; // Set to true for long-form dictation7const LANG = recognitionLang?.value || 'en-US';89// --- Browser support check ---10if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {11 utils.showNotification({12 title: 'Browser not supported',13 description: 'Voice input requires Chrome or Edge. Firefox and Safari do not support the Web Speech API.',14 notificationType: 'error',15 });16 return;17}1819// If already recording, stop instead of starting again20if (isRecording.value && window._retoolSpeechRecognition) {21 window._retoolSpeechRecognition.stop();22 return;23}2425// --- Create recognition instance ---26const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;27const recognition = new SpeechRecognition();2829recognition.lang = LANG;30recognition.interimResults = true;31recognition.maxAlternatives = 1;32recognition.continuous = USE_CONTINUOUS_MODE;3334// Store reference for stopVoiceInput query35window._retoolSpeechRecognition = recognition;3637// --- Event handlers ---38recognition.onstart = () => {39 isRecording.setValue(true);40};4142recognition.onresult = (event) => {43 let finalTranscript = '';44 let interimTranscript = '';4546 for (let i = event.resultIndex; i < event.results.length; i++) {47 const text = event.results[i][0].transcript;48 if (event.results[i].isFinal) {49 finalTranscript += text;50 } else {51 interimTranscript += text;52 }53 }5455 if (finalTranscript) {56 // Append final result to existing transcript57 const existing = voiceTranscript.value || '';58 voiceTranscript.setValue((existing + ' ' + finalTranscript).trim());59 } else if (interimTranscript) {60 // Show interim results (overwritten when final arrives)61 // NOTE: For interim-only display, use a separate interimTranscript state62 // This avoids polluting the final transcript with partial results63 }64};6566recognition.onerror = (event) => {67 isRecording.setValue(false);68 window._retoolSpeechRecognition = null;6970 const errorMessages = {71 'not-allowed': 'Microphone access was denied. Click the lock icon in your browser address bar and allow microphone access, then try again.',72 'no-speech': 'No speech was detected. Ensure your microphone is working and you are speaking clearly.',73 'audio-capture': 'No microphone found. Connect a microphone and try again.',74 'network': 'A network error occurred during recognition. Check your internet connection.',75 'aborted': 'Recording stopped.',76 'service-not-allowed': 'Speech recognition service is not allowed. This may occur on HTTP (non-HTTPS) pages.',77 };7879 const message = errorMessages[event.error] || `Recognition error: ${event.error}`;80 utils.showNotification({81 title: 'Voice input failed',82 description: message,83 notificationType: event.error === 'aborted' ? 'warning' : 'error',84 });85};8687recognition.onend = () => {88 isRecording.setValue(false);89 window._retoolSpeechRecognition = null;9091 if (voiceTranscript.value) {92 utils.showNotification({93 title: 'Recording complete',94 description: `Transcribed: "${voiceTranscript.value.slice(0, 60)}${voiceTranscript.value.length > 60 ? '...' : ''}"`,95 notificationType: 'success',96 duration: 3,97 });98 }99};100101// --- Start recording ---102try {103 recognition.start();104} catch (err) {105 isRecording.setValue(false);106 utils.showNotification({107 title: 'Could not start recording',108 description: err.message,109 notificationType: 'error',110 });111}Common mistakes
Why it's a problem: Trying to set a Text Input's value directly from the JS Query using textInput1.setValue() and expecting it to persist
How to avoid: Text Input components in Retool do not have a setValue() method. Instead, use a Temporary State (voiceTranscript.setValue()) and bind the Text Input's Default value to {{ voiceTranscript.value }}. The input will update reactively.
Why it's a problem: Creating a new SpeechRecognition instance every time the user clicks the button without stopping the previous one, causing multiple simultaneous recording sessions
How to avoid: Check if a recognition instance already exists on window._retoolSpeechRecognition before creating a new one. If one is running, call .stop() first. The onend callback cleans up the window reference.
Why it's a problem: Setting interimResults = true and writing every interim result to the final transcript state, causing duplicated words as interim results are replaced by final ones
How to avoid: In the onresult handler, only append to the final transcript state when event.results[i].isFinal is true. Use a separate state or ignore interim results for the value that gets submitted.
Why it's a problem: Not handling the 'not-allowed' error, leaving users confused when microphone permission is denied
How to avoid: In the onerror handler, specifically handle event.error === 'not-allowed' with instructions to click the browser lock icon and enable microphone permissions for the Retool domain.
Best practices
- Always check for Web Speech API support before calling it — show a clear error message if the user is on an unsupported browser
- Store the SpeechRecognition instance on window._retoolSpeechRecognition so multiple JS Queries (start and stop) can share the same instance
- Use Temporary State for the transcript rather than trying to set a Text Input's value directly — Text Input values cannot be set programmatically in Retool without a default value binding
- Set recognition.continuous = false for short inputs (search fields, single-line forms) and true only for long dictation — continuous mode requires explicit stop and uses more resources
- Show a visual recording indicator (button color change, animated icon) so users know when the microphone is active — silence during recording is confusing
- Always allow users to edit the transcribed text before submitting — Web Speech API accuracy is ~95% in ideal conditions but lower in noisy environments or with accents
- For HTTPS requirement: Web Speech API requires HTTPS in production. Retool Cloud is always HTTPS. Self-hosted Retool must use SSL.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a Retool app and want to add voice input to a form using the Web Speech API (no external API keys). I need: (1) a JS Query that initializes SpeechRecognition, starts/stops recording, and writes the transcript to a Retool Temporary State called 'voiceTranscript', (2) how to bind a Text Input's Default value to voiceTranscript.value so it updates as the user speaks, (3) a button that shows 'Start Recording' or 'Stop Recording' based on a 'isRecording' Temporary State, (4) error handling for the 'not-allowed' (microphone denied) case. The app runs in Chrome.
Create Retool voice input: 1) Temporary States: voiceTranscript (string, default ''), isRecording (boolean, default false). 2) JS Query 'startVoiceInput': new (window.SpeechRecognition || window.webkitSpeechRecognition)(), set lang='en-US', interimResults=true, onresult updates voiceTranscript.setValue(), onend sets isRecording.setValue(false). 3) Button label {{ isRecording.value ? 'Stop' : 'Start' }}, event handlers trigger startVoiceInput or stopVoiceInput based on isRecording. 4) Text Input Default value={{ voiceTranscript.value }}.
Frequently asked questions
Does the Web Speech API work in Firefox or Safari?
Firefox does not implement the Web Speech API — voice input using this tutorial will not work for Firefox users. Safari has experimental support behind a flag but it's unreliable. For cross-browser voice input, use a Custom Component wrapping a third-party speech-to-text API like OpenAI Whisper or AssemblyAI, which work in all modern browsers via HTTP requests.
Does voice input require any API keys or external services?
No — the Web Speech API is built into Chrome and Edge and uses Google's speech recognition service internally, but this is transparent to you. There are no API keys, no usage limits you need to manage, and no external service to configure. It works as long as the user's browser supports it and they grant microphone permission.
How accurate is the Web Speech API transcription?
The Web Speech API uses Google's speech recognition engine in Chrome. Accuracy is typically 90-95% for clear speech in a quiet environment with a standard US English accent. Accuracy decreases with background noise, non-native accents, technical jargon, or proper nouns. Always allow users to edit the transcription before submitting — provide a Text Input (not a read-only display) so corrections are easy.
Can I use voice input to search a table or trigger a query directly?
Yes — instead of binding the transcript to a Text Input, you can use the onresult callback to directly trigger a Retool query when a final transcript arrives. For example, when finalTranscript is captured, call searchQuery.trigger({ additionalScope: { searchTerm: finalTranscript } }) to immediately execute a search. This creates a hands-free search experience.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation