Cedar provides pre-built React components for voice interactions, along with guidance for creating custom voice UI components. These components integrate seamlessly with the voice state management system.

VoiceIndicator Component

The VoiceIndicator component provides visual feedback for voice states with smooth animations powered by Motion for React.

Basic Usage

import { VoiceIndicator } from '@cedar/voice';
import { useCedarStore } from '@cedar/core';

function MyVoiceApp() {
	const voice = useCedarStore((state) => state.voice);

	return (
		<div>
			<VoiceIndicator voiceState={voice} />
			{/* Your other components */}
		</div>
	);
}

Component Features

  • Listening State: Animated microphone icon with pulsing bars
  • Speaking State: Animated speaker icon with scaling effect
  • Error State: Alert icon with error message
  • Auto-hide: Only shows when voice is active or there’s an error

Animation Details

The component uses Motion for React with optimized animations:
// Listening animation - pulsing bars
{
	[0, 1, 2].map((i) => (
		<motion.div
			key={i}
			className='w-1 h-3 bg-red-500 rounded-full'
			animate={{
				scaleY: [1, 1.5, 1],
			}}
			transition={{
				duration: 0.5,
				repeat: Infinity,
				delay: i * 0.1,
			}}
		/>
	));
}

// Speaking animation - scaling effect
<motion.div
	className='w-4 h-4'
	animate={{
		scale: [1, 1.2, 1],
	}}
	transition={{
		duration: 0.8,
		repeat: Infinity,
	}}>
	<div className='w-full h-full bg-green-500 rounded-full opacity-30' />
</motion.div>;

Custom Voice Button

Create a custom voice button with enhanced visual feedback:
import React from 'react';
import { motion } from 'motion/react';
import { Mic, MicOff, Loader2 } from 'lucide-react';
import { useCedarStore } from '@cedar/core';

interface VoiceButtonProps {
	size?: 'small' | 'medium' | 'large';
	variant?: 'primary' | 'secondary' | 'outline';
	className?: string;
}

export const VoiceButton: React.FC<VoiceButtonProps> = ({
	size = 'medium',
	variant = 'primary',
	className = '',
}) => {
	const voice = useCedarStore((state) => state.voice);

	const handleClick = async () => {
		if (voice.voicePermissionStatus === 'prompt') {
			await voice.requestVoicePermission();
		}

		if (voice.voicePermissionStatus === 'granted') {
			voice.toggleVoice();
		}
	};

	const sizeClasses = {
		small: 'w-8 h-8',
		medium: 'w-12 h-12',
		large: 'w-16 h-16',
	};

	const variantClasses = {
		primary: 'bg-blue-500 hover:bg-blue-600 text-white',
		secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
		outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-50',
	};

	const isDisabled =
		voice.voicePermissionStatus === 'denied' ||
		voice.voicePermissionStatus === 'not-supported';

	return (
		<motion.button
			onClick={handleClick}
			disabled={isDisabled}
			className={`
        ${sizeClasses[size]}
        ${variantClasses[variant]}
        ${className}
        rounded-full flex items-center justify-center
        transition-colors duration-200
        disabled:opacity-50 disabled:cursor-not-allowed
        focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
      `}
			whileHover={!isDisabled ? { scale: 1.05 } : {}}
			whileTap={!isDisabled ? { scale: 0.95 } : {}}
			animate={{
				backgroundColor: voice.isListening ? '#ef4444' : undefined,
			}}
			style={{ willChange: 'transform' }}>
			{voice.voicePermissionStatus === 'prompt' && (
				<Loader2 className='w-5 h-5 animate-spin' />
			)}

			{voice.voicePermissionStatus === 'granted' && (
				<>
					{voice.isListening ? (
						<motion.div
							initial={{ scale: 0 }}
							animate={{ scale: 1 }}
							transition={{ type: 'spring', stiffness: 500, damping: 30 }}>
							<MicOff className='w-5 h-5' />
						</motion.div>
					) : (
						<motion.div
							initial={{ scale: 0 }}
							animate={{ scale: 1 }}
							transition={{ type: 'spring', stiffness: 500, damping: 30 }}>
							<Mic className='w-5 h-5' />
						</motion.div>
					)}
				</>
			)}

			{voice.voicePermissionStatus === 'denied' && (
				<MicOff className='w-5 h-5 opacity-50' />
			)}
		</motion.button>
	);
};

Voice Waveform Visualizer

Create an animated waveform visualizer for audio input:
import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { useCedarStore } from '@cedar/core';

interface VoiceWaveformProps {
	barCount?: number;
	height?: number;
	color?: string;
	className?: string;
}

export const VoiceWaveform: React.FC<VoiceWaveformProps> = ({
	barCount = 20,
	height = 40,
	color = '#3b82f6',
	className = '',
}) => {
	const voice = useCedarStore((state) => state.voice);
	const [audioData, setAudioData] = useState<number[]>([]);
	const analyserRef = useRef<AnalyserNode | null>(null);
	const animationRef = useRef<number>();

	useEffect(() => {
		if (voice.isListening && voice.audioContext && voice.audioStream) {
			// Create audio analyser
			const analyser = voice.audioContext.createAnalyser();
			const source = voice.audioContext.createMediaStreamSource(
				voice.audioStream
			);

			source.connect(analyser);
			analyser.fftSize = 256;

			const bufferLength = analyser.frequencyBinCount;
			const dataArray = new Uint8Array(bufferLength);

			analyserRef.current = analyser;

			const updateWaveform = () => {
				if (analyser) {
					analyser.getByteFrequencyData(dataArray);

					// Sample data for visualization
					const step = Math.floor(bufferLength / barCount);
					const samples = [];

					for (let i = 0; i < barCount; i++) {
						const sample = dataArray[i * step] || 0;
						samples.push(sample / 255); // Normalize to 0-1
					}

					setAudioData(samples);
				}

				if (voice.isListening) {
					animationRef.current = requestAnimationFrame(updateWaveform);
				}
			};

			updateWaveform();
		} else {
			// Reset when not listening
			setAudioData(new Array(barCount).fill(0));
			if (animationRef.current) {
				cancelAnimationFrame(animationRef.current);
			}
		}

		return () => {
			if (animationRef.current) {
				cancelAnimationFrame(animationRef.current);
			}
		};
	}, [voice.isListening, voice.audioContext, voice.audioStream, barCount]);

	return (
		<div
			className={`flex items-end justify-center gap-1 ${className}`}
			style={{ height: `${height}px` }}>
			{Array.from({ length: barCount }).map((_, index) => {
				const amplitude = audioData[index] || 0;
				const barHeight = Math.max(2, amplitude * height);

				return (
					<motion.div
						key={index}
						className='w-1 rounded-full'
						style={{
							backgroundColor: color,
							willChange: 'height',
						}}
						animate={{
							height: barHeight,
						}}
						transition={{
							duration: 0.1,
							ease: 'easeOut',
						}}
					/>
				);
			})}
		</div>
	);
};

Voice Status Panel

A comprehensive status panel showing all voice information:
import React from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
	Mic,
	MicOff,
	Volume2,
	VolumeX,
	Wifi,
	WifiOff,
	AlertCircle,
	CheckCircle,
} from 'lucide-react';
import { useCedarStore } from '@cedar/core';

export const VoiceStatusPanel: React.FC = () => {
	const voice = useCedarStore((state) => state.voice);

	const getPermissionStatus = () => {
		switch (voice.voicePermissionStatus) {
			case 'granted':
				return {
					icon: CheckCircle,
					color: 'text-green-500',
					text: 'Microphone access granted',
				};
			case 'denied':
				return {
					icon: MicOff,
					color: 'text-red-500',
					text: 'Microphone access denied',
				};
			case 'not-supported':
				return {
					icon: AlertCircle,
					color: 'text-orange-500',
					text: 'Voice not supported',
				};
			default:
				return {
					icon: Mic,
					color: 'text-gray-500',
					text: 'Microphone permission needed',
				};
		}
	};

	const getConnectionStatus = () => {
		// Assuming WebSocket connection status is available
		const isConnected = voice.voiceEndpoint && !voice.voiceError;
		return {
			icon: isConnected ? Wifi : WifiOff,
			color: isConnected ? 'text-green-500' : 'text-red-500',
			text: isConnected
				? 'Connected to voice service'
				: 'Disconnected from voice service',
		};
	};

	const permissionStatus = getPermissionStatus();
	const connectionStatus = getConnectionStatus();

	return (
		<motion.div
			initial={{ opacity: 0, y: 20 }}
			animate={{ opacity: 1, y: 0 }}
			className='bg-white rounded-lg shadow-lg p-6 max-w-md mx-auto'>
			<h3 className='text-lg font-semibold mb-4'>Voice Status</h3>

			{/* Permission Status */}
			<div className='flex items-center gap-3 mb-4'>
				<permissionStatus.icon
					className={`w-5 h-5 ${permissionStatus.color}`}
				/>
				<span className='text-sm'>{permissionStatus.text}</span>
			</div>

			{/* Connection Status */}
			<div className='flex items-center gap-3 mb-4'>
				<connectionStatus.icon
					className={`w-5 h-5 ${connectionStatus.color}`}
				/>
				<span className='text-sm'>{connectionStatus.text}</span>
			</div>

			{/* Current State */}
			<div className='flex items-center gap-3 mb-4'>
				{voice.isListening && (
					<>
						<Mic className='w-5 h-5 text-red-500' />
						<span className='text-sm'>Listening...</span>
						<motion.div
							className='ml-auto flex gap-1'
							initial={{ opacity: 0 }}
							animate={{ opacity: 1 }}>
							{[0, 1, 2].map((i) => (
								<motion.div
									key={i}
									className='w-1 h-3 bg-red-500 rounded-full'
									animate={{
										scaleY: [1, 1.5, 1],
									}}
									transition={{
										duration: 0.5,
										repeat: Infinity,
										delay: i * 0.1,
									}}
								/>
							))}
						</motion.div>
					</>
				)}

				{voice.isSpeaking && (
					<>
						<Volume2 className='w-5 h-5 text-green-500' />
						<span className='text-sm'>Speaking...</span>
						<motion.div
							className='ml-auto w-4 h-4'
							animate={{
								scale: [1, 1.2, 1],
							}}
							transition={{
								duration: 0.8,
								repeat: Infinity,
							}}>
							<div className='w-full h-full bg-green-500 rounded-full opacity-30' />
						</motion.div>
					</>
				)}

				{!voice.isListening && !voice.isSpeaking && (
					<>
						<VolumeX className='w-5 h-5 text-gray-400' />
						<span className='text-sm text-gray-500'>Idle</span>
					</>
				)}
			</div>

			{/* Voice Settings */}
			<div className='border-t pt-4'>
				<h4 className='text-sm font-medium mb-2'>Settings</h4>
				<div className='text-xs text-gray-600 space-y-1'>
					<div>Language: {voice.voiceSettings.language}</div>
					<div>Voice: {voice.voiceSettings.voiceId || 'Default'}</div>
					<div>Rate: {voice.voiceSettings.rate || 1.0}x</div>
					<div>
						Auto-add to messages:{' '}
						{voice.voiceSettings.autoAddToMessages ? 'Yes' : 'No'}
					</div>
				</div>
			</div>

			{/* Error Display */}
			<AnimatePresence>
				{voice.voiceError && (
					<motion.div
						initial={{ opacity: 0, height: 0 }}
						animate={{ opacity: 1, height: 'auto' }}
						exit={{ opacity: 0, height: 0 }}
						className='mt-4 p-3 bg-red-50 border border-red-200 rounded-md'>
						<div className='flex items-center gap-2'>
							<AlertCircle className='w-4 h-4 text-red-500' />
							<span className='text-sm text-red-700'>{voice.voiceError}</span>
						</div>
					</motion.div>
				)}
			</AnimatePresence>
		</motion.div>
	);
};

Voice Settings Component

A settings panel for configuring voice options:
import React from 'react';
import { motion } from 'motion/react';
import { Settings, Volume2, Mic } from 'lucide-react';
import { useCedarStore } from '@cedar/core';

export const VoiceSettings: React.FC = () => {
	const voice = useCedarStore((state) => state.voice);

	const voiceOptions = [
		{ value: 'alloy', label: 'Alloy' },
		{ value: 'echo', label: 'Echo' },
		{ value: 'fable', label: 'Fable' },
		{ value: 'onyx', label: 'Onyx' },
		{ value: 'nova', label: 'Nova' },
		{ value: 'shimmer', label: 'Shimmer' },
	];

	const languageOptions = [
		{ value: 'en-US', label: 'English (US)' },
		{ value: 'en-GB', label: 'English (UK)' },
		{ value: 'es-ES', label: 'Spanish' },
		{ value: 'fr-FR', label: 'French' },
		{ value: 'de-DE', label: 'German' },
		{ value: 'it-IT', label: 'Italian' },
		{ value: 'pt-BR', label: 'Portuguese (Brazil)' },
		{ value: 'ja-JP', label: 'Japanese' },
		{ value: 'ko-KR', label: 'Korean' },
		{ value: 'zh-CN', label: 'Chinese (Simplified)' },
	];

	return (
		<motion.div
			initial={{ opacity: 0, scale: 0.95 }}
			animate={{ opacity: 1, scale: 1 }}
			className='bg-white rounded-lg shadow-lg p-6 max-w-md mx-auto'>
			<div className='flex items-center gap-2 mb-6'>
				<Settings className='w-5 h-5' />
				<h3 className='text-lg font-semibold'>Voice Settings</h3>
			</div>

			<div className='space-y-6'>
				{/* Language Selection */}
				<div>
					<label className='block text-sm font-medium text-gray-700 mb-2'>
						<Mic className='w-4 h-4 inline mr-1' />
						Language
					</label>
					<select
						value={voice.voiceSettings.language}
						onChange={(e) =>
							voice.updateVoiceSettings({ language: e.target.value })
						}
						className='w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent'>
						{languageOptions.map((option) => (
							<option key={option.value} value={option.value}>
								{option.label}
							</option>
						))}
					</select>
				</div>

				{/* Voice Selection */}
				<div>
					<label className='block text-sm font-medium text-gray-700 mb-2'>
						<Volume2 className='w-4 h-4 inline mr-1' />
						Voice
					</label>
					<select
						value={voice.voiceSettings.voiceId || 'alloy'}
						onChange={(e) =>
							voice.updateVoiceSettings({ voiceId: e.target.value })
						}
						className='w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent'>
						{voiceOptions.map((option) => (
							<option key={option.value} value={option.value}>
								{option.label}
							</option>
						))}
					</select>
				</div>

				{/* Speech Rate */}
				<div>
					<label className='block text-sm font-medium text-gray-700 mb-2'>
						Speech Rate: {voice.voiceSettings.rate || 1.0}x
					</label>
					<input
						type='range'
						min='0.5'
						max='2.0'
						step='0.1'
						value={voice.voiceSettings.rate || 1.0}
						onChange={(e) =>
							voice.updateVoiceSettings({ rate: parseFloat(e.target.value) })
						}
						className='w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer'
					/>
					<div className='flex justify-between text-xs text-gray-500 mt-1'>
						<span>0.5x</span>
						<span>1.0x</span>
						<span>2.0x</span>
					</div>
				</div>

				{/* Volume */}
				<div>
					<label className='block text-sm font-medium text-gray-700 mb-2'>
						Volume: {Math.round((voice.voiceSettings.volume || 1.0) * 100)}%
					</label>
					<input
						type='range'
						min='0'
						max='1'
						step='0.1'
						value={voice.voiceSettings.volume || 1.0}
						onChange={(e) =>
							voice.updateVoiceSettings({ volume: parseFloat(e.target.value) })
						}
						className='w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer'
					/>
				</div>

				{/* Auto-add to Messages */}
				<div className='flex items-center justify-between'>
					<label className='text-sm font-medium text-gray-700'>
						Auto-add to Messages
					</label>
					<motion.button
						onClick={() =>
							voice.updateVoiceSettings({
								autoAddToMessages: !voice.voiceSettings.autoAddToMessages,
							})
						}
						className={`
              relative inline-flex h-6 w-11 items-center rounded-full transition-colors
              ${
								voice.voiceSettings.autoAddToMessages
									? 'bg-blue-600'
									: 'bg-gray-200'
							}
            `}
						whileTap={{ scale: 0.95 }}>
						<motion.span
							className='inline-block h-4 w-4 transform rounded-full bg-white shadow-lg'
							animate={{
								x: voice.voiceSettings.autoAddToMessages ? 24 : 4,
							}}
							transition={{ type: 'spring', stiffness: 500, damping: 30 }}
						/>
					</motion.button>
				</div>

				{/* Browser TTS */}
				<div className='flex items-center justify-between'>
					<label className='text-sm font-medium text-gray-700'>
						Use Browser TTS
					</label>
					<motion.button
						onClick={() =>
							voice.updateVoiceSettings({
								useBrowserTTS: !voice.voiceSettings.useBrowserTTS,
							})
						}
						className={`
              relative inline-flex h-6 w-11 items-center rounded-full transition-colors
              ${
								voice.voiceSettings.useBrowserTTS
									? 'bg-blue-600'
									: 'bg-gray-200'
							}
            `}
						whileTap={{ scale: 0.95 }}>
						<motion.span
							className='inline-block h-4 w-4 transform rounded-full bg-white shadow-lg'
							animate={{
								x: voice.voiceSettings.useBrowserTTS ? 24 : 4,
							}}
							transition={{ type: 'spring', stiffness: 500, damping: 30 }}
						/>
					</motion.button>
				</div>
			</div>
		</motion.div>
	);
};

Complete Voice Interface

Combining all components into a comprehensive voice interface:
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Settings } from 'lucide-react';
import {
	VoiceButton,
	VoiceWaveform,
	VoiceStatusPanel,
	VoiceSettings,
} from './VoiceComponents';

export const VoiceInterface: React.FC = () => {
	const [showSettings, setShowSettings] = useState(false);

	return (
		<div className='voice-interface'>
			{/* Main Voice Controls */}
			<div className='flex flex-col items-center gap-6 p-6'>
				{/* Voice Button */}
				<VoiceButton size='large' />

				{/* Waveform Visualizer */}
				<VoiceWaveform className='w-64' />

				{/* Settings Toggle */}
				<motion.button
					onClick={() => setShowSettings(!showSettings)}
					className='flex items-center gap-2 text-gray-600 hover:text-gray-800 transition-colors'
					whileHover={{ scale: 1.05 }}
					whileTap={{ scale: 0.95 }}>
					<Settings className='w-4 h-4' />
					<span className='text-sm'>Settings</span>
				</motion.button>
			</div>

			{/* Status Panel */}
			<div className='mb-6'>
				<VoiceStatusPanel />
			</div>

			{/* Settings Panel */}
			<AnimatePresence>
				{showSettings && (
					<motion.div
						initial={{ opacity: 0, height: 0 }}
						animate={{ opacity: 1, height: 'auto' }}
						exit={{ opacity: 0, height: 0 }}
						className='overflow-hidden'>
						<VoiceSettings />
					</motion.div>
				)}
			</AnimatePresence>
		</div>
	);
};

Styling Guidelines

CSS Classes for Voice Components

/* Voice button states */
.voice-button {
	@apply transition-all duration-200 ease-out;
}

.voice-button:hover {
	@apply transform scale-105;
}

.voice-button.listening {
	@apply bg-red-500 shadow-lg shadow-red-500/25;
	animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

.voice-button.speaking {
	@apply bg-green-500 shadow-lg shadow-green-500/25;
}

/* Voice indicator animations */
.voice-indicator {
	@apply flex items-center gap-2 justify-center;
}

.voice-indicator .listening-bars {
	@apply flex gap-1;
}

.voice-indicator .speaking-pulse {
	@apply w-4 h-4 bg-green-500 rounded-full opacity-30;
	animation: speaking-pulse 0.8s ease-in-out infinite;
}

/* Waveform visualizer */
.voice-waveform {
	@apply flex items-end justify-center gap-1;
}

.voice-waveform .bar {
	@apply w-1 rounded-full transition-all duration-100 ease-out;
	min-height: 2px;
}

/* Keyframes */
@keyframes pulse {
	0%,
	100% {
		opacity: 1;
	}
	50% {
		opacity: 0.8;
	}
}

@keyframes speaking-pulse {
	0%,
	100% {
		transform: scale(1);
	}
	50% {
		transform: scale(1.2);
	}
}

Accessibility Considerations

ARIA Labels and Screen Reader Support

// Enhanced VoiceButton with accessibility
export const AccessibleVoiceButton: React.FC<VoiceButtonProps> = (props) => {
	const voice = useCedarStore((state) => state.voice);

	const getAriaLabel = () => {
		if (voice.isListening) return 'Stop listening';
		if (voice.voicePermissionStatus === 'denied')
			return 'Microphone access denied';
		if (voice.voicePermissionStatus === 'not-supported')
			return 'Voice not supported';
		return 'Start listening';
	};

	return (
		<motion.button
			{...props}
			aria-label={getAriaLabel()}
			aria-pressed={voice.isListening}
			role='button'
			tabIndex={0}
			onKeyDown={(e) => {
				if (e.key === 'Enter' || e.key === ' ') {
					e.preventDefault();
					handleClick();
				}
			}}>
			{/* Button content */}
		</motion.button>
	);
};

Reduced Motion Support

// Respect user's motion preferences
const shouldReduceMotion = window.matchMedia(
	'(prefers-reduced-motion: reduce)'
).matches;

const animationProps = shouldReduceMotion
	? {}
	: {
			animate: { scale: [1, 1.2, 1] },
			transition: { duration: 0.8, repeat: Infinity },
	  };

<motion.div {...animationProps}>{/* Content */}</motion.div>;

Testing Voice Components

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { VoiceButton } from './VoiceButton';
import { useCedarStore } from '@cedar/core';

// Mock the store
jest.mock('@cedar/core', () => ({
	useCedarStore: jest.fn(),
}));

describe('VoiceButton', () => {
	const mockVoice = {
		isListening: false,
		voicePermissionStatus: 'granted',
		toggleVoice: jest.fn(),
		requestVoicePermission: jest.fn(),
	};

	beforeEach(() => {
		(useCedarStore as jest.Mock).mockReturnValue(mockVoice);
	});

	it('renders correctly', () => {
		render(<VoiceButton />);
		expect(screen.getByRole('button')).toBeInTheDocument();
	});

	it('calls toggleVoice when clicked', async () => {
		render(<VoiceButton />);

		fireEvent.click(screen.getByRole('button'));

		await waitFor(() => {
			expect(mockVoice.toggleVoice).toHaveBeenCalled();
		});
	});

	it('shows correct state when listening', () => {
		(useCedarStore as jest.Mock).mockReturnValue({
			...mockVoice,
			isListening: true,
		});

		render(<VoiceButton />);

		expect(screen.getByLabelText('Stop listening')).toBeInTheDocument();
	});
});

Next Steps