added reduce motion support for css animations and streaming text (#6551)

This commit is contained in:
Zane
2026-01-28 09:13:31 -08:00
committed by GitHub
parent 932923e31b
commit 7ae15fdc71
4 changed files with 120 additions and 19 deletions
+9 -11
View File
@@ -6,7 +6,6 @@ interface TextSplitterOptions {
splitTypeTypes?: ('lines' | 'words' | 'chars')[];
}
// Class to split text into lines, words, and characters for animation
export class TextSplitter {
textElement: HTMLElement;
onResize: (() => void) | null;
@@ -31,9 +30,7 @@ export class TextSplitter {
}
initResizeObserver() {
// Use a simpler approach to avoid type issues
const resizeObserver = new ResizeObserver(() => {
// Just check the current width directly from the element
if (this.textElement) {
const currentWidth = Math.floor(this.textElement.getBoundingClientRect().width);
@@ -197,15 +194,11 @@ export class TextAnimator {
}
reset() {
// Clear all timeouts
this.activeTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
this.activeTimeouts = [];
// Cancel all animations
this.activeAnimations.forEach((animation) => animation.cancel());
this.activeAnimations = [];
// Reset text content
const chars = this.splitter.getChars();
chars.forEach((char, index) => {
if (this.originalChars[index]) {
@@ -219,6 +212,10 @@ interface UseTextAnimatorProps {
text: string;
}
function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
export function useTextAnimator({ text }: UseTextAnimatorProps) {
const elementRef = useRef<HTMLDivElement>(null);
const animator = useRef<TextAnimator | null>(null);
@@ -226,22 +223,23 @@ export function useTextAnimator({ text }: UseTextAnimatorProps) {
useEffect(() => {
if (!elementRef.current) return;
// Create animator
if (prefersReducedMotion()) {
return;
}
animator.current = new TextAnimator(elementRef.current);
// Small delay to ensure content is ready
const timeoutId = setTimeout(() => {
animator.current?.animate();
}, 100);
// Cleanup
return () => {
window.clearTimeout(timeoutId);
if (animator.current) {
animator.current.reset();
}
};
}, [text]); // Re-run when text changes
}, [text]);
return elementRef;
}
+62 -8
View File
@@ -195,6 +195,12 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
}
}
function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
const REDUCED_MOTION_BATCH_INTERVAL = 1000;
async function streamFromResponse(
stream: AsyncIterable<MessageEvent>,
initialMessages: Message[],
@@ -203,6 +209,49 @@ async function streamFromResponse(
sessionId: string
): Promise<void> {
let currentMessages = initialMessages;
const reduceMotion = prefersReducedMotion();
let latestTokenState: TokenState | null = null;
let latestChatState: ChatState = ChatState.Streaming;
let lastBatchUpdate = Date.now();
let hasPendingUpdate = false;
const flushBatchedUpdates = () => {
if (reduceMotion && hasPendingUpdate) {
if (latestTokenState) {
dispatch({ type: 'SET_TOKEN_STATE', payload: latestTokenState });
}
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
dispatch({ type: 'SET_CHAT_STATE', payload: latestChatState });
hasPendingUpdate = false;
lastBatchUpdate = Date.now();
}
};
const maybeUpdateUI = (
tokenState: TokenState,
chatState: ChatState,
forceImmediate = false
) => {
if (!reduceMotion) {
dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState });
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
dispatch({ type: 'SET_CHAT_STATE', payload: chatState });
} else if (forceImmediate) {
dispatch({ type: 'SET_TOKEN_STATE', payload: tokenState });
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
dispatch({ type: 'SET_CHAT_STATE', payload: chatState });
hasPendingUpdate = false;
lastBatchUpdate = Date.now();
} else {
latestTokenState = tokenState;
latestChatState = chatState;
hasPendingUpdate = true;
const now = Date.now();
if (now - lastBatchUpdate >= REDUCED_MOTION_BATCH_INTERVAL) {
flushBatchedUpdates();
}
}
};
try {
for await (const event of stream) {
@@ -221,24 +270,23 @@ async function streamFromResponse(
);
if (hasToolConfirmation || hasElicitation) {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.WaitingForUserInput });
maybeUpdateUI(event.token_state, ChatState.WaitingForUserInput, true);
} else if (getCompactingMessage(msg)) {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Compacting });
maybeUpdateUI(event.token_state, ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Thinking });
maybeUpdateUI(event.token_state, ChatState.Thinking);
} else {
dispatch({ type: 'SET_CHAT_STATE', payload: ChatState.Streaming });
maybeUpdateUI(event.token_state, ChatState.Streaming);
}
dispatch({ type: 'SET_TOKEN_STATE', payload: event.token_state });
dispatch({ type: 'SET_MESSAGES', payload: currentMessages });
break;
}
case 'Error': {
flushBatchedUpdates();
onFinish('Stream error: ' + event.error);
return;
}
case 'Finish': {
flushBatchedUpdates();
onFinish();
return;
}
@@ -247,7 +295,11 @@ async function streamFromResponse(
}
case 'UpdateConversation': {
currentMessages = event.conversation;
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
if (!reduceMotion) {
dispatch({ type: 'SET_MESSAGES', payload: event.conversation });
} else {
hasPendingUpdate = true;
}
break;
}
case 'Notification': {
@@ -260,8 +312,10 @@ async function streamFromResponse(
}
}
flushBatchedUpdates();
onFinish();
} catch (error) {
flushBatchedUpdates();
if (error instanceof Error && error.name !== 'AbortError') {
onFinish('Stream error: ' + errorMessage(error));
}
+40
View File
@@ -347,6 +347,46 @@
}
}
/* Global reduced motion support - applies when user has system preference set */
@media (prefers-reduced-motion: reduce) {
/* Disable all CSS animations and transitions, except for toasts which need transitions for auto-dismiss */
*:not(.Toastify *),
*:not(.Toastify *)::before,
*:not(.Toastify *)::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* Specific animation classes that should be disabled */
.animate-shimmer,
.animate-spin,
.animate-pulse,
.animate-bounce,
[class*='animate-']:not(.Toastify *) {
animation: none !important;
}
/* Page transitions */
.page-transition {
animation: none !important;
opacity: 1 !important;
}
/* Goose icon entrance animation */
.goose-icon-entrance {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
/* Wind/rain animation in welcome logo */
[class*='animate-[wind'] {
animation: none !important;
}
}
/* Toast close button styling */
.Toastify__close-button {
color: var(--text-prominent-inverse) !important;
+9
View File
@@ -41,3 +41,12 @@
animation: collapseUp 0.15s ease-out forwards;
overflow: hidden;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.search-bar-enter,
.search-bar-exit {
animation: none;
max-height: var(--search-bar-height);
}
}