loading
Shimmer Map
A content-aware skeleton wrapper that preserves the layout and shape of the content it replaces.
iOSAndroidExpo Go
reanimatedsvg
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-svganatomy.tsx<ShimmerMap> <ShimmerMap.Group /></ShimmerMap>Sizing
The wrapper derives its size from its children and reserves a text-line height
when empty. Use className or style for explicit dimensions and clipping.
Groups
ShimmerMap.Group synchronizes one halo across its nested wrappers.
Install tailwind variants
pnpm add tailwind-variantsUsage
ShimmerMapExample.tsximport { Text } from "react-native";import { ShimmerMap } from "@animations/ui/components/loading/shimmer-map/uniwind";export function ShimmerMapExample({ profile,}: { profile?: { initials: string; name: string };}) { return ( <ShimmerMap.Group> <ShimmerMap className="size-12 items-center justify-center rounded-full" isLoading={!profile} > <Text>{profile?.initials}</Text> </ShimmerMap> <ShimmerMap className="rounded-md" isLoading={!profile}> <Text>{profile?.name}</Text> </ShimmerMap> </ShimmerMap.Group> );}Sizing
SizingExample.tsximport { Text } from "react-native";import { ShimmerMap } from "@animations/ui/components/loading/shimmer-map/uniwind";export function SizingExample() { return ( <ShimmerMap className="h-5 w-40 rounded-md" isLoading> <Text /> </ShimmerMap> );}shimmer-map/artwork.tsximport { StyleSheet, View, type ViewStyle,} from "react-native";import Animated, { type AnimatedStyle } from "react-native-reanimated";import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";export function ShimmerArtwork({ animatedStyle, backgroundColor, haloWidth, highlightColor,}: { animatedStyle: AnimatedStyle<ViewStyle>; backgroundColor: string; haloWidth: number; highlightColor: string;}) { return ( <View style={[styles.container, { backgroundColor }]}> <Animated.View style={[styles.highlight, { width: haloWidth }, animatedStyle]} > <Svg height="100%" width="100%"> <Defs> <LinearGradient id="shimmer-gradient"> <Stop offset="0" stopColor={backgroundColor} /> <Stop offset="0.5" stopColor={highlightColor} /> <Stop offset="1" stopColor={backgroundColor} /> </LinearGradient> </Defs> <Rect fill="url(#shimmer-gradient)" height="100%" width="100%" /> </Svg> </Animated.View> </View> );}const styles = StyleSheet.create({ container: { flex: 1, overflow: "hidden", }, highlight: { bottom: 0, left: 0, position: "absolute", top: 0, },});shimmer-map/config.tsexport const SHIMMER_MAP_CONFIG = { backgroundColor: "#e6e5df", duration: 1_450, highlightColor: "#ffffff",} as const;shimmer-map/group.tsximport { createContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject,} from "react";import { StyleSheet, View, type LayoutChangeEvent,} from "react-native";import { cancelAnimation, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue,} from "react-native-reanimated";import { SHIMMER_MAP_CONFIG } from "./config";type ShimmerMapProgress = { containerRef: RefObject<View | null>; progress: SharedValue<number>; width: number;};export const ShimmerMapProgressContext = createContext< ShimmerMapProgress | undefined>(undefined);export function ShimmerMapGroup({ children, duration = SHIMMER_MAP_CONFIG.duration, paused = false,}: { children: ReactNode; duration?: number; paused?: boolean;}) { const [width, setWidth] = useState(0); const containerRef = useRef<View>(null); const reducedMotion = useReducedMotion(); const progress = useSharedValue(0); const context = useMemo( () => ({ containerRef, progress, width }), [progress, width], ); useEffect(() => { progress.set( paused || reducedMotion ? 0 : withRepeat(withTiming(1, { duration }), -1, false), ); return () => cancelAnimation(progress); }, [duration, paused, progress, reducedMotion]); const onLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = event.nativeEvent.layout.width; setWidth((currentWidth) => currentWidth === nextWidth ? currentWidth : nextWidth, ); }, []); return ( <ShimmerMapProgressContext value={context}> <View collapsable={false} onLayout={onLayout} ref={containerRef} style={styles.container} > {children} </View> </ShimmerMapProgressContext> );}const styles = StyleSheet.create({ container: { alignSelf: "stretch", },});shimmer-map/has-renderable-content.tsimport { isValidElement, type ReactNode,} from "react";export function hasRenderableContent(node: ReactNode): boolean { if (typeof node === "number") { return true; } if (typeof node === "string") { return node.length > 0; } if (Array.isArray(node)) { return node.some(hasRenderableContent); } if (isValidElement<{ children?: ReactNode }>(node)) { return hasRenderableContent(node.props.children); } return false;}shimmer-map/types.tsimport type { ReactNode } from "react";export type ShimmerMapBaseProps = { accessibilityLabel?: string; backgroundColor?: string; children: ReactNode; duration?: number; highlightColor?: string; isLoading?: boolean; paused?: boolean;};export type ShimmerMapGroupProps = { children: ReactNode; duration?: number; paused?: boolean;};shimmer-map/uniwind/index.tsximport { View } from "react-native";import type { SlotsToClasses } from "#shared/types/slots";import { ShimmerArtwork } from "../artwork";import { SHIMMER_MAP_CONFIG } from "../config";import { ShimmerMapGroup as ShimmerMapGroupPrimitive } from "../group";import { hasRenderableContent } from "../has-renderable-content";import type { ShimmerMapBaseProps, ShimmerMapGroupProps,} from "../types";import { useShimmerMap } from "../use-shimmer-map";import { shimmerMapVariants, type ShimmerMapSlot,} from "./variants";export type ShimmerMapProps = ShimmerMapBaseProps & { className?: string; classNames?: SlotsToClasses<ShimmerMapSlot>;};function ShimmerMapGroup(props: ShimmerMapGroupProps) { return <ShimmerMapGroupPrimitive {...props} />;}function ShimmerMapRoot({ accessibilityLabel = "Loading content", backgroundColor = SHIMMER_MAP_CONFIG.backgroundColor, children, className, classNames, duration, highlightColor = SHIMMER_MAP_CONFIG.highlightColor, isLoading = true, paused,}: ShimmerMapProps) { const { animatedStyle, haloWidth, onLayout, targetRef, } = useShimmerMap({ duration, paused, }); const usesAutomaticFallback = isLoading && !hasRenderableContent(children); const { container, shimmer } = shimmerMapVariants({ empty: usesAutomaticFallback, }); return ( <View accessibilityLabel={isLoading ? accessibilityLabel : undefined} accessibilityRole={isLoading ? "progressbar" : undefined} className={container({ className: [ isLoading && className, isLoading && classNames?.container, ], })} onLayout={onLayout} ref={targetRef} > {children} {isLoading && ( <View className={shimmer({ className: classNames?.shimmer })} pointerEvents="none" > <ShimmerArtwork animatedStyle={animatedStyle} backgroundColor={backgroundColor} haloWidth={haloWidth} highlightColor={highlightColor} /> </View> )} </View> );}export const ShimmerMap = Object.assign(ShimmerMapRoot, { Group: ShimmerMapGroup,});shimmer-map/uniwind/variants.tsimport { tv } from "tailwind-variants";export const shimmerMapSlots = { container: "overflow-hidden", shimmer: "absolute inset-0",} as const;export type ShimmerMapSlot = keyof typeof shimmerMapSlots;export const shimmerMapVariants = tv({ slots: shimmerMapSlots, variants: { empty: { true: { container: "min-h-4", }, }, },});shimmer-map/use-shimmer-map.tsimport { useCallback, useContext, useEffect, useRef, useState,} from "react";import { View, type LayoutChangeEvent,} from "react-native";import { cancelAnimation, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming,} from "react-native-reanimated";import { SHIMMER_MAP_CONFIG } from "./config";import { ShimmerMapProgressContext } from "./group";const MIN_HALO_WIDTH = 72;const MAX_HALO_WIDTH = 160;const HALO_WIDTH_RATIO = 0.32;export function useShimmerMap({ duration = SHIMMER_MAP_CONFIG.duration, paused = false,}: { duration?: number; paused?: boolean;}) { const [layout, setLayout] = useState({ offsetX: 0, width: 0 }); const targetRef = useRef<View>(null); const group = useContext(ShimmerMapProgressContext); const groupContainerRef = group?.containerRef; const isGrouped = Boolean(group); const reducedMotion = useReducedMotion(); const localProgress = useSharedValue(0); const progress = group?.progress ?? localProgress; const groupWidth = group?.width ?? layout.width; const haloWidth = group ? Math.min( Math.max(groupWidth * HALO_WIDTH_RATIO, MIN_HALO_WIDTH), MAX_HALO_WIDTH, ) : layout.width; const measureInGroup = useCallback(() => { if (!groupContainerRef?.current || !targetRef.current) { return; } targetRef.current.measureLayout( groupContainerRef.current, (offsetX, _offsetY, width) => { setLayout((current) => current.offsetX === offsetX && current.width === width ? current : { offsetX, width }, ); }, ); }, [groupContainerRef]); useEffect(() => { if (isGrouped) { return; } localProgress.set( layout.width > 0 && !paused && !reducedMotion ? withRepeat(withTiming(1, { duration }), -1, false) : 0, ); return () => cancelAnimation(localProgress); }, [ duration, isGrouped, layout.width, localProgress, paused, reducedMotion, ]); const onLayout = useCallback( (event: LayoutChangeEvent) => { const { width } = event.nativeEvent.layout; if (isGrouped) { measureInGroup(); return; } setLayout((current) => current.width === width ? current : { offsetX: current.offsetX, width }, ); }, [isGrouped, measureInGroup], ); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: -haloWidth + progress.get() * (groupWidth + haloWidth) - layout.offsetX, }, ], })); return { animatedStyle, haloWidth, onLayout, targetRef };}