Skip to content

feat(RecordButton): Complete rewrite with enhanced visual design and states#73

Merged
garrity-miepub merged 6 commits intomainfrom
feature/record-button-updstes
Feb 4, 2026
Merged

feat(RecordButton): Complete rewrite with enhanced visual design and states#73
garrity-miepub merged 6 commits intomainfrom
feature/record-button-updstes

Conversation

@garrity-miepub
Copy link
Copy Markdown
Contributor

Major Changes

Component Architecture (RecordButton.tsx)

  • Complete rewrite based on modern v0.app reference design
  • 6 distinct states: idle, recording, processing, disabled, error, success
  • 4 visual variants with updated color schemes:
    • default: Red/pink gradient with glow effects
    • outline: Subtle border with amber/orange recording state
    • ghost: Transparent with emerald/green recording state
    • minimal: Clean, understated with blue/indigo recording state
  • 3 sizes: sm (40px), md (48px), lg (56px)
  • Animated waveform bars component for recording visualization
  • Pulse ring animation during recording state
  • Duration display support while recording
  • Transcription state integration for AI applications
  • Both controlled and uncontrolled recording modes
  • Built-in MediaRecorder support with callbacks

Stories & Documentation (RecordButton.

Major Changes

Component Architecture (RecordButton.tsx)

  • Complete rewrite based on modern v0.app reference design
  • 6 distinct states: idle, tch ### Component
    • Complete rewrite based on modern v0.app reta- 6 distinct states: idle, recording, processing, disablegr- 4 visual variants with updated color schemes:
    • default: Red/pink gralt - default: Red/pink gradient with glow effec e - outline: Subtle border with amber/orange reng - ghost: Transparent with emerald/green recording state al - minimal: Clean, understated with blue/indigo recordird- 3 sizize, TranscriptionState, TranscriptionResult
  • Clean named- Animated waveform bars component for ree
  • Pulse ring animation during recording state
  • Duration dispti- Duration display support while recording -lating animation for recording state bars
record-button.mov

…states

## Major Changes

### Component Architecture (RecordButton.tsx)
- Complete rewrite based on modern v0.app reference design
- 6 distinct states: idle, recording, processing, disabled, error, success
- 4 visual variants with updated color schemes:
  - default: Red/pink gradient with glow effects
  - outline: Subtle border with amber/orange recording state
  - ghost: Transparent with emerald/green recording state
  - minimal: Clean, understated with blue/indigo recording state
- 3 sizes: sm (40px), md (48px), lg (56px)
- Animated waveform bars component for recording visualization
- Pulse ring animation during recording state
- Duration display support while recording
- Transcription state integration for AI applications
- Both controlled and uncontrolled recording modes
- Built-in MediaRecorder support with callbacks

### Stories & Documentation (RecordButton.
## Major Changes

### Component Architecture (RecordButton.tsx)
- Complete rewrite based on modern v0.app reference design
- 6 distinct states: idle, tch
### Component
- - Complete rewrite based on modern v0.app reta- 6 distinct states: idle, recording, processing, disablegr- 4 visual variants with updated color schemes:
  - default: Red/pink gralt  - default: Red/pink gradient with glow effec e  - outline: Subtle border with amber/orange reng  - ghost: Transparent with emerald/green recording state
 al  - minimal: Clean, understated with blue/indigo recordird- 3 sizize, TranscriptionState, TranscriptionResult
- Clean named- Animated waveform bars component for ree
- Pulse ring animation during recording state
- Duration dispti- Duration display support while recording
-lating animation for recording state bars
Copilot AI review requested due to automatic review settings February 4, 2026 03:17
…onflict

The 'onError' prop conflicted with the native HTMLButtonElement 'onError'
event handler which expects a ReactEventHandler<HTMLButtonElement>.

Renamed to 'onRecordingError' for clarity and type safety.
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Feb 4, 2026

Deploying ui with  Cloudflare Pages  Cloudflare Pages

Latest commit: e39a804
Status: ✅  Deploy successful!
Preview URL: https://a37423c2.ui-6d0.pages.dev
Branch Preview URL: https://feature-record-button-updste.ui-6d0.pages.dev

View logs

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR completely rewrites the RecordButton component with a modern design featuring 6 distinct states (idle, recording, processing, disabled, error, success) and 4 visual variants (default, outline, ghost, minimal). The component now supports both controlled and uncontrolled usage patterns, includes animated waveform bars and pulse ring effects for the recording state, and provides better integration with transcription services.

Changes:

  • Complete component rewrite with enhanced state management (6 states vs 3 previously)
  • New visual variants system with updated color schemes and animations
  • Added waveform animation CSS keyframes to base.css
  • Extensive updates to Storybook documentation with new interactive demos
  • Exports updated to remove unused recordingIndicatorVariants

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.

File Description
src/styles/base.css Adds waveform animation keyframes for the recording visualization feature
src/components/RecordButton/index.ts Updates exports to reflect new type definitions and remove unused variant
src/components/RecordButton/RecordButton.tsx Complete rewrite with new state system, variants, animations, and controlled/uncontrolled support
src/components/RecordButton/RecordButton.stories.tsx Comprehensive Storybook updates with new interactive demos and variant showcases

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…tionality

- Default story now shows uncontrolled recording with audio playback
- InInputField story shows integration with form inputs
- Recording functionality was already implemented, just hidden by controlled state
- Added eslint-disable for audio element caption requirement in demos
… demo

- Default story: Controllable via controls panel (state, variant, size, etc.)
- LiveRecording story: Fully functional recording with audio playback
- Renamed RecordingState to Recording for consistency
Copilot AI review requested due to automatic review settings February 4, 2026 03:30
Replace native <audio> element with AudioPlayer component for consistent styling
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@garrity-miepub
Copy link
Copy Markdown
Contributor Author

Added the live recording demo

live-recording.mov

@garrity-miepub
Copy link
Copy Markdown
Contributor Author

✅ All Copilot Review Comments Addressed

Commit e39a804 addresses all 20 code review comments from Copilot. Here's a summary:

Memory Leak Fixes

Comment Fix
InteractiveDemo nested setTimeout leak Changed to timersRef array with clearTimers() helper
PressAndHoldDemo same timeout leak Same fix applied
setTimeout in MediaRecorder callbacks not cleaned up Added timeoutsRef array + addTimeout() helper + clearAllTimeouts() in cleanup effect

Dependency Array Improvements

Comment Fix
startRecording uses isRecording/isProcessing but not effectiveState Replaced with effectiveState since they're derived values
handleClick has redundant isRecording in deps Removed; now uses effectiveState === 'recording' directly

Accessibility Improvements

Comment Fix
aria-pressed should only be for toggle behavior Changed to aria-pressed={effectiveState === 'recording' ? true : undefined}
Need aria-busy for processing state Added aria-busy={effectiveState === 'processing' ? true : undefined}
Props spread could override aria-label Moved {...props} before aria attributes

Code Quality

Comment Fix
Redundant disabled in Omit type Removed from Omit<..., 'children' | 'disabled'>
Hardcoded emerald-500 for success state Changed to semantic text-success and bg-success/10
Hardcoded red-500 for duration display Changed to text-destructive

Documentation & DX

Comment Fix
State precedence undocumented Added JSDoc section explaining hierarchy: disabledtranscriptionStatestate → internal
Controlled mode limitations unclear Added comprehensive docs about controlled vs uncontrolled modes
Dev warnings for conflicting props Added useEffect with console.warn for conflicting state props

Breaking Change Fix

Comment Fix
variant="filled" no longer exists Updated Dashboard.stories.tsx to use variant="default"

Design Decisions (Documented, Not Changed)

  • Controlled/uncontrolled pattern: Intentionally different from Switch pattern - documented in JSDoc
  • Recording callbacks disabled in controlled mode: By design - documented that controlled mode requires external recording implementation
  • isControlled closure capture: Minor edge case with negligible real-world impact

@garrity-miepub garrity-miepub marked this pull request as ready for review February 4, 2026 03:52
Copilot AI review requested due to automatic review settings February 4, 2026 03:52
@garrity-miepub garrity-miepub merged commit d46b11e into main Feb 4, 2026
13 checks passed
@garrity-miepub garrity-miepub deleted the feature/record-button-updstes branch February 4, 2026 03:52
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// Dev mode warnings for conflicting states
React.useEffect(() => {
// Only warn in development
if (typeof window === 'undefined') return;
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check typeof window === 'undefined' is used to determine if the code is running in development mode, but this check actually determines if the code is running in a browser environment vs Node.js (SSR). This means the warnings won't appear in development SSR builds.

A more appropriate check would be process.env.NODE_ENV !== 'production' to ensure warnings only appear in development, regardless of whether it's client-side or server-side rendering. However, note that this requires a bundler that handles environment variables.

Suggested change
if (typeof window === 'undefined') return;
if (process.env.NODE_ENV === 'production') return;

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +256
const clearTimers = () => {
timersRef.current.forEach(clearTimeout);
timersRef.current = [];
};

const handleClick = () => {
if (state === 'idle') {
// Start recording
setState('recording');
} else if (state === 'recording') {
// Stop recording, start processing
clearTimers();
setState('processing');

// Simulate processing time
const processingTimer = setTimeout(() => {
setState('success');

// Reset to idle after showing success
const successTimer = setTimeout(() => {
setState('idle');
}, 1500);
timersRef.current.push(successTimer);
}, 2000);
timersRef.current.push(processingTimer);
}
};

React.useEffect(() => {
return () => {
clearTimers();
};
}, []);
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clearTimers function is defined inside the component but is also used in the cleanup useEffect. This works, but clearTimers is recreated on every render, which means the effect cleanup will potentially reference a stale version.

While this pattern works due to closure semantics (the cleanup will run with the timersRef from the appropriate render), for consistency and clarity, consider wrapping clearTimers in a useCallback with no dependencies since it only uses timersRef.current which doesn't need to be in dependencies.

Copilot uses AI. Check for mistakes.
/** Callback when recording is complete with the audio blob */
onRecordingComplete?: (blob: Blob, duration: number) => void;
/** Callback when recording starts */
onRecordingStart?: () => void;
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callback prop has been renamed from onError to onRecordingError. This is a breaking change that will affect any existing consumers using the onError prop.

While the new name is more specific and clear about when the error occurs, this breaking change should be clearly documented in the PR description and migration guide. Consider whether providing backward compatibility (accepting both prop names with a deprecation warning) would be beneficial for a smoother migration path.

Suggested change
onRecordingStart?: () => void;
onRecordingStart?: () => void;
/**
* @deprecated Use `onRecordingError` instead. This will be removed in a future major version.
* Backward-compatible alias for `onRecordingError`.
*/
onError?: (error: Error) => void;

Copilot uses AI. Check for mistakes.
Comment on lines +179 to +191
@keyframes waveform {
0%,
100% {
transform: scaleY(0.3);
}
50% {
transform: scaleY(1);
}
}

.animate-waveform {
animation: waveform 0.5s ease-in-out infinite;
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The waveform animation keyframes at lines 179-187 are not wrapped in a @media (prefers-reduced-motion: no-preference) query. This means users who have set prefers-reduced-motion: reduce will still see the waveform animation initially, even though the global rule at line 165 attempts to override animation durations.

The global override at lines 165-173 will reduce the animation duration to 0.01ms, but it's better practice to conditionally define or apply animations only when motion is acceptable. Consider either:

  1. Wrapping the keyframes definition in @media (prefers-reduced-motion: no-preference)
  2. Or providing an alternative static visual indicator for users with reduced motion preferences
Suggested change
@keyframes waveform {
0%,
100% {
transform: scaleY(0.3);
}
50% {
transform: scaleY(1);
}
}
.animate-waveform {
animation: waveform 0.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: no-preference) {
@keyframes waveform {
0%,
100% {
transform: scaleY(0.3);
}
50% {
transform: scaleY(1);
}
}
.animate-waveform {
animation: waveform 0.5s ease-in-out infinite;
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +475 to +664
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 <SpinnerIcon className={iconSize} />;
}
if (isRecording) {
return recordingIcon || <StopIcon className={iconSize} />;
}
return idleIcon || <MicrophoneIcon className={iconSize} />;
};
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<HTMLButtonElement>) => {
// 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 <WaveformBars size={size} />;
}
return recordingIcon || <StopIcon className={iconSize} />;
case 'processing':
return <LoadingSpinner className={iconSize} />;
case 'disabled':
case 'error':
return <MicOffIcon className={iconSize} />;
case 'success':
return <CheckIcon className={iconSize} />;
default:
return idleIcon || <MicIcon className={iconSize} />;
}
};

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 (
<div className="relative inline-flex items-center gap-2">
<button
type="button"
onClick={handleClick}
disabled={disabled || isProcessing || isTranscribing}
className={cn(
recordButtonVariants({ variant, size }),
isRecording && 'text-red-600 dark:text-red-400',
isTranscribing && 'text-primary-600 dark:text-primary-400',
className
return (
<div className="relative inline-flex items-center gap-2">
<button
ref={ref}
type="button"
disabled={isDisabled}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disabled attribute on the button element is set based on isDisabled, which is true when effectiveState is 'disabled' or 'processing'. However, the native disabled HTML attribute and the disabled prop are semantically different:

  • The disabled prop is meant to indicate the button should not be interactive (e.g., no microphone permission)
  • The 'processing' state means the button is temporarily busy, but not permanently disabled

Setting disabled on the button when state='processing' prevents users from canceling or interacting during processing, which might be the desired behavior. However, this conflicts with the standard HTML behavior where disabled elements are not focusable and don't trigger events. Consider using aria-busy and aria-disabled instead of the native disabled attribute for the 'processing' state, or make this behavior configurable.

Copilot uses AI. Check for mistakes.
Comment on lines +606 to +613
if (effectiveState === 'recording') {
stopRecording();
} else if (effectiveState === 'idle') {
startRecording();
}
}
},
[onClick, isControlled, effectiveState, startRecording, stopRecording]
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the handleClick function, the internal recording logic only runs when !isControlled, but the guard conditions check effectiveState === 'recording' and effectiveState === 'idle'. However, effectiveState can be influenced by transcriptionState and disabled props even in uncontrolled mode.

This means if someone sets transcriptionState="transcribing" on an uncontrolled button, the user won't be able to stop an active recording because effectiveState will be 'processing' instead of 'recording', even though the internal state might still be 'recording'.

Consider checking internalState directly when !isControlled, or add logic to handle cases where external state props override the internal recording state.

Suggested change
if (effectiveState === 'recording') {
stopRecording();
} else if (effectiveState === 'idle') {
startRecording();
}
}
},
[onClick, isControlled, effectiveState, startRecording, stopRecording]
// In uncontrolled mode, drive behavior from the internal state
if (internalState === 'recording') {
stopRecording();
} else if (internalState === 'idle') {
startRecording();
}
}
},
[onClick, isControlled, internalState, startRecording, stopRecording]

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 11
export {
RecordButton,
recordButtonVariants,
recordingIndicatorVariants,
formatDuration,
type RecordButtonProps,
type RecordButtonState,
type RecordButtonVariant,
type RecordButtonSize,
type TranscriptionState,
type TranscriptionResult,
} from './RecordButton';
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recordingIndicatorVariants export has been removed from the public API. This is a breaking change for any consumers who were importing and using this variant function directly.

While it's reasonable to remove internal implementation details from the public API, this should be documented in the PR description or in a CHANGELOG/breaking changes section to help consumers migrate. If this variant is no longer needed because the recording indicator functionality has changed, consider mentioning this explicitly.

Copilot uses AI. Check for mistakes.
| 'error'
| 'success';

export type RecordButtonVariant = 'default' | 'outline' | 'ghost' | 'minimal';
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variant prop values have completely changed from the old set ('default', 'filled', 'primary') to a new set ('default', 'outline', 'ghost', 'minimal'). This is a breaking change where:

  • 'filled' variant is removed (replaced by 'default' or other variants)
  • 'primary' variant is removed (no direct replacement)
  • 'outline', 'ghost', and 'minimal' are new additions

All existing code using 'filled' or 'primary' variants will need to be updated. The Dashboard.stories.tsx file shows examples of this migration where 'filled' was changed to 'default'. Consider:

  1. Documenting a clear migration path for each removed variant
  2. Adding runtime warnings when deprecated variants are used (if supporting a transition period)
  3. Providing a codemod script to help automate the migration
Suggested change
export type RecordButtonVariant = 'default' | 'outline' | 'ghost' | 'minimal';
/**
* Visual style variants for the RecordButton.
*
* Legacy variants:
* - 'filled' deprecated, use 'default' (or another new variant) instead.
* - 'primary' deprecated, no direct replacement; prefer 'default' or a
* context-appropriate combination of 'outline' | 'ghost' | 'minimal'.
*/
export type RecordButtonVariant =
| 'default'
| 'outline'
| 'ghost'
| 'minimal'
// Legacy, supported for backwards compatibility:
| 'filled'
| 'primary';

Copilot uses AI. Check for mistakes.
Comment on lines +238 to +240
sm: 'size-10',
md: 'size-12',
lg: 'size-14',
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The button sizes have changed from height-based definitions (h-7/w-7, h-9/w-9, h-11/w-11) to the newer size-* utility (size-10, size-12, size-14). This is a breaking change in actual rendered dimensions:

Old sizes:

  • sm: 28px (h-7/w-7 = 1.75rem)
  • md: 36px (h-9/w-9 = 2.25rem)
  • lg: 44px (h-11/w-11 = 2.75rem)

New sizes:

  • sm: 40px (size-10 = 2.5rem)
  • md: 48px (size-12 = 3rem)
  • lg: 56px (size-14 = 3.5rem)

All sizes have increased by 12px, which may affect layouts where RecordButton is used. While the PR description mentions sizes of "40px, 48px, 56px" matching the implementation, existing integrations expecting the old sizes will see larger buttons. Consider documenting this size change explicitly in the migration guide.

Suggested change
sm: 'size-10',
md: 'size-12',
lg: 'size-14',
sm: 'h-7 w-7',
md: 'h-9 w-9',
lg: 'h-11 w-11',

Copilot uses AI. Check for mistakes.
Comment on lines +547 to +584
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 <SpinnerIcon className={iconSize} />;
}
if (isRecording) {
return recordingIcon || <StopIcon className={iconSize} />;
}
return idleIcon || <MicrophoneIcon className={iconSize} />;
};
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);
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout callbacks in addTimeout at lines 547, 552, 581 are wrapped in the tracking mechanism, but they use the isControlled variable from the outer closure. If the component's controlled state changes (e.g., the state prop is added or removed) between when the timeout is set and when it fires, the behavior could be inconsistent.

For safety, consider capturing isControlled in a variable at the time the timeout is set, or checking the current controlled state when the timeout fires.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants