Expo Playground UI Patterns Reference
Complete implementation guide for 20+ UI patterns extracted from expo-playground. Stack: React Native 0.83 + Expo 55 + NativeWind 4 + Reanimated 4 + Skia 2.
FOUNDATION
Dependencies
json
{
"expo": "^55.0.0-preview.7",
"react": "19.2.0",
"react-native": "0.83.1",
"nativewind": "^4.2.1",
"tailwindcss": "^3.3.2",
"react-native-reanimated": "~4.2.1",
"react-native-gesture-handler": "~2.30.0",
"@shopify/react-native-skia": "^2.4.18",
"expo-blur": "~55.0.2",
"expo-linear-gradient": "~55.0.2",
"expo-mesh-gradient": "~55.0.2",
"expo-video": "~55.0.2",
"expo-symbols": "~55.0.2",
"lottie-react-native": "~7.3.1",
"lucide-react-native": "^0.562.0",
"react-native-svg": "15.15.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.0.2"
}
Babel Config
js
module.exports = function (api) {
api.cache(true);
return {
presets: [['nativewind/babel', { jsxImportSource: 'nativewind' }], 'babel-preset-expo'],
plugins: ['react-native-worklets/plugin'], // Required for Skia
};
};
Metro Config
js
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });
app.json experiments
json
{ "experiments": { "tsconfigPaths": true, "reactCompiler": true } }
Reanimated Shared Element Transitions
json
// package.json
{ "reanimated": { "staticFeatureFlags": { "ENABLE_SHARED_ELEMENT_TRANSITIONS": true } } }
THEME SYSTEM
Architecture: 3-layer theming
Layer 1: CSS Variables via NativeWind `vars()`
ts
// utils/color-theme.ts
import { vars } from 'nativewind';
export const themes = {
light: vars({
'--color-primary': '#000000',
'--color-invert': '#ffffff',
'--color-secondary': '#ffffff',
'--color-background': '#F4F4F5',
'--color-darker': '#F4F4F5',
'--color-text': '#000000',
'--color-highlight': '#FF2056',
'--color-border': 'rgba(0,0,0,0.15)',
}),
dark: vars({
'--color-primary': '#ffffff',
'--color-invert': '#000000',
'--color-secondary': '#262626',
'--color-background': '#0A0A0A',
'--color-darker': '#000000',
'--color-text': '#ffffff',
'--color-highlight': '#FF2056',
'--color-border': 'rgba(255,255,255,0.15)',
}),
};
Layer 2: ThemeContext
tsx
// app/contexts/ThemeContext.tsx
import { colorScheme } from 'nativewind';
const ThemeContext = createContext({ theme: 'dark', toggleTheme: () => {} });
export function ThemeProvider({ children }) {
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>('dark');
const toggleTheme = () => {
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
setCurrentTheme(newTheme);
colorScheme.set(newTheme); // Sync NativeWind dark mode
};
return (
<ThemeContext.Provider value={{ theme: currentTheme, toggleTheme }}>
<StatusBar style={currentTheme === 'dark' ? 'light' : 'dark'} />
<View style={themes[currentTheme]}>{children}</View>
</ThemeContext.Provider>
);
}
Layer 3: Imperative Colors Hook
tsx
// app/contexts/ThemeColors.tsx
export default function useThemeColors() {
const { theme } = useTheme();
const isDark = theme === 'dark';
return {
icon: isDark ? '#fff' : '#000',
bg: isDark ? '#0A0A0A' : '#F4F4F5',
invert: isDark ? '#000' : '#fff',
secondary: isDark ? '#262626' : '#fff',
text: isDark ? '#fff' : '#000',
highlight: '#FF2056',
border: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)',
isDark,
// ... more colors
};
}
Tailwind Config
js
// tailwind.config.js
module.exports = {
presets: [require('nativewind/preset')],
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
invert: 'var(--color-invert)',
secondary: 'var(--color-secondary)',
background: 'var(--color-background)',
text: 'var(--color-text)',
highlight: 'var(--color-highlight)',
border: 'var(--color-border)',
darker: 'var(--color-darker)',
},
},
},
};
Usage: bg-background, text-text, bg-secondary, border-border, text-highlight
UTILITY: cn() - Class Merging
ts
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
UTILITY: useShadow - Cross-Platform Shadows
ts
// utils/useShadow.ts
export const shadowPresets = {
small: { elevation: 3, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 2.5, shadowOffset: { width: 0, height: 1 } },
medium: { elevation: 8, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 5, shadowOffset: { width: 0, height: 3 } },
large: { elevation: 15, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 10.84, shadowOffset: { width: 0, height: 10 } },
card: { elevation: 4, shadowColor: '#000', shadowOpacity: 0.2, shadowRadius: 3.84, shadowOffset: { width: 0, height: 2 } },
};
// Usage: <View style={shadowPresets.large} className="rounded-2xl bg-secondary">
ANIMATION RECIPES
Spring Configs Reference
| Config | Feel | Use For | |
|---|
{ damping: 100, stiffness: 600 } | Fast, snappy | Tabs, card flips, grid reorders | |
{ damping: 100, stiffness: 900 } | Very stiff | Chip indicators, card scale | |
{ damping: 90, stiffness: 700 } | Quick, slight bounce | Dropdowns, info panels | |
{ damping: 80, stiffness: 400 } | Smooth | Image zoom, background transitions | |
{ damping: 15, stiffness: 150 } | Soft, elastic | Opacity fade, subtle reveals | |
{ bounciness: 10, speed: 12 } | Playful bounce | Switch thumb | |
{ tension: 100, friction: 8 } | RN Animated spring | Sliding indicators | |
Easing Reference
| Easing | Usage | |
|---|
Easing.out(Easing.cubic) | Standard arrivals/exits | |
Easing.bezier(0.25, 0.1, 0.25, 1) | Apple-style smooth motion | |
Easing.bezier(0.1, 1, 0.94, 1) | Snappy expand (bottom bar) | |
Easing.out(Easing.back(0.5)) | Subtle overshoot entrance | |
Easing.out(Easing.back(1)) | Visible overshoot | |
Easing.in(Easing.cubic) | Exit animations | |
Easing.inOut(Easing.cubic) | Symmetric transitions | |
Counting Animation (number interpolation)
tsx
// Works with RN Animated. For smooth number transitions.
const countAnim = useRef(new Animated.Value(0)).current;
const [displayValue, setDisplayValue] = useState(startValue);
useEffect(() => {
const start = displayValue;
const end = targetValue;
countAnim.setValue(0);
const listener = countAnim.addListener(({ value }) => {
setDisplayValue(Math.round(start + (end - start) * value));
});
Animated.timing(countAnim, { toValue: 1, duration: 500, useNativeDriver: false }).start();
return () => countAnim.removeListener(listener);
}, [targetValue]);
Math-Based Stagger (no withDelay)
tsx
// Each item offsets its progress from a single shared value
const getStaggeredStyle = (index: number) => {
return useAnimatedStyle(() => {
const delay = index * 0.08;
const adjustedProgress = Math.max(0, Math.min(1, (sharedValue.value - delay) / (1 - delay)));
const opacity = interpolate(adjustedProgress, [0, 0.5, 1], [0, 0, 1]);
const translateY = interpolate(adjustedProgress, [0, 1], [10, 0]);
return { opacity, transform: [{ translateY }] };
});
};
Multi-Stop Interpolation for Staggered Reveals
tsx
// Content stays hidden for first 30%, tabs fade out early, content fades in late
const tabOpacity = interpolate(value, [0, 0.3, 1], [1, 0, 0]);
const contentOpacity = interpolate(value, [0, 0.3, 0.6, 1], [0, 0, 0, 1]);
const height = interpolate(value, [0, 0.3, 1], [collapsed, collapsed, expanded]);
Data-Synced Fade Transition
tsx
// Fade out -> change data at midpoint -> fade in
const fadeAnim = useRef(new Animated.Value(1)).current;
const animateTransition = (callback: () => void) => {
Animated.sequence([
Animated.timing(fadeAnim, { toValue: 0, duration: 200, useNativeDriver: true }),
Animated.timing(fadeAnim, { toValue: 1, duration: 200, useNativeDriver: true }),
]).start();
setTimeout(callback, 200); // Execute at midpoint
};
PATTERN: 3D Card Flip
Reanimated shared value + perspective transform + opacity interpolation.
tsx
const rotation = useSharedValue(0);
const flipCard = () => {
rotation.value = withSpring(rotation.value === 0 ? 180 : 0, { damping: 100, stiffness: 600 });
};
const frontStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1000 }, { rotateY: `${rotation.value}deg` }],
opacity: interpolate(rotation.value, [0, 90, 180], [1, 0, 0]),
}));
const backStyle = useAnimatedStyle(() => ({
transform: [{ perspective: 1000 }, { rotateY: `${rotation.value - 180}deg` }],
opacity: interpolate(rotation.value, [0, 90, 180], [0, 0, 1]),
}));
// Layout: two Animated.Views with position: absolute, full size
<View style={{ height: 450 }}>
<Animated.View style={[StyleSheet.absoluteFill, frontStyle]}>{/* Front content */}</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, backStyle]}>{/* Back content */}</Animated.View>
</View>
PATTERN: Glassmorphism / Blur
tsx
import { BlurView } from 'expo-blur';
// Light glass (over images)
<BlurView intensity={40} tint="light" style={{ position: 'absolute', ...StyleSheet.absoluteFillObject }}>
<View style={{ backgroundColor: 'rgba(255,255,255,0)' }} />
</BlurView>
// Dark glass (bottom bars, tooltips)
<BlurView intensity={10} tint="dark" className="rounded-full overflow-hidden bg-black/50">
{/* Content */}
</BlurView>
// Glass button
<BlurView intensity={80} tint="light" style={{ width: 40, height: 40, borderRadius: 20, overflow: 'hidden' }}>
<Feather name="arrow-left" size={20} color="white" />
</BlurView>
PATTERN: Expanding Hotspots (Product Detail)
Interactive hotspots over an image that expand from circle to card with glassmorphism.
tsx
// Three coordinated shared values for background image
const imageScale = useSharedValue(1);
const imageTranslateX = useSharedValue(0);
const imageTranslateY = useSharedValue(0);
// Each hotspot has: expanded (0|1) + otherExpanded (0|1)
const containerStyle = useAnimatedStyle(() => ({
width: interpolate(expanded.value, [0, 1], [34, 220]),
height: interpolate(expanded.value, [0, 1], [34, 130]),
borderRadius: interpolate(expanded.value, [0, 1], [24, 20]),
opacity: interpolate(otherExpanded.value, [0, 1], [1, 0]), // Hide when sibling expands
transform: [{ scale: interpolate(otherExpanded.value, [0, 1], [1, 0.6]) }],
}));
// Plus icon rotates 45deg to become X
const iconStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${interpolate(expanded.value, [0, 1], [0, 45])}deg` }],
}));
// Content reveal with delay (appears after 20% expansion)
const contentStyle = useAnimatedStyle(() => {
const progress = Math.max(0, Math.min(1, (expanded.value - 0.2) / 0.8));
return {
opacity: interpolate(progress, [0, 0.5, 1], [0, 0, 1]),
transform: [{ translateY: interpolate(progress, [0, 1], [40, 0]) }],
};
});
// On tap: image zooms to hotspot area
imageScale.value = withSpring(product.scale, { damping: 80, stiffness: 400 });
imageTranslateX.value = withSpring(product.imageOffset.x, { damping: 80, stiffness: 400 });
imageTranslateY.value = withSpring(product.imageOffset.y, { damping: 80, stiffness: 400 });
PATTERN: Hero Transition (Notes/Card Expand)
Measure card position, open modal, animate from card position to centered modal.
tsx
// Step 1: Measure card position
const cardRef = useRef<View>(null);
cardRef.current?.measureInWindow((x, y, width, height) => {
setCardLayout({ x, y, width, height });
setSelectedNote(note);
});