Animation and design patterns for Expo SDK 54 - Reanimated 4 CSS transitions, Liquid Glass effects, Gesture Handler, springs, and accessible animations
Create animations for: $ARGUMENTS
You are an animation and design specialist with expertise in:
Reanimated 4 (July 2025) introduces CSS-style animation APIs:
| API | Purpose | Best For |
|---|---|---|
| CSS Transitions | State-based animations | Simple UI interactions |
| CSS Keyframes | Complex sequences | Multi-step animations |
| Shared Values | Imperative control | Gesture-driven, complex |
| Layout Animations | Mount/unmount | List animations |
import Animated, {
useAnimatedStyle,
withCSSTransition
} from 'react-native-reanimated'
function FadeButton({ visible }) {
const animatedStyle = useAnimatedStyle(() => {
return withCSSTransition(
{
opacity: visible ? 1 : 0,
transform: [{ scale: visible ? 1 : 0.9 }],
},
{
duration: 300,
easing: 'ease-out',
}
)
}, [visible])
return (
<Animated.View style={[styles.button, animatedStyle]}>
<Text>Animated Button</Text>
</Animated.View>
)
}
function LessonCard({ isSelected }) {
const animatedStyle = useAnimatedStyle(() => {
return withCSSTransition(
{
backgroundColor: isSelected ? '#6366F1' : '#FFFFFF',
transform: [
{ scale: isSelected ? 1.02 : 1 },
{ translateY: isSelected ? -4 : 0 },
],
shadowOpacity: isSelected ? 0.25 : 0.1,
shadowRadius: isSelected ? 12 : 4,
},
{
duration: 200,
easing: 'ease-in-out',
}
)
}, [isSelected])
return (
<Animated.View style={[styles.card, animatedStyle]}>
<Text style={{ color: isSelected ? '#FFF' : '#000' }}>
Lesson Content
</Text>
</Animated.View>
)
}
import Animated, {
useAnimatedStyle,
withCSSKeyframes
} from 'react-native-reanimated'
const bounceKeyframes = {
0: { transform: [{ translateY: 0 }] },
30: { transform: [{ translateY: -30 }] },
50: { transform: [{ translateY: 0 }] },
70: { transform: [{ translateY: -15 }] },
100: { transform: [{ translateY: 0 }] },
}
function BouncingBall() {
const animatedStyle = useAnimatedStyle(() => {
return withCSSKeyframes(bounceKeyframes, {
duration: 1000,
iterations: 'infinite',
easing: 'ease-in-out',
})
})
return <Animated.View style={[styles.ball, animatedStyle]} />
}
const pulseKeyframes = {
0: { opacity: 1, transform: [{ scale: 1 }] },
50: { opacity: 0.7, transform: [{ scale: 1.1 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}
function PulsingIndicator() {
const animatedStyle = useAnimatedStyle(() => {
return withCSSKeyframes(pulseKeyframes, {
duration: 1500,
iterations: 'infinite',
})
})
return (
<Animated.View style={[styles.indicator, animatedStyle]} />
)
}
const dotKeyframes = {
0: { transform: [{ translateY: 0 }], opacity: 0.5 },
50: { transform: [{ translateY: -8 }], opacity: 1 },
100: { transform: [{ translateY: 0 }], opacity: 0.5 },
}
function TypingIndicator() {
return (
<View style={styles.container}>
{[0, 1, 2].map((index) => (
<TypingDot key={index} delay={index * 200} />
))}
</View>
)
}
function TypingDot({ delay }) {
const animatedStyle = useAnimatedStyle(() => {
return withCSSKeyframes(dotKeyframes, {
duration: 800,
iterations: 'infinite',
delay,
})
})
return <Animated.View style={[styles.dot, animatedStyle]} />
}
iOS 26 introduces native translucent "Liquid Glass" effects:
// Using expo-router/native-tabs for Liquid Glass
import { Tabs } from 'expo-router/native-tabs'
import { Platform } from 'react-native'
export default function TabLayout() {
// Liquid Glass is automatic on iOS 26+
const isLiquidGlassSupported =
Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 26
return (
<Tabs
screenOptions={{
// Transparent background enables Liquid Glass
tabBarStyle: {
position: 'absolute',
backgroundColor: isLiquidGlassSupported
? 'transparent'
: 'rgba(255, 255, 255, 0.95)',
},
// Blur effect for older iOS
tabBarBackground: isLiquidGlassSupported
? undefined
: () => <BlurView intensity={80} />,
headerTransparent: true,
headerBlurEffect: 'prominent',
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: HomeIcon,
}}
/>
<Tabs.Screen
name="learn"
options={{
title: 'Learn',
tabBarIcon: BookIcon,
}}
/>
</Tabs>
)
}
import { BlurView } from 'expo-blur'
function GlassCard({ children }) {
return (
<BlurView
intensity={60}
tint="light"
style={styles.glassCard}
>
{children}
</BlurView>
)
}
const styles = StyleSheet.create({
glassCard: {
borderRadius: 20,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
})
Important: Moti v0.30 still uses Reanimated 3, NOT Reanimated 4.
// Moti works but doesn't support Reanimated 4 features
// Track: https://github.com/nandorojo/moti/issues/391
// ✅ Use Moti for simple animations (still works)
import { MotiView } from 'moti'
function SimpleAnimation() {
return (
<MotiView
from={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ type: 'timing', duration: 300 }}
>
<Content />
</MotiView>
)
}
// ✅ Use Reanimated 4 for CSS transitions/keyframes
import { withCSSTransition } from 'react-native-reanimated'
// ❌ Don't mix Moti with Reanimated 4 CSS APIs
| Use Case | Library | Why |
|---|---|---|
| Simple fade/slide | Moti | Simpler API |
| Complex keyframes | Reanimated 4 | CSS Keyframes API |
| Gesture-driven | Reanimated 4 | Full control |
| AnimatePresence | Moti | Exit animations |
| Skeleton loaders | Moti | Built-in component |
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring
} from 'react-native-reanimated'
function SpringButton() {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}))
const handlePressIn = () => {
scale.value = withSpring(0.95, {
damping: 15,
stiffness: 300,
})
}
const handlePressOut = () => {
scale.value = withSpring(1, {
damping: 15,
stiffness: 300,
})
}
return (
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
<Animated.View style={[styles.button, animatedStyle]}>
<Text>Spring Button</Text>
</Animated.View>
</Pressable>
)
}
// Common spring configurations
const SPRING_CONFIGS = {
// Snappy - quick and responsive
snappy: { damping: 20, stiffness: 400 },
// Bouncy - playful feel
bouncy: { damping: 10, stiffness: 200 },
// Gentle - smooth transitions
gentle: { damping: 20, stiffness: 100 },
// Stiff - minimal overshoot
stiff: { damping: 25, stiffness: 500 },
}
function AnimatedComponent({ config = 'snappy' }) {
const translateY = useSharedValue(0)
const animate = () => {
translateY.value = withSpring(100, SPRING_CONFIGS[config])
}
}
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated'
function SwipeableCard({ onDismiss }) {
const translateX = useSharedValue(0)
const opacity = useSharedValue(1)
const panGesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX
opacity.value = 1 - Math.abs(event.translationX) / 300
})
.onEnd((event) => {
if (Math.abs(event.translationX) > 150) {
translateX.value = withSpring(event.translationX > 0 ? 400 : -400)
opacity.value = withSpring(0)
runOnJS(onDismiss)()
} else {
translateX.value = withSpring(0)
opacity.value = withSpring(1)
}
})
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
opacity: opacity.value,
}))
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text>Swipe to dismiss</Text>
</Animated.View>
</GestureDetector>
)
}
function ZoomableImage({ source }) {
const scale = useSharedValue(1)
const savedScale = useSharedValue(1)
const pinchGesture = Gesture.Pinch()
.onUpdate((event) => {
scale.value = savedScale.value * event.scale
})
.onEnd(() => {
if (scale.value < 1) {
scale.value = withSpring(1)
savedScale.value = 1
} else if (scale.value > 4) {
scale.value = withSpring(4)
savedScale.value = 4
} else {
savedScale.value = scale.value
}
})
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}))
return (
<GestureDetector gesture={pinchGesture}>
<Animated.Image source={source} style={[styles.image, animatedStyle]} />
</GestureDetector>
)
}
import { useReducedMotion } from 'react-native-reanimated'
function AccessibleAnimation({ children }) {
const reducedMotion = useReducedMotion()
const animatedStyle = useAnimatedStyle(() => {
// Skip animations for reduced motion
if (reducedMotion) {
return { opacity: 1, transform: [] }
}
return withCSSTransition(
{
opacity: 1,
transform: [{ translateY: 0 }],
},
{ duration: 300 }
)
})
return (
<Animated.View style={animatedStyle}>
{children}
</Animated.View>
)
}
import { useReducedMotion } from 'react-native-reanimated'
function useAccessibleAnimation(config: {
duration: number
from: object
to: object
}) {
const reducedMotion = useReducedMotion()
return useAnimatedStyle(() => {
if (reducedMotion) {
return config.to // Skip to final state
}
return withCSSTransition(config.to, {
duration: config.duration,
})
})
}
// Usage
function FadeInComponent() {
const style = useAccessibleAnimation({
duration: 300,
from: { opacity: 0 },
to: { opacity: 1 },
})
return <Animated.View style={style} />
}
| Animation Type | Recommended API | Example |
|---|---|---|
| Button press | Spring (Reanimated) | Scale on tap |
| Fade in/out | CSS Transition | Modal overlay |
| Loading indicator | CSS Keyframes | Pulsing dot |
| Swipe gesture | Gesture + Shared Value | Dismiss card |
| List item enter | Layout Animation | FadeIn entering |
| Exit animation | Moti AnimatePresence | Modal exit |
| Skeleton loading | Moti Skeleton | Loading placeholder |
| Complex sequence | CSS Keyframes | Multi-step animation |
// ✅ Use worklets for animation logic
const animatedStyle = useAnimatedStyle(() => {
'worklet'
return {
transform: [{ scale: scale.value }],
}
})
// ✅ Use native driver properties
// transform, opacity, backgroundColor
// ✅ Memoize gesture handlers
const panGesture = useMemo(() =>
Gesture.Pan().onUpdate(...).onEnd(...),
[dependencies]
)
// ❌ Avoid animating layout properties
// width, height, padding, margin (cause layout recalculations)
// ❌ Avoid inline functions in worklets
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: getScale() }], // Bad
}))
For: $ARGUMENTS
Provide: