- {attachments.map((att, i) => (
-
-
-
{Math.round(att.duration)}s
-
-
- ))}
+export const Sizes: Story = {
+ render: () => (
+
+
+
Sizes
+
+ Three sizes for different contexts and layouts.
+
+
+
+ {(['sm', 'md', 'lg'] as RecordButtonSize[]).map((size) => (
+
+
+
+
{size.toUpperCase()}
+
+ size="{size}"
+
+
- )}
-
-
+ ))}
- );
- },
+
+ ),
parameters: {
- docs: {
- description: {
- story: 'Full chat input example with recording attachments.',
- },
- },
+ layout: 'padded',
},
};
// ============================================================================
-// States
+// Recording Animations
// ============================================================================
-export const Disabled: Story = {
- args: {
- disabled: true,
- },
-};
-
-export const WithMaxDuration: Story = {
- args: {
- maxDuration: 10,
- showDuration: true,
- },
+export const RecordingAnimations: Story = {
+ render: () => (
+
+
+
+ Recording Animations
+
+
+ Different visual feedback options for the recording state.
+
+
+
+
+
+
+
Pulse + Stop Icon
+
+ Default recording state with pulse rings
+
+
+
+
+
+
+
Pulse + Waveform
+
+ Shows audio activity with waveform bars
+
+
+
+
+
+
+
Waveform Only
+
+ Subtle animation without pulse rings
+
+
+
+
+
+ ),
parameters: {
- docs: {
- description: {
- story: 'Automatically stops recording after 10 seconds.',
- },
- },
+ layout: 'padded',
},
};
// ============================================================================
-// Callbacks
+// Interactive Demos
// ============================================================================
-export const WithCallbacks: Story = {
- args: {
- showDuration: true,
- onRecordingStart: () => console.log('Recording started'),
- onRecordingComplete: (blob, duration) =>
- console.log('Recording complete:', { blob, duration }),
- onError: (error) => console.error('Error:', error),
- },
+export const InteractiveDemos: Story = {
+ render: () => (
+
+
+
+ Interactive Demos
+
+
+ Try the different interaction patterns.
+
+
+
+
+
+ Toggle Pattern
+
+ Click to toggle
+
+
+
+
+
+
+ Press & Hold Pattern
+
+ Hold to record
+
+
+
+
+
+
+ ),
parameters: {
- docs: {
- description: {
- story: 'Check the console for callback events.',
- },
- },
+ layout: 'padded',
},
};
// ============================================================================
-// Custom Icons
+// With Duration
// ============================================================================
-export const CustomIcons: Story = {
- args: {
- idleIcon: (
-
- ),
- recordingIcon: (
-
- ),
- },
- parameters: {
- docs: {
- description: {
- story: 'Using custom icons for idle and recording states.',
- },
- },
+export const WithDuration: Story = {
+ render: function WithDurationStory() {
+ return (
+
+
+ Click to start recording and see the duration timer.
+
+
+
+ );
},
};
// ============================================================================
-// Transcription Examples
+// In Input Field
// ============================================================================
-/**
- * Batch Transcription Mode
- *
- * Records audio, then sends it to a transcription service and waits for the result.
- * The textarea shows a loading state while transcription is in progress.
- */
-export const BatchTranscription: Story = {
- render: function BatchTranscriptionStory() {
- const [text, setText] = React.useState('');
- const { state, transcribe, reset } = useMockBatchTranscription();
+export const InInputField: Story = {
+ render: function InInputFieldStory() {
+ const [inputValue, setInputValue] = React.useState('');
- const handleRecordingComplete = async (blob: Blob, duration: number) => {
- const result = await transcribe(blob, duration);
- setText((prev) => (prev ? `${prev}\n\n${result}` : result));
+ const handleRecordingComplete = (blob: Blob, duration: number) => {
+ // In a real app, you'd send the blob to a transcription service
+ setInputValue(
+ `[Voice message: ${duration.toFixed(1)}s, ${(blob.size / 1024).toFixed(1)}KB]`
+ );
};
return (
-
-
- Batch Transcription Demo
-
-
- Record audio, then wait for the full transcription. The textarea
- becomes read-only during processing.
+
+
+ RecordButton embedded in an input field. Click the mic to record.
-
-
-
-
+ {inputValue && (
+
+ Value: {inputValue}
+
+ )}
);
},
- parameters: {
- docs: {
- description: {
- story: `
-**Batch Transcription Mode**
-
-This mode is for "send it, wait, and get transcription back" workflows:
-
-1. User clicks record and speaks
-2. User stops recording
-3. Audio is sent to transcription service
-4. Textarea shows loading state (read-only)
-5. Full transcription appears when complete
-
-Best for: High accuracy needs, offline processing, longer recordings.
- `,
- },
- },
- },
};
-/**
- * Real-time Streaming Transcription
- *
- * Text appears word-by-word as the user speaks.
- * Shows a cursor effect to indicate live transcription.
- */
-export const StreamingTranscription: Story = {
- render: function StreamingTranscriptionStory() {
+// ============================================================================
+// Transcription Integration
+// ============================================================================
+
+export const BatchTranscription: Story = {
+ render: function BatchTranscriptionStory() {
const [text, setText] = React.useState('');
- const { state, partialText, startStreaming, stopStreaming, reset } =
- useMockStreamingTranscription();
+ const {
+ state,
+ text: transcribedText,
+ transcribe,
+ reset,
+ } = useMockBatchTranscription();
- const handleRecordingStart = () => {
- startStreaming();
+ const handleRecordingComplete = async (blob: Blob, duration: number) => {
+ await transcribe(blob, duration);
};
- const handleRecordingComplete = () => {
- stopStreaming();
- // After processing, append the final text
- setTimeout(() => {
- setText((prev) => {
- const newText = partialText || 'Transcription complete.';
- return prev ? `${prev}\n\n${newText}` : newText;
- });
- }, 600);
- };
+ React.useEffect(() => {
+ if (transcribedText) {
+ setText(transcribedText);
+ }
+ }, [transcribedText]);
return (
-
-
- Real-time Streaming Demo
+
+
+
Batch Transcription
+
+ Record audio, then transcribe after recording completes.
+
+
+
+
-
- Text appears as you speak. Watch the words stream in real-time.
-
-
-
-
- {(text || partialText) && (
-
- )}
-
-
- );
- },
- parameters: {
- docs: {
- description: {
- story: `
-**Real-time Streaming Mode**
-
-This mode shows text appearing word-by-word as the user speaks:
-
-1. User clicks record
-2. Transcription starts immediately
-3. Words appear in the textarea as they're recognized
-4. A blinking cursor indicates live transcription
-5. When stopped, final processing occurs
-
-Best for: Immediate feedback, live captioning, real-time note-taking.
- `,
- },
- },
- },
-};
-
-/**
- * Transcription States Demonstration
- *
- * Shows all possible transcription states for testing and documentation.
- */
-export const TranscriptionStates: Story = {
- render: function TranscriptionStatesStory() {
- const [currentState, setCurrentState] =
- React.useState
('idle');
-
- const states: TranscriptionState[] = [
- 'idle',
- 'recording',
- 'transcribing',
- 'streaming',
- 'complete',
- 'error',
- ];
-
- return (
-
-
- Transcription State Tester
-
-
-
- {states.map((state) => (
-
- ))}
-
-
-
{}}
- transcriptionState={currentState}
- streamingText={
- currentState === 'streaming'
- ? 'This text is streaming in word by word...'
- : undefined
- }
- placeholder="Select a state above to preview..."
- rows={3}
- />
-
-
-
-
- Current state: {currentState}
-
-
-
- );
- },
- parameters: {
- docs: {
- description: {
- story:
- 'Interactive demo of all transcription states. Click buttons to preview each state.',
- },
- },
- },
-};
-
-/**
- * Full Chat with Voice Transcription
- *
- * Complete chat interface with voice-to-text transcription.
- */
-export const ChatWithTranscription: Story = {
- render: function ChatWithTranscriptionStory() {
- const [message, setMessage] = React.useState('');
- const [messages, setMessages] = React.useState<
- Array<{ id: string; text: string; isVoice: boolean }>
- >([]);
- const { state, transcribe, reset } = useMockBatchTranscription();
-
- const handleSend = () => {
- if (!message.trim()) return;
- setMessages((prev) => [
- ...prev,
- { id: Date.now().toString(), text: message, isVoice: false },
- ]);
- setMessage('');
- };
-
- const handleRecordingComplete = async (blob: Blob, duration: number) => {
- const result = await transcribe(blob, duration);
- setMessages((prev) => [
- ...prev,
- { id: Date.now().toString(), text: result, isVoice: true },
- ]);
- reset();
- };
-
- return (
-
- {/* Messages */}
-
- {messages.length === 0 && (
-
- Send a message or record audio
-
- )}
- {messages.map((msg) => (
-
- {msg.isVoice && (
-
- )}
-
{msg.text}
-
- ))}
-
-
- {/* Input */}
-
-
-
-
-
-
+
);
},
parameters: {
- docs: {
- description: {
- story:
- 'Complete chat interface demonstrating voice-to-text integration.',
- },
- },
+ layout: 'padded',
},
};
-/**
- * Form Field with Voice Input
- *
- * Shows how to add voice transcription to a standard form field.
- */
-export const FormFieldWithVoice: Story = {
- render: function FormFieldWithVoiceStory() {
- const [name, setName] = React.useState('');
- const [description, setDescription] = React.useState('');
- const { state, transcribe, reset } = useMockBatchTranscription();
- const [activeField, setActiveField] = React.useState<
- 'name' | 'description' | null
- >(null);
+export const StreamingTranscription: Story = {
+ render: function StreamingTranscriptionStory() {
+ const [text, setText] = React.useState('');
+ const {
+ state,
+ text: finalText,
+ partialText,
+ startStreaming,
+ stopStreaming,
+ reset,
+ } = useMockStreamingTranscription();
+
+ React.useEffect(() => {
+ if (finalText) {
+ setText(finalText);
+ }
+ }, [finalText]);
- const handleRecordingComplete = async (blob: Blob, duration: number) => {
- const result = await transcribe(blob, duration);
- if (activeField === 'name') {
- setName(result);
- } else if (activeField === 'description') {
- setDescription(result);
+ const handleClick = () => {
+ if (state === 'idle') {
+ startStreaming();
+ } else if (state === 'streaming') {
+ stopStreaming();
}
- setActiveField(null);
- reset();
};
return (
-
- Voice-Enabled Form
-
-
- {/* Name field */}
-
-
-
setName(e.target.value)}
- placeholder="Enter your name..."
- className="pr-12"
- readOnly={state !== 'idle' && activeField === 'name'}
- />
-
- setActiveField('name')}
- onRecordingComplete={handleRecordingComplete}
- disabled={state !== 'idle' && activeField !== 'name'}
- />
-
-
+
Streaming Transcription
+
+ See text appear word-by-word as you speak.
+
-
- {/* Description field */}
-
-
+
+
+
-
- {/* Submit */}
-
);
},
parameters: {
- docs: {
- description: {
- story:
- 'Standard form with voice input capability on multiple fields. Shows how to integrate voice transcription into existing forms.',
- },
- },
+ layout: 'padded',
},
};
diff --git a/src/components/RecordButton/RecordButton.tsx b/src/components/RecordButton/RecordButton.tsx
index 1e4fd598..53fa6fed 100644
--- a/src/components/RecordButton/RecordButton.tsx
+++ b/src/components/RecordButton/RecordButton.tsx
@@ -1,12 +1,21 @@
import * as React from 'react';
-import { cva, type VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
import { cn } from '../../utils/cn';
// ============================================================================
// Types
// ============================================================================
-export type RecordButtonState = 'idle' | 'recording' | 'processing';
+export type RecordButtonState =
+ | 'idle'
+ | 'recording'
+ | 'processing'
+ | 'disabled'
+ | 'error'
+ | 'success';
+
+export type RecordButtonVariant = 'default' | 'outline' | 'ghost' | 'minimal';
+export type RecordButtonSize = 'sm' | 'md' | 'lg';
/** Transcription state for integration with transcription services */
export type TranscriptionState =
@@ -26,25 +35,20 @@ export interface TranscriptionResult {
confidence?: number;
}
-export interface RecordButtonProps extends VariantProps<
- typeof recordButtonVariants
+export interface RecordButtonProps extends Omit<
+ React.ButtonHTMLAttributes,
+ 'children'
> {
- /** Callback when recording is complete with the audio blob */
- onRecordingComplete?: (blob: Blob, duration: number) => void;
- /** Callback when recording starts */
- onRecordingStart?: () => void;
- /** Callback when an error occurs */
- onError?: (error: Error) => void;
- /** Maximum recording duration in seconds (0 for unlimited) */
- maxDuration?: number;
- /** Audio MIME type */
- mimeType?: string;
- /** Whether the button is disabled */
- disabled?: boolean;
- /** Additional class name */
- className?: string;
- /** Accessible label */
- 'aria-label'?: string;
+ /** Current state of the button */
+ state?: RecordButtonState;
+ /** Size of the button */
+ size?: RecordButtonSize;
+ /** Visual style variant */
+ variant?: RecordButtonVariant;
+ /** Show waveform bars when recording (instead of stop icon) */
+ showWaveform?: boolean;
+ /** Show pulse rings when recording */
+ showPulse?: boolean;
/** Show recording duration while recording */
showDuration?: boolean;
/** Custom idle icon */
@@ -55,98 +59,63 @@ export interface RecordButtonProps extends VariantProps<
transcriptionState?: TranscriptionState;
/** Show transcription state indicator */
showTranscriptionState?: boolean;
-}
-
-// ============================================================================
-// Variants
-// ============================================================================
-
-const recordButtonVariants = cva(
- [
- 'relative inline-flex items-center justify-center',
- 'rounded-full transition-all duration-200',
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
- 'disabled:pointer-events-none disabled:opacity-50',
- ],
- {
- variants: {
- variant: {
- default: [
- 'text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100',
- 'dark:text-neutral-400 dark:hover:text-neutral-200 dark:hover:bg-neutral-800',
- ],
- filled: [
- 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200',
- 'dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700',
- ],
- primary: [
- 'bg-primary-600 text-white hover:bg-primary-700',
- 'dark:bg-primary-500 dark:hover:bg-primary-600',
- ],
- },
- size: {
- sm: 'h-7 w-7',
- md: 'h-9 w-9',
- lg: 'h-11 w-11',
- },
- },
- defaultVariants: {
- variant: 'default',
- size: 'md',
- },
- }
-);
-
-const recordingIndicatorVariants = cva(
- [
- 'absolute -top-1 -right-1',
- 'flex items-center justify-center',
- 'rounded-full bg-red-500 text-white',
- 'animate-pulse',
- ],
- {
- variants: {
- size: {
- sm: 'h-3 w-3',
- md: 'h-4 w-4',
- lg: 'h-5 w-5',
- },
- },
- defaultVariants: {
- size: 'md',
- },
- }
-);
-// ============================================================================
-// Helper Functions
-// ============================================================================
-
-function formatDuration(seconds: number): string {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins}:${secs.toString().padStart(2, '0')}`;
+ // Recording callbacks (for uncontrolled usage)
+ /** Callback when recording is complete with the audio blob */
+ onRecordingComplete?: (blob: Blob, duration: number) => void;
+ /** Callback when recording starts */
+ onRecordingStart?: () => void;
+ /** Callback when a recording error occurs */
+ onRecordingError?: (error: Error) => void;
+ /** Maximum recording duration in seconds (0 for unlimited) */
+ maxDuration?: number;
+ /** Audio MIME type */
+ mimeType?: string;
}
// ============================================================================
// Icons
// ============================================================================
-function MicrophoneIcon({ className }: { className?: string }) {
+function MicIcon({ className }: { className?: string }) {
return (
+ );
+}
+
+function MicOffIcon({ className }: { className?: string }) {
+ return (
+
);
}
@@ -154,9 +123,10 @@ function MicrophoneIcon({ className }: { className?: string }) {
function StopIcon({ className }: { className?: string }) {
return (
+ >
);
}
+// ============================================================================
+// Waveform Animation (for recording state)
+// ============================================================================
+
+function WaveformBars({ size }: { size: RecordButtonSize }) {
+ const barHeight = size === 'sm' ? 'h-2' : size === 'md' ? 'h-3' : 'h-4';
+
+ return (
+
+ {[0, 1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ );
+}
+
+// ============================================================================
+// Style Variants
+// ============================================================================
+
+const recordButtonVariants = cva(
+ [
+ 'relative inline-flex items-center justify-center rounded-full',
+ 'transition-all duration-200',
+ 'outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+ ],
+ {
+ variants: {
+ variant: {
+ default: '',
+ outline: 'border-2',
+ ghost: '',
+ minimal: '',
+ },
+ size: {
+ sm: 'size-10',
+ md: 'size-12',
+ lg: 'size-14',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'md',
+ },
+ }
+);
+
+const iconSizes: Record = {
+ sm: 'size-4',
+ md: 'size-5',
+ lg: 'size-6',
+};
+
+// ============================================================================
+// State Styles
+// ============================================================================
+
+function getStateStyles(
+ state: RecordButtonState,
+ variant: RecordButtonVariant
+): string {
+ const styles: Record<
+ RecordButtonVariant,
+ Record
+ > = {
+ default: {
+ idle: 'bg-primary/10 text-primary hover:bg-primary/20',
+ recording: 'bg-red-500/10 text-red-500 hover:bg-red-500/20',
+ processing: 'bg-primary/10 text-primary cursor-wait',
+ disabled: 'bg-muted text-muted-foreground cursor-not-allowed opacity-50',
+ error: 'bg-destructive/10 text-destructive',
+ success: 'bg-success/10 text-success',
+ },
+ outline: {
+ idle: 'border-primary/50 text-primary bg-transparent hover:bg-primary/10 hover:border-primary',
+ recording:
+ 'border-red-500/50 text-red-500 bg-transparent hover:bg-red-500/10 hover:border-red-500',
+ processing: 'border-primary/50 text-primary bg-transparent cursor-wait',
+ disabled:
+ 'border-muted text-muted-foreground bg-transparent cursor-not-allowed opacity-50',
+ error: 'border-destructive/50 text-destructive bg-transparent',
+ success: 'border-success/50 text-success bg-transparent',
+ },
+ ghost: {
+ idle: 'text-primary hover:bg-primary/10',
+ recording: 'text-red-500 hover:bg-red-500/10',
+ processing: 'text-primary bg-primary/5 cursor-wait',
+ disabled: 'text-muted-foreground cursor-not-allowed opacity-50',
+ error: 'text-destructive',
+ success: 'text-success',
+ },
+ minimal: {
+ idle: 'text-primary hover:text-primary/80',
+ recording: 'text-red-500 hover:text-red-500/80',
+ processing: 'text-primary cursor-wait',
+ disabled: 'text-muted-foreground/40 cursor-not-allowed',
+ error: 'text-destructive',
+ success: 'text-success',
+ },
+ };
+
+ return styles[variant][state];
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+function formatDuration(seconds: number): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
+}
+
// ============================================================================
// Main Component
// ============================================================================
/**
- * A simple microphone recording button that can be placed anywhere.
- * Perfect for adding voice input to text fields, chat inputs, or forms.
+ * A voice recording button with 6 states and 4 visual variants.
+ * Supports pulse animations, waveform visualization, and transcription integration.
+ *
+ * ## Controlled vs Uncontrolled Mode
+ *
+ * **Uncontrolled mode** (default): The component manages its own recording state.
+ * Use `onRecordingComplete`, `onRecordingStart`, and `onRecordingError` callbacks.
+ *
+ * **Controlled mode**: When the `state` prop is provided, the component becomes
+ * controlled and you must manage state changes externally. Note: In controlled mode,
+ * the internal MediaRecorder functionality is disabled - you must implement your own
+ * recording logic.
+ *
+ * ## State Precedence
+ *
+ * When multiple state-controlling props are provided, they follow this precedence:
+ * 1. `disabled` prop (highest priority)
+ * 2. `transcriptionState` prop
+ * 3. `state` prop
+ * 4. Internal state (uncontrolled)
*
* @example
* ```tsx
- * // Basic usage
+ * // Uncontrolled with recording callbacks
* {
- * console.log('Recorded:', blob, duration);
- * }}
+ * onRecordingComplete={(blob, duration) => console.log('Recorded:', blob)}
+ * onRecordingError={(error) => console.error('Recording failed:', error)}
* />
*
- * // In an input field
- *
+ * // Controlled state (requires external recording implementation)
+ *
+ *
+ * // Different variants
+ *
*
- * // With max duration
- *
+ * // With waveform animation
+ *
* ```
*/
-function RecordButton({
- onRecordingComplete,
- onRecordingStart,
- onError,
- maxDuration = 0,
- mimeType = 'audio/webm',
- disabled = false,
- variant,
- size,
- className,
- 'aria-label': ariaLabel,
- showDuration = false,
- idleIcon,
- recordingIcon,
- transcriptionState,
- showTranscriptionState = false,
-}: RecordButtonProps) {
- const [state, setState] = React.useState('idle');
- const [duration, setDuration] = React.useState(0);
-
- const mediaRecorderRef = React.useRef(null);
- const streamRef = React.useRef(null);
- const chunksRef = React.useRef([]);
- const timerRef = React.useRef(undefined);
- const startTimeRef = React.useRef(0);
-
- const isRecording = state === 'recording';
- const isProcessing = state === 'processing';
- const isTranscribing =
- transcriptionState === 'transcribing' || transcriptionState === 'streaming';
-
- // Cleanup on unmount
- React.useEffect(() => {
- return () => {
- if (timerRef.current) {
- clearInterval(timerRef.current);
- }
- if (streamRef.current) {
- streamRef.current.getTracks().forEach((track) => track.stop());
- }
+const RecordButton = React.forwardRef(
+ (
+ {
+ className,
+ variant = 'default',
+ size = 'md',
+ state: controlledState,
+ showWaveform = false,
+ showPulse = true,
+ disabled,
+ showDuration = false,
+ idleIcon,
+ recordingIcon,
+ transcriptionState,
+ showTranscriptionState = false,
+ onRecordingComplete,
+ onRecordingStart,
+ onRecordingError,
+ maxDuration = 0,
+ mimeType = 'audio/webm',
+ onClick,
+ ...props
+ },
+ ref
+ ) => {
+ // Internal state for uncontrolled usage
+ const [internalState, setInternalState] =
+ React.useState('idle');
+ const [duration, setDuration] = React.useState(0);
+
+ const mediaRecorderRef = React.useRef(null);
+ const streamRef = React.useRef(null);
+ const chunksRef = React.useRef([]);
+ const timerRef = React.useRef(undefined);
+ const startTimeRef = React.useRef(0);
+ const timeoutsRef = React.useRef([]);
+
+ // Helper to track and manage timeouts
+ const addTimeout = (callback: () => void, delay: number) => {
+ const id = setTimeout(() => {
+ callback();
+ // Remove from tracking after execution
+ timeoutsRef.current = timeoutsRef.current.filter((t) => t !== id);
+ }, delay);
+ timeoutsRef.current.push(id);
+ return id;
};
- }, []);
-
- const stopRecording = React.useCallback(() => {
- if (timerRef.current) {
- clearInterval(timerRef.current);
- }
-
- if (
- mediaRecorderRef.current &&
- mediaRecorderRef.current.state !== 'inactive'
- ) {
- mediaRecorderRef.current.stop();
- }
-
- if (streamRef.current) {
- streamRef.current.getTracks().forEach((track) => track.stop());
- }
- }, []);
-
- const startRecording = React.useCallback(async () => {
- if (disabled || isRecording || isProcessing) return;
-
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
- streamRef.current = stream;
-
- const options = { mimeType };
- if (!MediaRecorder.isTypeSupported(mimeType)) {
- mediaRecorderRef.current = new MediaRecorder(stream);
- } else {
- mediaRecorderRef.current = new MediaRecorder(stream, options);
- }
- chunksRef.current = [];
+ const clearAllTimeouts = () => {
+ timeoutsRef.current.forEach(clearTimeout);
+ timeoutsRef.current = [];
+ };
- mediaRecorderRef.current.ondataavailable = (e) => {
- if (e.data.size > 0) {
- chunksRef.current.push(e.data);
+ // Use controlled state if provided, otherwise internal state
+ const isControlled = controlledState !== undefined;
+ const currentState = isControlled ? controlledState : internalState;
+
+ // Map transcription state to button state if provided
+ // Precedence: disabled prop → transcriptionState → state prop → internal state
+ const effectiveState: RecordButtonState = disabled
+ ? 'disabled'
+ : transcriptionState === 'error'
+ ? 'error'
+ : transcriptionState === 'transcribing' ||
+ transcriptionState === 'streaming'
+ ? 'processing'
+ : transcriptionState === 'complete'
+ ? 'success'
+ : currentState;
+
+ // Dev mode warnings for conflicting states
+ React.useEffect(() => {
+ // Only warn in development
+ if (typeof window === 'undefined') return;
+
+ // Warn when disabled is true but other state props suggest a different visual state
+ if (
+ disabled &&
+ ((controlledState && controlledState !== 'disabled') ||
+ transcriptionState)
+ ) {
+ console.warn(
+ '[RecordButton]: `disabled` prop takes precedence over both `state` and `transcriptionState`. ' +
+ 'When `disabled` is true, the button will always appear disabled.'
+ );
+ }
+
+ // Warn when both controlled state and transcriptionState are provided and conflict
+ if (controlledState !== undefined && transcriptionState !== undefined) {
+ const mappedTranscriptionState: RecordButtonState | undefined =
+ transcriptionState === 'error'
+ ? 'error'
+ : transcriptionState === 'transcribing' ||
+ transcriptionState === 'streaming'
+ ? 'processing'
+ : transcriptionState === 'complete'
+ ? 'success'
+ : undefined;
+
+ if (
+ mappedTranscriptionState !== undefined &&
+ mappedTranscriptionState !== controlledState
+ ) {
+ console.warn(
+ '[RecordButton]: `transcriptionState` takes precedence over `state`. ' +
+ `Received state="${controlledState}" and transcriptionState="${transcriptionState}". ` +
+ 'This may lead to unexpected visual states.'
+ );
+ }
+ }
+ }, [disabled, controlledState, transcriptionState]);
+
+ const iconSize = iconSizes[size];
+ const isRecording = effectiveState === 'recording';
+ const isDisabled =
+ effectiveState === 'disabled' || effectiveState === 'processing';
+
+ // Cleanup on unmount
+ React.useEffect(() => {
+ return () => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ clearAllTimeouts();
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => track.stop());
}
};
+ }, []);
- mediaRecorderRef.current.onstop = () => {
- setState('processing');
-
- const blob = new Blob(chunksRef.current, { type: mimeType });
- const finalDuration = duration;
-
- // Small delay to show processing state
- setTimeout(() => {
- onRecordingComplete?.(blob, finalDuration);
- setState('idle');
- setDuration(0);
- }, 200);
- };
+ const stopRecording = React.useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
- mediaRecorderRef.current.start(100);
- startTimeRef.current = Date.now();
- setState('recording');
- onRecordingStart?.();
+ if (
+ mediaRecorderRef.current &&
+ mediaRecorderRef.current.state !== 'inactive'
+ ) {
+ mediaRecorderRef.current.stop();
+ }
- timerRef.current = window.setInterval(() => {
- const elapsed = (Date.now() - startTimeRef.current) / 1000;
- setDuration(elapsed);
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => track.stop());
+ }
+ }, []);
+
+ const startRecording = React.useCallback(async () => {
+ // Guard: don't start if disabled, already recording, or processing
+ if (
+ disabled ||
+ effectiveState === 'recording' ||
+ effectiveState === 'processing'
+ )
+ return;
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ });
+ streamRef.current = stream;
+
+ const options = { mimeType };
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
+ mediaRecorderRef.current = new MediaRecorder(stream);
+ } else {
+ mediaRecorderRef.current = new MediaRecorder(stream, options);
+ }
- if (maxDuration > 0 && elapsed >= maxDuration) {
- stopRecording();
+ chunksRef.current = [];
+
+ mediaRecorderRef.current.ondataavailable = (e) => {
+ if (e.data.size > 0) {
+ chunksRef.current.push(e.data);
+ }
+ };
+
+ mediaRecorderRef.current.onstop = () => {
+ if (!isControlled) {
+ setInternalState('processing');
+ }
+
+ const blob = new Blob(chunksRef.current, { type: mimeType });
+ const finalDuration = duration;
+
+ // Small delay to show processing state
+ addTimeout(() => {
+ onRecordingComplete?.(blob, finalDuration);
+ if (!isControlled) {
+ setInternalState('success');
+ // Reset to idle after showing success
+ addTimeout(() => {
+ setInternalState('idle');
+ }, 1500);
+ }
+ setDuration(0);
+ }, 200);
+ };
+
+ mediaRecorderRef.current.start(100);
+ startTimeRef.current = Date.now();
+
+ if (!isControlled) {
+ setInternalState('recording');
}
- }, 100);
- } catch (error) {
- onError?.(error as Error);
- setState('idle');
- }
- }, [
- disabled,
- isRecording,
- isProcessing,
- mimeType,
- maxDuration,
- duration,
- onRecordingComplete,
- onRecordingStart,
- onError,
- stopRecording,
- ]);
-
- const handleClick = React.useCallback(() => {
- if (isRecording) {
- stopRecording();
- } else {
- startRecording();
- }
- }, [isRecording, startRecording, stopRecording]);
-
- const iconSize =
- size === 'sm' ? 'h-4 w-4' : size === 'lg' ? 'h-6 w-6' : 'h-5 w-5';
-
- const getIcon = () => {
- if (isProcessing || isTranscribing) {
- return ;
- }
- if (isRecording) {
- return recordingIcon || ;
- }
- return idleIcon || ;
- };
+ onRecordingStart?.();
+
+ timerRef.current = window.setInterval(() => {
+ const elapsed = (Date.now() - startTimeRef.current) / 1000;
+ setDuration(elapsed);
+
+ if (maxDuration > 0 && elapsed >= maxDuration) {
+ stopRecording();
+ }
+ }, 100);
+ } catch (error) {
+ onRecordingError?.(error as Error);
+ if (!isControlled) {
+ setInternalState('error');
+ // Reset to idle after showing error
+ addTimeout(() => {
+ setInternalState('idle');
+ }, 2000);
+ }
+ }
+ }, [
+ disabled,
+ effectiveState,
+ isControlled,
+ mimeType,
+ maxDuration,
+ duration,
+ onRecordingComplete,
+ onRecordingStart,
+ onRecordingError,
+ stopRecording,
+ ]);
+
+ const handleClick = React.useCallback(
+ (e: React.MouseEvent) => {
+ // Call external onClick if provided
+ onClick?.(e);
+
+ // Handle internal recording logic only if not fully controlled
+ if (!isControlled) {
+ if (effectiveState === 'recording') {
+ stopRecording();
+ } else if (effectiveState === 'idle') {
+ startRecording();
+ }
+ }
+ },
+ [onClick, isControlled, effectiveState, startRecording, stopRecording]
+ );
+
+ // Determine which icon to show
+ const renderIcon = () => {
+ switch (effectiveState) {
+ case 'recording':
+ if (showWaveform) {
+ return ;
+ }
+ return recordingIcon || ;
+ case 'processing':
+ return ;
+ case 'disabled':
+ case 'error':
+ return ;
+ case 'success':
+ return ;
+ default:
+ return idleIcon || ;
+ }
+ };
- const getAriaLabel = () => {
- if (ariaLabel) return ariaLabel;
- if (isTranscribing) return 'Transcribing audio';
- if (isProcessing) return 'Processing recording';
- if (isRecording) return 'Stop recording';
- return 'Start recording';
- };
+ const getAriaLabel = () => {
+ switch (effectiveState) {
+ case 'recording':
+ return 'Stop recording';
+ case 'processing':
+ return 'Processing recording';
+ case 'disabled':
+ return 'Recording unavailable';
+ case 'error':
+ return 'Recording failed';
+ case 'success':
+ return 'Recording complete';
+ default:
+ return 'Start recording';
+ }
+ };
- const getTranscriptionLabel = () => {
- if (transcriptionState === 'streaming') return 'Listening...';
- if (transcriptionState === 'transcribing') return 'Transcribing...';
- return null;
- };
+ const getTranscriptionLabel = () => {
+ if (transcriptionState === 'streaming') return 'Listening...';
+ if (transcriptionState === 'transcribing') return 'Transcribing...';
+ return null;
+ };
- return (
-
-
- {showDuration && isRecording && (
-
- {formatDuration(duration)}
-
- )}
- {showTranscriptionState && getTranscriptionLabel() && (
-
- {getTranscriptionLabel()}
-
- )}
-
- );
-}
+
+ );
+ }
+);
RecordButton.displayName = 'RecordButton';
@@ -422,9 +706,4 @@ RecordButton.displayName = 'RecordButton';
// Exports
// ============================================================================
-export {
- RecordButton,
- recordButtonVariants,
- recordingIndicatorVariants,
- formatDuration,
-};
+export { RecordButton, recordButtonVariants, formatDuration };
diff --git a/src/components/RecordButton/index.ts b/src/components/RecordButton/index.ts
index c7a40ec4..e8817ac0 100644
--- a/src/components/RecordButton/index.ts
+++ b/src/components/RecordButton/index.ts
@@ -1,10 +1,11 @@
export {
RecordButton,
recordButtonVariants,
- recordingIndicatorVariants,
formatDuration,
type RecordButtonProps,
type RecordButtonState,
+ type RecordButtonVariant,
+ type RecordButtonSize,
type TranscriptionState,
type TranscriptionResult,
} from './RecordButton';
diff --git a/src/styles/base.css b/src/styles/base.css
index 553391b3..3b392e28 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -171,3 +171,21 @@ kbd {
transition-duration: 0.01ms !important;
}
}
+
+/* ============================================
+ Waveform Animation (for RecordButton)
+ ============================================ */
+
+@keyframes waveform {
+ 0%,
+ 100% {
+ transform: scaleY(0.3);
+ }
+ 50% {
+ transform: scaleY(1);
+ }
+}
+
+.animate-waveform {
+ animation: waveform 0.5s ease-in-out infinite;
+}