Speech Input
Dictation button using the Web Speech API (MediaRecorder fallback) with listening and processing states.
import { SpeechInput } from "@/components/ai/speech-input";import { useState } from "react";
export default function Demo() { const [text, setText] = useState(""); return ( <div className="flex flex-col items-start gap-3"> <div className="flex items-center gap-3"> <SpeechInput onTranscriptionChange={(t) => setText((prev) => `${prev} ${t}`.trim()) } // Browsers without the Web Speech API (Firefox) record and call this to transcribe. onAudioRecorded={async () => "Rebalance MAG4 when drift is above 2%."} /> <span className="text-xs text-muted-foreground"> Click to dictate (Chrome, Edge, Safari) or record. </span> </div> <p className="min-h-5 text-sm">{text}</p> </div> );}<script setup lang="ts">import { SpeechInput } from "@/components/ai/speech-input";import { ref } from "vue";
const text = ref("");// Browsers without the Web Speech API (Firefox) record and call this to transcribe.const transcribe = async () => "Rebalance MAG4 when drift is above 2%.";</script>
<template> <div class="flex flex-col items-start gap-3"> <div class="flex items-center gap-3"> <SpeechInput :on-audio-recorded="transcribe" @transcription-change="(t) => (text = `${text} ${t}`.trim())" /> <span class="text-xs text-muted-foreground">Click to dictate (Chrome, Edge, Safari) or record.</span> </div> <p class="min-h-5 text-sm">{{ text }}</p> </div></template><script lang="ts"> import { SpeechInput } from "$lib/components/ai/speech-input";
let text = $state(""); // Browsers without the Web Speech API (Firefox) record and call this to transcribe. const transcribe = async () => "Rebalance MAG4 when drift is above 2%.";</script>
<div class="flex flex-col items-start gap-3"> <div class="flex items-center gap-3"> <SpeechInput onTranscriptionChange={(t) => (text = `${text} ${t}`.trim())} onAudioRecorded={transcribe} /> <span class="text-xs text-muted-foreground">Click to dictate (Chrome, Edge, Safari) or record.</span> </div> <p class="min-h-5 text-sm">{text}</p></div>Built on button and spinner.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-speech-inputpnpm dlx shadcn@latest add @edmi-ui/ai-speech-inputyarn dlx shadcn@latest add @edmi-ui/ai-speech-inputbunx shadcn@latest add @edmi-ui/ai-speech-inputPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,spinner,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-speech-inputpnpm dlx shadcn-vue@latest add @edmi-ui/ai-speech-inputyarn dlx shadcn-vue@latest add @edmi-ui/ai-speech-inputbunx shadcn-vue@latest add @edmi-ui/ai-speech-inputPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @lucide/vueTerminal window pnpm add @lucide/vueTerminal window yarn add @lucide/vueTerminal window bun add @lucide/vueAdd the Edmi components this one builds on:
button,spinner,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,spinner,elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { SpeechInput } from "@/components/ai/speech-input";
<div className="flex flex-col items-start gap-3"> <div className="flex items-center gap-3"> <SpeechInput // Browsers without the Web Speech API (Firefox) record and call this to transcribe. onAudioRecorded={async () => "Rebalance MAG4 when drift is above 2%."} /> <span className="text-xs text-muted-foreground"> Click to dictate (Chrome, Edge, Safari) or record. </span> </div> <p className="min-h-5 text-sm"></p></div><script setup lang="ts">import { SpeechInput } from "@/components/ai/speech-input";</script>
<template> <div class="flex flex-col items-start gap-3"> <div class="flex items-center gap-3"> <SpeechInput /> <span class="text-xs text-muted-foreground">Click to dictate (Chrome, Edge, Safari) or record.</span> </div> <p class="min-h-5 text-sm"></p> </div></template><script lang="ts"> import { SpeechInput } from "$lib/components/ai/speech-input";</script>
<div class="flex flex-col items-start gap-3"> <div class="flex items-center gap-3"> <SpeechInput /> <span class="text-xs text-muted-foreground">Click to dictate (Chrome, Edge, Safari) or record.</span> </div> <p class="min-h-5 text-sm"></p></div>Component source
Section titled “Component source”"use client";
import { cn } from "cn";import type { ComponentProps } from "react";import { useCallback, useEffect, useRef, useState } from "react";import { StopIcon, MicrophoneIcon } from "@phosphor-icons/react";import { Button } from "@/registry/edmi/ui/button";import { Spinner } from "@/registry/edmi/ui/spinner";
// Built on ui/button (DESIGN §5b): outline icon button; listening = brand fill with a solid soft ring and// two pulsing outlines, processing = spinner. `elevation` is forwarded to the Button.
interface SpeechRecognition extends EventTarget { continuous: boolean; interimResults: boolean; lang: string; start(): void; stop(): void; onstart: ((this: SpeechRecognition, ev: Event) => void) | null; onend: ((this: SpeechRecognition, ev: Event) => void) | null; onresult: | ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null; onerror: | ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;}
interface SpeechRecognitionEvent extends Event { results: SpeechRecognitionResultList; resultIndex: number;}
interface SpeechRecognitionResultList { readonly length: number; item(index: number): SpeechRecognitionResult; [index: number]: SpeechRecognitionResult;}
interface SpeechRecognitionResult { readonly length: number; item(index: number): SpeechRecognitionAlternative; [index: number]: SpeechRecognitionAlternative; isFinal: boolean;}
interface SpeechRecognitionAlternative { transcript: string; confidence: number;}
interface SpeechRecognitionErrorEvent extends Event { error: string;}
declare global { interface Window { SpeechRecognition: new () => SpeechRecognition; webkitSpeechRecognition: new () => SpeechRecognition; }}
type SpeechInputMode = "speech-recognition" | "media-recorder" | "none";
export type SpeechInputProps = ComponentProps<typeof Button> & { onTranscriptionChange?: (text: string) => void; /** * Callback for when audio is recorded using MediaRecorder fallback. * This is called in browsers that don't support the Web Speech API (Firefox, Safari). * The callback receives an audio Blob that should be sent to a transcription service. * Return the transcribed text, which will be passed to onTranscriptionChange. */ onAudioRecorded?: (audioBlob: Blob) => Promise<string>; lang?: string;};
const detectSpeechInputMode = (): SpeechInputMode => { if (typeof window === "undefined") { return "none"; }
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) { return "speech-recognition"; }
if ("MediaRecorder" in window && "mediaDevices" in navigator) { return "media-recorder"; }
return "none";};
export const SpeechInput = ({ className, variant = "outline", size = "icon", onTranscriptionChange, onAudioRecorded, lang = "en-US", ...props}: SpeechInputProps) => { const [isListening, setIsListening] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [mode] = useState<SpeechInputMode>(detectSpeechInputMode); const [isRecognitionReady, setIsRecognitionReady] = useState(false); const recognitionRef = useRef<SpeechRecognition | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null); const streamRef = useRef<MediaStream | null>(null); const audioChunksRef = useRef<Blob[]>([]); const onTranscriptionChangeRef = useRef< SpeechInputProps["onTranscriptionChange"] >(onTranscriptionChange); const onAudioRecordedRef = useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
// Keep refs in sync onTranscriptionChangeRef.current = onTranscriptionChange; onAudioRecordedRef.current = onAudioRecorded;
// Initialize Speech Recognition when mode is speech-recognition useEffect(() => { if (mode !== "speech-recognition") { return; }
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; const speechRecognition = new SpeechRecognition();
speechRecognition.continuous = true; speechRecognition.interimResults = true; speechRecognition.lang = lang;
const handleStart = () => { setIsListening(true); };
const handleEnd = () => { setIsListening(false); };
const handleResult = (event: Event) => { const speechEvent = event as SpeechRecognitionEvent; let finalTranscript = "";
for ( let i = speechEvent.resultIndex; i < speechEvent.results.length; i += 1 ) { const result = speechEvent.results[i]; if (result.isFinal) { finalTranscript += result[0]?.transcript ?? ""; } }
if (finalTranscript) { onTranscriptionChangeRef.current?.(finalTranscript); } };
const handleError = () => { setIsListening(false); };
speechRecognition.addEventListener("start", handleStart); speechRecognition.addEventListener("end", handleEnd); speechRecognition.addEventListener("result", handleResult); speechRecognition.addEventListener("error", handleError);
recognitionRef.current = speechRecognition; setIsRecognitionReady(true);
return () => { speechRecognition.removeEventListener("start", handleStart); speechRecognition.removeEventListener("end", handleEnd); speechRecognition.removeEventListener("result", handleResult); speechRecognition.removeEventListener("error", handleError); speechRecognition.stop(); recognitionRef.current = null; setIsRecognitionReady(false); }; }, [mode, lang]);
// Cleanup MediaRecorder and stream on unmount useEffect( () => () => { if (mediaRecorderRef.current?.state === "recording") { mediaRecorderRef.current.stop(); } if (streamRef.current) { for (const track of streamRef.current.getTracks()) { track.stop(); } } }, [], );
// Start MediaRecorder recording const startMediaRecorder = useCallback(async () => { if (!onAudioRecordedRef.current) { return; }
try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; const mediaRecorder = new MediaRecorder(stream); audioChunksRef.current = [];
const handleDataAvailable = (event: BlobEvent) => { if (event.data.size > 0) { audioChunksRef.current.push(event.data); } };
const handleStop = async () => { for (const track of stream.getTracks()) { track.stop(); } streamRef.current = null;
const audioBlob = new Blob(audioChunksRef.current, { type: "audio/webm", });
if (audioBlob.size > 0 && onAudioRecordedRef.current) { setIsProcessing(true); try { const transcript = await onAudioRecordedRef.current(audioBlob); if (transcript) { onTranscriptionChangeRef.current?.(transcript); } } catch { // Error handling delegated to the onAudioRecorded caller } finally { setIsProcessing(false); } } };
const handleError = () => { setIsListening(false); for (const track of stream.getTracks()) { track.stop(); } streamRef.current = null; };
mediaRecorder.addEventListener("dataavailable", handleDataAvailable); mediaRecorder.addEventListener("stop", handleStop); mediaRecorder.addEventListener("error", handleError);
mediaRecorderRef.current = mediaRecorder; mediaRecorder.start(); setIsListening(true); } catch { setIsListening(false); } }, []);
// Stop MediaRecorder recording const stopMediaRecorder = useCallback(() => { if (mediaRecorderRef.current?.state === "recording") { mediaRecorderRef.current.stop(); } setIsListening(false); }, []);
const toggleListening = useCallback(() => { if (mode === "speech-recognition" && recognitionRef.current) { if (isListening) { recognitionRef.current.stop(); } else { recognitionRef.current.start(); } } else if (mode === "media-recorder") { if (isListening) { stopMediaRecorder(); } else { startMediaRecorder(); } } }, [mode, isListening, startMediaRecorder, stopMediaRecorder]);
// Determine if button should be disabled const isDisabled = mode === "none" || (mode === "speech-recognition" && !isRecognitionReady) || (mode === "media-recorder" && !onAudioRecorded) || isProcessing;
return ( <div data-slot="ai-speech-input" data-state={ isProcessing ? "processing" : isListening ? "listening" : "idle" } className="relative inline-flex items-center justify-center" > {/* Pulsing outlines while listening */} {isListening && [0, 1].map((index) => ( <span aria-hidden="true" className="pointer-events-none absolute inset-0 animate-ping rounded-md border-2 border-brand" key={index} style={{ animationDelay: `${index * 0.5}s`, animationDuration: "2s", }} /> ))}
<Button aria-label={isListening ? "Stop dictation" : "Start dictation"} aria-pressed={isListening} className={cn( "relative z-10", isListening && "ring-[5px] ring-[color-mix(in_srgb,var(--brand)_22%,var(--background))]", className, )} disabled={isDisabled} onClick={toggleListening} size={size} variant={isListening ? "brand" : variant} {...props} > {isProcessing && <Spinner />} {!isProcessing && isListening && ( <StopIcon className="size-3.5" /> )} {!(isProcessing || isListening) && ( <MicrophoneIcon className="size-4" /> )} </Button> </div> );};<script setup lang="ts">import type { HTMLAttributes } from "vue"import type { ButtonVariants } from "@/registry/edmi/ui/button"import { AudioLinesIcon } from "@lucide/vue"import { computed, onMounted, onUnmounted, ref, useAttrs, watch } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import type { Elevation } from "@/registry/edmi/ui/elevation"import { Spinner } from "@/registry/edmi/ui/spinner"
// Built on ui/button (DESIGN §5b): outline icon button; listening = brand fill with a solid soft ring and// two pulsing outlines, processing = spinner. `elevation` is forwarded to the Button.defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{ class?: HTMLAttributes["class"] variant?: ButtonVariants["variant"] size?: ButtonVariants["size"] /** ✦ depth: sunken -1, flat 0, raised +1, floating +2 (follows the Button rules). */ elevation?: Elevation /** * MediaRecorder fallback for browsers without the Web Speech API (Firefox): receives the recorded audio and * returns the transcript, which is emitted as `transcriptionChange`. */ onAudioRecorded?: (audioBlob: Blob) => Promise<string> lang?: string}>(), { variant: "outline", size: "icon", elevation: undefined, lang: "en-US", onAudioRecorded: undefined,})
const emit = defineEmits<{ (e: "transcriptionChange", text: string): void}>()
interface SpeechRecognitionInstance extends EventTarget { continuous: boolean interimResults: boolean lang: string start: () => void stop: () => void}
interface SpeechRecognitionResultCustom { readonly length: number [index: number]: { transcript: string, confidence: number } isFinal: boolean}
interface SpeechRecognitionEventCustom extends Event { results: ArrayLike<SpeechRecognitionResultCustom> resultIndex: number}
type SpeechRecognitionConstructor = new () => SpeechRecognitionInstancetype SpeechInputMode = "speech-recognition" | "media-recorder" | "none"
const isListening = ref(false)const isProcessing = ref(false)const mode = ref<SpeechInputMode>("none")const recognition = ref<SpeechRecognitionInstance | null>(null)let mediaRecorder: MediaRecorder | null = nulllet mediaStream: MediaStream | null = nulllet audioChunks: Blob[] = []
function detectSpeechInputMode(): SpeechInputMode { if (typeof window === "undefined") return "none" if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) return "speech-recognition" if ("MediaRecorder" in window && "mediaDevices" in navigator) return "media-recorder" return "none"}
onMounted(() => { mode.value = detectSpeechInputMode()})
// Create the recognizer when the mode or the language changes.watch([mode, () => props.lang], ([newMode, newLang]) => { recognition.value?.stop() recognition.value = null if (newMode !== "speech-recognition") return
const w = window as unknown as Record<string, SpeechRecognitionConstructor | undefined> const Ctor = (w.SpeechRecognition ?? w.webkitSpeechRecognition) as SpeechRecognitionConstructor const speechRecognition = new Ctor() speechRecognition.continuous = true speechRecognition.interimResults = true speechRecognition.lang = newLang
speechRecognition.addEventListener("start", () => { isListening.value = true }) speechRecognition.addEventListener("end", () => { isListening.value = false }) speechRecognition.addEventListener("error", () => { isListening.value = false }) speechRecognition.addEventListener("result", (event) => { const speechEvent = event as SpeechRecognitionEventCustom let finalTranscript = "" for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i += 1) { const result = speechEvent.results[i] if (result?.isFinal) finalTranscript += result[0]?.transcript ?? "" } if (finalTranscript) emit("transcriptionChange", finalTranscript) })
recognition.value = speechRecognition}, { immediate: true })
function releaseStream() { if (mediaStream) { for (const track of mediaStream.getTracks()) track.stop() mediaStream = null }}
onUnmounted(() => { recognition.value?.stop() if (mediaRecorder?.state === "recording") mediaRecorder.stop() releaseStream()})
async function startMediaRecorder() { if (!props.onAudioRecorded) return try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) mediaStream = stream const recorder = new MediaRecorder(stream) audioChunks = []
recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) audioChunks.push(event.data) })
recorder.addEventListener("stop", async () => { releaseStream() const audioBlob = new Blob(audioChunks, { type: "audio/webm" }) if (audioBlob.size > 0 && props.onAudioRecorded) { isProcessing.value = true try { const transcript = await props.onAudioRecorded(audioBlob) if (transcript) emit("transcriptionChange", transcript) } catch { // Error handling is delegated to the onAudioRecorded caller } finally { isProcessing.value = false } } })
recorder.addEventListener("error", () => { isListening.value = false releaseStream() })
mediaRecorder = recorder recorder.start() isListening.value = true } catch { isListening.value = false }}
function stopMediaRecorder() { if (mediaRecorder?.state === "recording") mediaRecorder.stop() isListening.value = false}
function toggleListening() { if (mode.value === "speech-recognition" && recognition.value) { if (isListening.value) recognition.value.stop() else recognition.value.start() } else if (mode.value === "media-recorder") { if (isListening.value) stopMediaRecorder() else startMediaRecorder() }}
const attrs = useAttrs()const isDisabled = computed(() => (attrs.disabled !== undefined && attrs.disabled !== false) || mode.value === "none" || (mode.value === "speech-recognition" && !recognition.value) || (mode.value === "media-recorder" && !props.onAudioRecorded) || isProcessing.value,)
const state = computed(() => (isProcessing.value ? "processing" : isListening.value ? "listening" : "idle"))</script>
<template> <div data-slot="ai-speech-input" :data-state="state" class="relative inline-flex items-center justify-center" > <!-- Pulsing outlines while listening --> <template v-if="isListening"> <span v-for="index in [0, 1]" :key="index" aria-hidden="true" class="pointer-events-none absolute inset-0 animate-ping rounded-md border-2 border-brand" :style="{ animationDelay: `${index * 0.5}s`, animationDuration: '2s' }" /> </template>
<Button v-bind="$attrs" :aria-label="isListening ? 'Stop dictation' : 'Start dictation'" :aria-pressed="isListening" :variant="isListening ? 'brand' : props.variant" :size="props.size" :elevation="props.elevation" :disabled="isDisabled" :class="cn( 'relative z-10', isListening && 'ring-[5px] ring-[color-mix(in_srgb,var(--brand)_22%,var(--background))]', props.class, )" @click="toggleListening" > <Spinner v-if="isProcessing" /> <svg v-else-if="isListening" class="size-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" > <rect x="5" y="5" width="14" height="14" rx="2" /> </svg> <AudioLinesIcon v-else class="size-4" /> </Button> </div></template>export { default as SpeechInput } from "./SpeechInput.vue";<script lang="ts"> import StopIcon from "phosphor-svelte/lib/Stop"; import MicrophoneIcon from "phosphor-svelte/lib/Microphone"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { Spinner } from "$lib/registry/ui/spinner/index.js"; import { cn } from "$lib/utils.js"; import { onDestroy, onMount } from "svelte";
// Built on ui/button (DESIGN §5b): outline icon button; listening = brand fill with a solid soft ring and // two pulsing outlines, processing = spinner. `elevation` is forwarded to the Button (Button props). let { class: className, variant = "outline", size = "icon", disabled = false, lang = "en-US", onTranscriptionChange, onAudioRecorded, ...restProps }: Omit<ButtonProps, "onclick" | "href"> & { onTranscriptionChange?: (text: string) => void; /** * MediaRecorder fallback for browsers without the Web Speech API (Firefox): receives the recorded audio * and returns the transcript, which is passed to `onTranscriptionChange`. */ onAudioRecorded?: (audioBlob: Blob) => Promise<string>; lang?: string; } = $props();
interface SpeechRecognitionInstance extends EventTarget { continuous: boolean; interimResults: boolean; lang: string; start: () => void; stop: () => void; } interface SpeechRecognitionResultCustom { readonly length: number; [index: number]: { transcript: string; confidence: number }; isFinal: boolean; } interface SpeechRecognitionEventCustom extends Event { results: ArrayLike<SpeechRecognitionResultCustom>; resultIndex: number; } type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance; type SpeechInputMode = "speech-recognition" | "media-recorder" | "none";
let isListening = $state(false); let isProcessing = $state(false); let mode = $state<SpeechInputMode>("none"); let recognition = $state<SpeechRecognitionInstance | null>(null); let mediaRecorder: MediaRecorder | null = null; let mediaStream: MediaStream | null = null; let audioChunks: Blob[] = [];
function detectMode(): SpeechInputMode { if (typeof window === "undefined") return "none"; if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) return "speech-recognition"; if ("MediaRecorder" in window && "mediaDevices" in navigator) return "media-recorder"; return "none"; }
onMount(() => { mode = detectMode(); });
// Create the recognizer when the mode or the language changes. $effect(() => { if (mode !== "speech-recognition") return; const w = window as unknown as Record<string, SpeechRecognitionConstructor | undefined>; const Ctor = (w.SpeechRecognition ?? w.webkitSpeechRecognition) as SpeechRecognitionConstructor; const speechRecognition = new Ctor(); speechRecognition.continuous = true; speechRecognition.interimResults = true; speechRecognition.lang = lang;
const onStart = () => (isListening = true); const onEnd = () => (isListening = false); const onResult = (event: Event) => { const speechEvent = event as SpeechRecognitionEventCustom; let finalTranscript = ""; for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i += 1) { const result = speechEvent.results[i]; if (result?.isFinal) finalTranscript += result[0]?.transcript ?? ""; } if (finalTranscript) onTranscriptionChange?.(finalTranscript); }; speechRecognition.addEventListener("start", onStart); speechRecognition.addEventListener("end", onEnd); speechRecognition.addEventListener("error", onEnd); speechRecognition.addEventListener("result", onResult); recognition = speechRecognition;
return () => { speechRecognition.removeEventListener("start", onStart); speechRecognition.removeEventListener("end", onEnd); speechRecognition.removeEventListener("error", onEnd); speechRecognition.removeEventListener("result", onResult); speechRecognition.stop(); recognition = null; }; });
function releaseStream() { if (mediaStream) { for (const track of mediaStream.getTracks()) track.stop(); mediaStream = null; } }
onDestroy(() => { if (mediaRecorder?.state === "recording") mediaRecorder.stop(); releaseStream(); });
async function startMediaRecorder() { if (!onAudioRecorded) return; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaStream = stream; const recorder = new MediaRecorder(stream); audioChunks = []; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) audioChunks.push(event.data); }); recorder.addEventListener("stop", async () => { releaseStream(); const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); if (audioBlob.size > 0 && onAudioRecorded) { isProcessing = true; try { const transcript = await onAudioRecorded(audioBlob); if (transcript) onTranscriptionChange?.(transcript); } catch { // Error handling is delegated to the onAudioRecorded caller } finally { isProcessing = false; } } }); recorder.addEventListener("error", () => { isListening = false; releaseStream(); }); mediaRecorder = recorder; recorder.start(); isListening = true; } catch { isListening = false; } }
function stopMediaRecorder() { if (mediaRecorder?.state === "recording") mediaRecorder.stop(); isListening = false; }
function toggleListening() { if (mode === "speech-recognition" && recognition) { if (isListening) recognition.stop(); else recognition.start(); } else if (mode === "media-recorder") { if (isListening) stopMediaRecorder(); else startMediaRecorder(); } }
const isDisabled = $derived( disabled || mode === "none" || (mode === "speech-recognition" && !recognition) || (mode === "media-recorder" && !onAudioRecorded) || isProcessing ); const stateName = $derived(isProcessing ? "processing" : isListening ? "listening" : "idle");</script>
<div data-slot="ai-speech-input" data-state={stateName} class="relative inline-flex items-center justify-center"> <!-- Pulsing outlines while listening --> {#if isListening} {#each [0, 1] as index (index)} <span aria-hidden="true" class="pointer-events-none absolute inset-0 animate-ping rounded-md border-2 border-brand" style="animation-delay: {index * 0.5}s; animation-duration: 2s;" ></span> {/each} {/if}
<Button aria-label={isListening ? "Stop dictation" : "Start dictation"} aria-pressed={isListening} variant={isListening ? "brand" : variant} {size} disabled={isDisabled} class={cn( "relative z-10", isListening && "ring-[5px] ring-[color-mix(in_srgb,var(--brand)_22%,var(--background))]", className )} onclick={toggleListening} {...restProps} > {#if isProcessing} <Spinner /> {:else if isListening} <StopIcon class="size-3.5" /> {:else} <MicrophoneIcon class="size-4" /> {/if} </Button></div>import SpeechInput from "./speech-input.svelte";
export { SpeechInput };Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
The speech button is a Button, so it follows the Button levels: flat by default, sunken (-1), raised (+1) and floating (+2).
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
Sunken (-1)
Flat (0)
Raised (+1)
Floating (+2)
import { SpeechInput } from "@/components/ai/speech-input";
const levels = [ { value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" },] as const;
export default function Demo() { return ( <div className="flex w-full max-w-2xl flex-col gap-4"> {levels.map(({ value, label }) => ( <div key={value} className="flex items-center gap-4"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <div className="flex items-center gap-3"> <SpeechInput elevation={value} onAudioRecorded={async () => ""} /> <SpeechInput elevation={value} variant="default" onAudioRecorded={async () => ""} /> </div> </div> ))} </div> );}<script setup lang="ts">import { SpeechInput } from "@/components/ai/speech-input";
const noop = async () => "";
const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }];</script>
<template> <div class="flex w-full max-w-2xl flex-col gap-5"> <div v-for="level in levels" :key="level.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ level.label }}</p> <div class="flex items-center gap-3"> <SpeechInput :elevation="level.value" :on-audio-recorded="noop" /> <SpeechInput :elevation="level.value" variant="default" :on-audio-recorded="noop" /> </div> </div> </div></template><script lang="ts"> import { SpeechInput } from "$lib/components/ai/speech-input";
const noop = async () => ""; const levels = [{ value: "sunken", label: "Sunken (-1)" }, { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" }, { value: "floating", label: "Floating (+2)" }] as const;</script>
<div class="flex w-full max-w-2xl flex-col gap-5"> {#each levels as level (level.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{level.label}</p> <div class="flex items-center gap-3"> <SpeechInput elevation={level.value} onAudioRecorded={noop} /> <SpeechInput elevation={level.value} variant="default" onAudioRecorded={noop} /> </div> </div> {/each}</div>| Prop | Type | Default |
|---|---|---|
onTranscriptionChange |
(text: string) => void |
|
onAudioRecorded |
(blob: Blob) => Promise<string> (MediaRecorder fallback: return the transcript) |
|
lang |
string |
en-US |
variant, size |
Button props | outline, icon |
elevation |
"auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
✦ Edmi additions
Section titled “✦ Edmi additions”- States follow the board: outline default,
--brandfill with a solid soft ring and pulsing outlines while listening, spinner while processing, 45% opacity when disabled.
Framework notes
Section titled “Framework notes”- Vue:
transcriptionChangeevent andon-audio-recordedprop. Svelte:onTranscriptionChangeandonAudioRecordedprops. The mic icon in Vue isAudioLinesIcon(the shadcn-vue icon map has no mic icon; with the Phosphor default it renders a microphone).