Widget Animation Patterns: Framer Motion, CSS Transitions & Performance
Smooth, purposeful animations transform ChatGPT widgets from functional interfaces into delightful user experiences. But poorly implemented animations can destroy performance, trigger motion sickness, and violate OpenAI's widget runtime constraints. This guide reveals production-ready animation patterns that achieve 60fps performance while respecting accessibility requirements and mobile device limitations.
When users interact with your ChatGPT widget, animations provide critical feedback: confirming actions, revealing relationships, and guiding attention. A card sliding into view signals new content. A button scaling on press confirms the tap. A spinner rotating indicates loading. These micro-interactions separate professional widgets from amateur implementations.
However, ChatGPT's widget runtime imposes unique constraints. Your animations must remain performant within the 4,000-token structuredContent limit, work seamlessly across iOS and Android system webviews, and never interfere with ChatGPT's core conversation flow. This requires careful selection of animation techniques, aggressive performance optimization, and comprehensive accessibility support.
This article provides seven production-ready code examples totaling 740 lines of TypeScript and CSS. You'll learn how to integrate Framer Motion for complex React animations, optimize CSS transitions for GPU acceleration, implement prefers-reduced-motion support, monitor animation performance, and build a reusable animation library. By the end, you'll create widget animations that feel native to ChatGPT while maintaining flawless 60fps performance.
Let's build animations that users love and OpenAI approves.
Framer Motion Integration for React Widgets
Framer Motion is the gold standard for React animations, offering declarative syntax, gesture support, and automatic performance optimization. For ChatGPT widgets, Framer Motion excels at orchestrating complex multi-element animations, managing AnimatePresence for mount/unmount transitions, and handling gesture-driven interactions.
Why Framer Motion for ChatGPT Widgets:
- Declarative API: Define animations in JSX, matching React's component model
- AnimatePresence: Smooth exit animations when removing elements (critical for cards/carousels)
- Gesture Support: Built-in drag, tap, and hover handlers for interactive widgets
- Variants: Reusable animation states for consistent motion design
- Layout Animations: Automatic position/scale transitions when component layout changes
Production Setup (120 lines):
// widget-animations.tsx - Framer Motion setup for ChatGPT widgets
import { motion, AnimatePresence, Variants } from 'framer-motion';
import { useState } from 'react';
// Animation variants for consistent motion design
const fadeInUp: Variants = {
hidden: {
opacity: 0,
y: 20,
transition: { duration: 0.3, ease: 'easeOut' }
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.3, ease: 'easeOut' }
},
exit: {
opacity: 0,
y: -10,
transition: { duration: 0.2, ease: 'easeIn' }
}
};
const scaleButton: Variants = {
idle: { scale: 1 },
hover: { scale: 1.05, transition: { duration: 0.15 } },
tap: { scale: 0.95, transition: { duration: 0.1 } }
};
const slideInCard: Variants = {
hidden: { x: -300, opacity: 0 },
visible: {
x: 0,
opacity: 1,
transition: { type: 'spring', stiffness: 300, damping: 30 }
},
exit: {
x: 300,
opacity: 0,
transition: { duration: 0.2 }
}
};
// Animated card component with enter/exit transitions
export function AnimatedCard({ children, id }: { children: React.ReactNode; id: string }) {
return (
<motion.div
key={id}
variants={fadeInUp}
initial="hidden"
animate="visible"
exit="exit"
layout // Automatic position animations when siblings change
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(255, 255, 255, 0.1)'
}}
>
{children}
</motion.div>
);
}
// Interactive button with gesture animations
export function AnimatedButton({
children,
onClick,
disabled = false
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<motion.button
variants={scaleButton}
initial="idle"
whileHover={disabled ? 'idle' : 'hover'}
whileTap={disabled ? 'idle' : 'tap'}
onClick={onClick}
disabled={disabled}
style={{
backgroundColor: '#D4AF37',
color: '#0A0E27',
padding: '12px 24px',
borderRadius: '8px',
border: 'none',
fontWeight: 600,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1
}}
>
{children}
</motion.button>
);
}
// Carousel with AnimatePresence for smooth item transitions
export function AnimatedCarousel({ items }: { items: Array<{ id: string; content: string }> }) {
const [currentIndex, setCurrentIndex] = useState(0);
const currentItem = items[currentIndex];
return (
<div style={{ position: 'relative', overflow: 'hidden', minHeight: '200px' }}>
<AnimatePresence mode="wait">
<motion.div
key={currentItem.id}
variants={slideInCard}
initial="hidden"
animate="visible"
exit="exit"
style={{
position: 'absolute',
width: '100%',
padding: '20px',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: '12px'
}}
>
<p>{currentItem.content}</p>
</motion.div>
</AnimatePresence>
<div style={{ position: 'absolute', bottom: '10px', left: '50%', transform: 'translateX(-50%)' }}>
{items.map((_, index) => (
<motion.button
key={index}
whileTap={{ scale: 0.9 }}
onClick={() => setCurrentIndex(index)}
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: index === currentIndex ? '#D4AF37' : 'rgba(255, 255, 255, 0.3)',
border: 'none',
margin: '0 4px',
cursor: 'pointer',
padding: 0
}}
/>
))}
</div>
</div>
);
}
// Staggered list animations (children animate in sequence)
export function StaggeredList({ children }: { children: React.ReactNode[] }) {
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // 100ms delay between each child
delayChildren: 0.2
}
}
};
const itemVariants: Variants = {
hidden: { opacity: 0, x: -20 },
visible: { opacity: 1, x: 0 }
};
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
style={{ listStyle: 'none', padding: 0, margin: 0 }}
>
{children.map((child, index) => (
<motion.li key={index} variants={itemVariants}>
{child}
</motion.li>
))}
</motion.ul>
);
}
Key Techniques:
- Variants: Define reusable animation states (
hidden,visible,exit) for consistent motion - AnimatePresence: Enable exit animations when components unmount (critical for carousels)
- Layout Prop: Automatic smooth transitions when component position/size changes
- Gesture Handlers:
whileHover,whileTapfor interactive feedback - Staggered Animations:
staggerChildrencreates cascading effects for lists
For more React widget patterns, see our comprehensive widget development guide.
CSS Transitions: Lightweight Performance Champions
While Framer Motion excels at complex React animations, CSS transitions dominate for simple state changes and maximum performance. CSS transitions run on the GPU compositor thread, avoiding JavaScript execution overhead and achieving buttery-smooth 60fps even on low-end devices.
When to Use CSS Transitions:
- Simple state changes: Hover effects, active states, visibility toggles
- Performance-critical: Mobile devices, low-power mode, complex widget layouts
- Non-React widgets: Vanilla JavaScript implementations without React overhead
- Fallback support: Works in all browsers without JavaScript dependencies
Production Utility Classes (130 lines):
/* animation-utilities.css - GPU-optimized CSS transitions */
/* Core transition utilities */
.transition-fast {
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); /* ease-out */
}
.transition-normal {
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-slow {
transition-duration: 500ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-spring {
transition-duration: 400ms;
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* spring-like bounce */
}
/* Property-specific transitions (GPU-accelerated only) */
.transition-opacity {
transition-property: opacity;
will-change: opacity; /* Hint browser to optimize */
}
.transition-transform {
transition-property: transform;
will-change: transform;
}
.transition-all-gpu {
transition-property: opacity, transform;
will-change: opacity, transform;
}
/* Interactive states */
.hover-lift {
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-lift:hover {
transform: translateY(-4px);
}
.hover-scale {
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-scale:hover {
transform: scale(1.05);
}
.active-press {
transition: transform 100ms cubic-bezier(0.4, 0, 1, 1); /* ease-in for press */
}
.active-press:active {
transform: scale(0.95);
}
/* Fade animations */
.fade-enter {
opacity: 0;
animation: fadeIn 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.fade-exit {
opacity: 1;
animation: fadeOut 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes fadeOut {
to {
opacity: 0;
}
}
/* Slide animations */
.slide-up-enter {
opacity: 0;
transform: translateY(20px);
animation: slideUp 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes slideUp {
to {
opacity: 1;
transform: translateY(0);
}
}
.slide-down-exit {
opacity: 1;
transform: translateY(0);
animation: slideDown 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes slideDown {
to {
opacity: 0;
transform: translateY(-10px);
}
}
/* Loading spinner (GPU-accelerated) */
.spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: #d4af37;
border-radius: 50%;
animation: spin 800ms linear infinite;
will-change: transform;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Skeleton loading (shimmer effect) */
.skeleton {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 8px;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Pulse animation (for attention-grabbing elements) */
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Safe remove will-change after animation completes */
.transition-complete {
will-change: auto;
}
Critical Performance Rules:
- Only animate opacity and transform: These properties are GPU-composited (avoid animating width, height, top, left)
- Use will-change sparingly: Hints browser to optimize, but overuse creates memory pressure
- Remove will-change after animation: Add
.transition-completeclass when done - Prefer transform over position:
translateY()is 10x faster than animatingtop - Use cubic-bezier timing: Custom easing curves feel more natural than
linear/ease
For CSS performance deep-dives, explore our widget performance optimization guide.
GPU Acceleration: The 60fps Secret
Modern browsers render animations on two threads: the main thread (JavaScript execution, DOM manipulation) and the compositor thread (GPU-accelerated layer painting). To achieve 60fps, animations must run exclusively on the compositor thread, avoiding main thread work that blocks rendering.
GPU-Composited Properties:
- ✅
opacity: Fades elements without repainting - ✅
transform: Translate, scale, rotate without layout recalculation - ❌
width,height: Triggers layout, reflows entire page - ❌
top,left: Repaints element and siblings - ❌
color,background-color: Repaints pixels (use with caution)
Production GPU Optimization (110 lines):
// gpu-animations.tsx - GPU-accelerated React components
import { motion } from 'framer-motion';
import { useEffect, useRef, useState } from 'react';
// GPU-optimized card component
export function GPUCard({
children,
isVisible
}: {
children: React.ReactNode;
isVisible: boolean;
}) {
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!cardRef.current) return;
// Force GPU layer creation (use sparingly)
if (isVisible) {
cardRef.current.style.willChange = 'opacity, transform';
} else {
// Remove will-change when animation completes
const timer = setTimeout(() => {
if (cardRef.current) {
cardRef.current.style.willChange = 'auto';
}
}, 300);
return () => clearTimeout(timer);
}
}, [isVisible]);
return (
<motion.div
ref={cardRef}
initial={{ opacity: 0, transform: 'translateY(20px)' }}
animate={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(20px)'
}}
transition={{ duration: 0.3, ease: 'easeOut' }}
style={{
// Force 3D rendering context (enables GPU acceleration)
transform: 'translate3d(0, 0, 0)',
backfaceVisibility: 'hidden',
perspective: 1000,
// Prevent subpixel rendering issues
WebkitFontSmoothing: 'antialiased',
MozOsxFontSmoothing: 'grayscale'
}}
>
{children}
</motion.div>
);
}
// Infinite scroll list with GPU-optimized virtualization
export function GPUScrollList({ items }: { items: string[] }) {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });
const scrollRef = useRef<HTMLDivElement>(null);
const handleScroll = () => {
if (!scrollRef.current) return;
const scrollTop = scrollRef.current.scrollTop;
const itemHeight = 60; // Fixed height for GPU optimization
const start = Math.floor(scrollTop / itemHeight);
const end = start + 20;
setVisibleRange({ start, end });
};
return (
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
height: '400px',
overflowY: 'auto',
// Enable GPU-accelerated scrolling
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch' // iOS momentum scrolling
}}
>
<div style={{ height: `${items.length * 60}px`, position: 'relative' }}>
{items.slice(visibleRange.start, visibleRange.end).map((item, index) => (
<motion.div
key={visibleRange.start + index}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
position: 'absolute',
top: `${(visibleRange.start + index) * 60}px`,
height: '60px',
width: '100%',
// GPU-accelerated positioning
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}
>
{item}
</motion.div>
))}
</div>
</div>
);
}
// Performance monitoring utility
export function useGPUPerformance() {
const [fps, setFps] = useState(60);
const frameRef = useRef(0);
const lastTimeRef = useRef(performance.now());
useEffect(() => {
let rafId: number;
const measureFPS = (currentTime: number) => {
frameRef.current++;
const delta = currentTime - lastTimeRef.current;
if (delta >= 1000) {
// Update FPS every second
setFps(Math.round((frameRef.current * 1000) / delta));
frameRef.current = 0;
lastTimeRef.current = currentTime;
}
rafId = requestAnimationFrame(measureFPS);
};
rafId = requestAnimationFrame(measureFPS);
return () => cancelAnimationFrame(rafId);
}, []);
return { fps, isPerformant: fps >= 55 }; // Allow 5fps tolerance
}
GPU Acceleration Checklist:
- ✅ Use
transform: translate3d(0, 0, 0)to force GPU layer - ✅ Set
will-change: transform, opacitybefore animation starts - ✅ Remove
will-change: autoafter animation completes (avoid memory leaks) - ✅ Use
backfaceVisibility: hiddento prevent flickering - ✅ Enable
-webkit-overflow-scrolling: touchfor iOS momentum scrolling
Learn more about mobile optimization in our mobile-first widget design guide.
Accessibility: Respecting Reduced Motion Preferences
The Problem: Many users experience motion sickness, vertigo, or seizures from animations. The prefers-reduced-motion media query lets users disable animations system-wide. Ignoring this preference violates WCAG 2.1 (Guideline 2.3) and can make your widget unusable for disabled users.
The Solution: Detect prefers-reduced-motion and disable decorative animations while preserving critical feedback (e.g., loading states, error alerts).
Production Reduced Motion Hook (100 lines):
// use-reduced-motion.ts - Accessibility-first animation hook
import { useEffect, useState } from 'react';
import { Variants } from 'framer-motion';
/**
* Hook to detect user's reduced motion preference
* Returns true if user prefers reduced motion
*/
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(mediaQuery.matches);
// Listen for preference changes (rare, but possible)
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
/**
* Create animation variants that respect reduced motion
* Returns instant transitions if user prefers reduced motion
*/
export function createAccessibleVariants(
normalVariants: Variants,
reducedMotion: boolean
): Variants {
if (!reducedMotion) return normalVariants;
// Convert all animations to instant transitions
const accessibleVariants: Variants = {};
for (const [key, value] of Object.entries(normalVariants)) {
if (typeof value === 'object' && 'transition' in value) {
accessibleVariants[key] = {
...value,
transition: { duration: 0.01 } // Near-instant (0 causes bugs)
};
} else {
accessibleVariants[key] = value;
}
}
return accessibleVariants;
}
/**
* Accessible animated component wrapper
* Automatically disables animations for reduced motion users
*/
export function AccessibleMotion({
children,
variants,
...props
}: {
children: React.ReactNode;
variants: Variants;
[key: string]: any;
}) {
const reducedMotion = useReducedMotion();
const accessibleVariants = createAccessibleVariants(variants, reducedMotion);
return (
<motion.div variants={accessibleVariants} {...props}>
{children}
</motion.div>
);
}
// Example usage in production widget
export function AccessibleCard() {
const reducedMotion = useReducedMotion();
const cardVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: reducedMotion
? { duration: 0.01 }
: { duration: 0.3, ease: 'easeOut' }
}
};
return (
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
style={{
padding: '16px',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderRadius: '12px'
}}
>
<h3>Accessible Content</h3>
<p>This animation respects user preferences</p>
</motion.div>
);
}
CSS Approach (simpler for non-React widgets):
/* Disable all decorative animations for reduced motion users */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* BUT: Keep critical loading indicators */
.spinner,
.skeleton,
[role='progressbar'] {
animation-duration: revert !important;
transition-duration: revert !important;
}
}
For comprehensive accessibility guidance, see our WCAG compliance guide for widgets.
Performance Monitoring: Detecting Animation Jank
Even with GPU optimization, animations can stutter due to heavy JavaScript execution, network requests, or browser throttling. Production widgets need real-time performance monitoring to detect jank and gracefully degrade animations.
Animation Performance Monitor (80 lines):
// animation-monitor.ts - Real-time performance tracking
export class AnimationMonitor {
private frames: number[] = [];
private rafId: number | null = null;
private lastFrameTime: number = 0;
private isMonitoring: boolean = false;
/**
* Start monitoring frame rate
* Calls onJank when FPS drops below threshold
*/
start(options: {
fpsThreshold?: number;
onJank?: (fps: number) => void;
onFPSUpdate?: (fps: number) => void;
}) {
const { fpsThreshold = 55, onJank, onFPSUpdate } = options;
this.isMonitoring = true;
this.lastFrameTime = performance.now();
const measure = (currentTime: number) => {
if (!this.isMonitoring) return;
const delta = currentTime - this.lastFrameTime;
this.frames.push(delta);
// Keep last 60 frames (1 second at 60fps)
if (this.frames.length > 60) {
this.frames.shift();
}
// Calculate average FPS
if (this.frames.length >= 10) {
const avgDelta = this.frames.reduce((a, b) => a + b, 0) / this.frames.length;
const fps = 1000 / avgDelta;
onFPSUpdate?.(Math.round(fps));
// Detect jank (sustained low FPS)
if (fps < fpsThreshold && this.frames.length === 60) {
onJank?.(Math.round(fps));
}
}
this.lastFrameTime = currentTime;
this.rafId = requestAnimationFrame(measure);
};
this.rafId = requestAnimationFrame(measure);
}
/**
* Stop monitoring
*/
stop() {
this.isMonitoring = false;
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.frames = [];
}
/**
* Get current average FPS
*/
getCurrentFPS(): number {
if (this.frames.length === 0) return 60;
const avgDelta = this.frames.reduce((a, b) => a + b, 0) / this.frames.length;
return Math.round(1000 / avgDelta);
}
}
// React hook wrapper
export function useAnimationPerformance() {
const [fps, setFps] = useState(60);
const [hasJank, setHasJank] = useState(false);
const monitorRef = useRef(new AnimationMonitor());
useEffect(() => {
const monitor = monitorRef.current;
monitor.start({
fpsThreshold: 55,
onFPSUpdate: setFps,
onJank: () => setHasJank(true)
});
return () => monitor.stop();
}, []);
return { fps, hasJank, isPerformant: fps >= 55 };
}
Usage Example:
function PerformanceAwareWidget() {
const { fps, hasJank } = useAnimationPerformance();
// Disable complex animations if jank detected
const shouldAnimate = !hasJank && fps >= 55;
return (
<div>
<motion.div
animate={shouldAnimate ? { opacity: 1, y: 0 } : { opacity: 1, y: 0 }}
transition={shouldAnimate ? { duration: 0.3 } : { duration: 0.01 }}
>
Widget Content
</motion.div>
{process.env.NODE_ENV === 'development' && (
<div style={{ position: 'fixed', top: 10, right: 10 }}>
FPS: {fps} {hasJank && '⚠️ Jank detected'}
</div>
)}
</div>
);
}
Gesture Handlers: Touch and Drag Interactions
Interactive animations respond to user gestures—drag to dismiss cards, swipe to navigate carousels, pinch to zoom. Framer Motion provides production-ready gesture handlers that work across touch and mouse inputs.
Production Gesture Handler (90 lines):
// gesture-animations.tsx - Touch and drag interactions
import { motion, useMotionValue, useTransform } from 'framer-motion';
/**
* Draggable card with dismiss gesture
* Swipe left/right to dismiss
*/
export function DismissibleCard({
children,
onDismiss
}: {
children: React.ReactNode;
onDismiss: () => void;
}) {
const x = useMotionValue(0);
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0]);
return (
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.2}
onDragEnd={(_, info) => {
// Dismiss if dragged > 150px
if (Math.abs(info.offset.x) > 150) {
onDismiss();
}
}}
style={{
x,
opacity,
cursor: 'grab',
padding: '16px',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderRadius: '12px'
}}
whileDrag={{ cursor: 'grabbing' }}
>
{children}
</motion.div>
);
}
/**
* Swipeable carousel with momentum
*/
export function SwipeCarousel({ items }: { items: string[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const x = useMotionValue(0);
const handleDragEnd = (event: any, info: any) => {
const threshold = 50;
if (info.offset.x > threshold && currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
} else if (info.offset.x < -threshold && currentIndex < items.length - 1) {
setCurrentIndex(currentIndex + 1);
}
};
return (
<div style={{ overflow: 'hidden', position: 'relative', height: '200px' }}>
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.1}
onDragEnd={handleDragEnd}
animate={{ x: -currentIndex * 300 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
display: 'flex',
gap: '16px',
x,
cursor: 'grab'
}}
whileDrag={{ cursor: 'grabbing' }}
>
{items.map((item, index) => (
<div
key={index}
style={{
minWidth: '300px',
padding: '20px',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: '12px'
}}
>
{item}
</div>
))}
</motion.div>
</div>
);
}
/**
* Long-press gesture detector
*/
export function LongPressButton({
children,
onLongPress
}: {
children: React.ReactNode;
onLongPress: () => void;
}) {
const [isPressed, setIsPressed] = useState(false);
return (
<motion.button
onTapStart={() => setIsPressed(true)}
onTap={() => setIsPressed(false)}
onTapCancel={() => setIsPressed(false)}
onPanStart={(event, info) => {
setTimeout(() => {
if (isPressed) {
onLongPress();
}
}, 800); // 800ms long press threshold
}}
whileTap={{ scale: 0.95 }}
style={{
padding: '12px 24px',
backgroundColor: '#D4AF37',
color: '#0A0E27',
borderRadius: '8px',
border: 'none',
fontWeight: 600
}}
>
{children}
</motion.button>
);
}
Reusable Animation Library
Consolidate animation patterns into a reusable library to maintain consistency across your widget codebase.
Production Animation Library (110 lines):
// animation-library.ts - Reusable animation presets
import { Variants } from 'framer-motion';
/**
* Fade animations
*/
export const fadeVariants = {
fadeIn: {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.3 } }
},
fadeOut: {
visible: { opacity: 1 },
hidden: { opacity: 0, transition: { duration: 0.2 } }
},
fadeInUp: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3, ease: 'easeOut' } }
}
};
/**
* Scale animations
*/
export const scaleVariants = {
scaleIn: {
hidden: { opacity: 0, scale: 0.9 },
visible: { opacity: 1, scale: 1, transition: { duration: 0.3 } }
},
scaleOut: {
visible: { opacity: 1, scale: 1 },
hidden: { opacity: 0, scale: 0.9, transition: { duration: 0.2 } }
}
};
/**
* Slide animations
*/
export const slideVariants = {
slideInLeft: {
hidden: { x: -300, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { type: 'spring', stiffness: 300, damping: 30 } }
},
slideInRight: {
hidden: { x: 300, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { type: 'spring', stiffness: 300, damping: 30 } }
}
};
/**
* Gesture animations
*/
export const gestureVariants = {
buttonTap: {
idle: { scale: 1 },
hover: { scale: 1.05, transition: { duration: 0.15 } },
tap: { scale: 0.95, transition: { duration: 0.1 } }
},
cardHover: {
idle: { y: 0, boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)' },
hover: {
y: -4,
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.2)',
transition: { duration: 0.2 }
}
}
};
/**
* Stagger container variants
*/
export const staggerVariants = {
container: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2
}
}
},
item: {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
}
};
/**
* Loading animations
*/
export const loadingVariants = {
spinner: {
animate: {
rotate: 360,
transition: { duration: 0.8, repeat: Infinity, ease: 'linear' }
}
},
pulse: {
animate: {
opacity: [1, 0.5, 1],
transition: { duration: 1.5, repeat: Infinity, ease: 'easeInOut' }
}
}
};
/**
* Utility: Combine multiple variants
*/
export function combineVariants(...variants: Variants[]): Variants {
return Object.assign({}, ...variants);
}
/**
* Utility: Create responsive animation (disable on mobile)
*/
export function responsiveVariant(variant: Variants, isMobile: boolean): Variants {
if (isMobile) {
// Instant transitions on mobile for performance
const mobileVariant: Variants = {};
for (const [key, value] of Object.entries(variant)) {
if (typeof value === 'object') {
mobileVariant[key] = { ...value, transition: { duration: 0.01 } };
}
}
return mobileVariant;
}
return variant;
}
Conclusion: Animation Best Practices for ChatGPT Widgets
Mastering widget animations requires balancing three competing forces: delightful user experience, 60fps performance, and accessibility compliance. By combining Framer Motion's declarative power with CSS transitions' GPU efficiency, you create animations that feel native to ChatGPT while respecting user preferences and device constraints.
Key Takeaways:
- Choose the Right Tool: Framer Motion for complex React animations, CSS transitions for simple state changes
- GPU Acceleration is Mandatory: Only animate
opacityandtransform, usewill-changestrategically - Respect Reduced Motion: Detect
prefers-reduced-motionand disable decorative animations - Monitor Performance: Track FPS in production, gracefully degrade animations when jank occurs
- Build a Library: Create reusable animation variants for consistency across widgets
For ChatGPT app builders, animation quality separates professional submissions from rejections. OpenAI's reviewers test widgets on both high-end and low-end devices, in light and dark modes, with reduced motion enabled. A widget that stutters on a 3-year-old iPhone or ignores accessibility preferences will fail review—no matter how innovative the core functionality.
The seven code examples in this guide (740 lines total) provide production-ready foundations: Framer Motion integration, CSS utilities, GPU optimization, reduced motion hooks, performance monitoring, gesture handlers, and a reusable animation library. Adapt these patterns to your specific use case, test rigorously across devices, and watch your widget approval rates soar.
Ready to build fluid, accessible ChatGPT widget animations? Start your free trial with MakeAIHQ and deploy production-ready widgets in 48 hours—no animation expertise required. Our platform automatically generates GPU-optimized code, implements reduced motion support, and ensures OpenAI approval on first submission.
Internal Resources
- Complete Widget Development Guide - Comprehensive widget architecture patterns
- Widget Performance Optimization - Core Web Vitals and loading speed
- Mobile-First Widget Design - Responsive patterns for iOS and Android
- Widget Accessibility (WCAG) - Keyboard navigation, screen readers, ARIA
- Widget State Management - React state, Zustand, and window.openai.setWidgetState
- Advanced Widget UX Patterns - Carousels, forms, error handling
- Widget Testing Strategies - Unit tests, integration tests, visual regression
External Resources
- Framer Motion Documentation - Official Framer Motion API reference and examples
- CSS Animations Guide (MDN) - Comprehensive CSS animation tutorial
- Web Animations API - JavaScript animation standard for complex interactions
Schema.org Structured Data:
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Widget Animation Patterns: Framer Motion & CSS Transitions",
"description": "Create smooth ChatGPT widget animations: Framer Motion integration, CSS transitions, GPU acceleration, reduced motion, and 60fps performance.",
"step": [
{
"@type": "HowToStep",
"name": "Integrate Framer Motion",
"text": "Install Framer Motion and create animation variants for declarative React animations with AnimatePresence, gestures, and layout transitions."
},
{
"@type": "HowToStep",
"name": "Optimize CSS Transitions",
"text": "Use GPU-accelerated properties (opacity, transform) with cubic-bezier timing functions for lightweight, performant state changes."
},
{
"@type": "HowToStep",
"name": "Enable GPU Acceleration",
"text": "Force GPU compositing with translate3d, will-change hints, and backfaceVisibility to achieve 60fps on low-end devices."
},
{
"@type": "HowToStep",
"name": "Implement Reduced Motion Support",
"text": "Detect prefers-reduced-motion media query and disable decorative animations for users with motion sensitivities (WCAG 2.1 compliance)."
},
{
"@type": "HowToStep",
"name": "Monitor Animation Performance",
"text": "Track frame rate with requestAnimationFrame, detect jank, and gracefully degrade animations when performance drops below 55fps."
},
{
"@type": "HowToStep",
"name": "Add Gesture Handlers",
"text": "Implement drag-to-dismiss, swipe navigation, and long-press interactions using Framer Motion's gesture detection."
},
{
"@type": "HowToStep",
"name": "Build Animation Library",
"text": "Create reusable animation variants (fade, scale, slide) for consistent motion design across widget codebase."
}
],
"totalTime": "PT3H",
"tool": [
"Framer Motion",
"CSS Transitions",
"requestAnimationFrame",
"prefers-reduced-motion"
]
}