Skip to content
edmi-ui

Speech Input

Dictation button using the Web Speech API (MediaRecorder fallback) with listening and processing states.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Click to dictate (Chrome, Edge, Safari) or record.

Click to dictate (Chrome, Edge, Safari) or record.

Click to dictate (Chrome, Edge, Safari) or record.

Built on button and spinner.

Terminal window
npx shadcn@latest add @edmi-ui/ai-speech-input
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-speech-input
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-speech-input
Terminal window
bunx shadcn@latest add @edmi-ui/ai-speech-input

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-speech-input
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-speech-input
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-speech-input
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-speech-input

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-speech-input.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/speech-input.tsx
"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>
);
};
registry/components/ai/speech-input/SpeechInput.vue
<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 () => SpeechRecognitionInstance
type 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 = null
let mediaStream: MediaStream | null = null
let 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>
registry/components/ai/speech-input/index.ts
export { default as SpeechInput } from "./SpeechInput.vue";
src/lib/registry/ai/speech-input/speech-input.svelte
<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>
src/lib/registry/ai/speech-input/index.ts
import SpeechInput from "./speech-input.svelte";
export { SpeechInput };

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).

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

Sunken (-1)

Flat (0)

Raised (+1)

Floating (+2)

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"
  • States follow the board: outline default, --brand fill with a solid soft ring and pulsing outlines while listening, spinner while processing, 45% opacity when disabled.
  • Vue: transcriptionChange event and on-audio-recorded prop. Svelte: onTranscriptionChange and onAudioRecorded props. The mic icon in Vue is AudioLinesIcon (the shadcn-vue icon map has no mic icon; with the Phosphor default it renders a microphone).