diff --git a/__tests__/snapshots/__snapshots__/ui-components-extended.test.tsx.snap b/__tests__/snapshots/__snapshots__/ui-components-extended.test.tsx.snap index 8e7c8df..06c15dc 100644 --- a/__tests__/snapshots/__snapshots__/ui-components-extended.test.tsx.snap +++ b/__tests__/snapshots/__snapshots__/ui-components-extended.test.tsx.snap @@ -200,9 +200,10 @@ exports[`Skeleton snapshots renders default skeleton 1`] = ` `; @@ -211,11 +212,13 @@ exports[`Skeleton snapshots renders with custom dimensions 1`] = ` `; diff --git a/components/ui/Skeleton.tsx b/components/ui/Skeleton.tsx index 7dc21ba..d2f3828 100644 --- a/components/ui/Skeleton.tsx +++ b/components/ui/Skeleton.tsx @@ -1,5 +1,13 @@ import React from "react"; -import { View, type ViewStyle } from "react-native"; +import { type ViewStyle } from "react-native"; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withTiming, +} from "react-native-reanimated"; import { cn } from "~/lib/utils"; export interface SkeletonProps { @@ -8,36 +16,37 @@ export interface SkeletonProps { height?: number; } +/** + * A pulsing placeholder block. The pulse runs entirely on the reanimated UI + * thread (a looped opacity 1 → 0.4 → 1), so a list of a dozen skeletons costs + * zero React re-renders while it breathes — previously each frame called + * setState on every skeleton. + */ export function Skeleton({ className, width, height }: SkeletonProps) { - const [opacity, setOpacity] = React.useState(1); + const opacity = useSharedValue(1); React.useEffect(() => { - let frame: number; - let start: number | null = null; + opacity.value = withRepeat( + withSequence( + withTiming(0.4, { duration: 750, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 750, easing: Easing.inOut(Easing.ease) }), + ), + -1, + false, + ); + }, [opacity]); - const animate = (timestamp: number) => { - if (start === null) start = timestamp; - const elapsed = timestamp - start; - // Pulse between 0.4 and 1.0 over 1.5s - const t = (Math.sin((elapsed / 1500) * Math.PI * 2) + 1) / 2; - setOpacity(0.4 + t * 0.6); - frame = requestAnimationFrame(animate); - }; + const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - frame = requestAnimationFrame(animate); - return () => cancelAnimationFrame(frame); - }, []); - - const style: ViewStyle = { - opacity, + const dims: ViewStyle = { ...(width != null ? { width } : {}), ...(height != null ? { height } : {}), }; return ( - ); }