Veltrix

loading

Shimmer Map

A content-aware skeleton wrapper that preserves the layout and shape of the content it replaces.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedsvg
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated react-native-svg

Anatomy

1 file

anatomy.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-variants

Usage

ShimmerMapExample.tsx
import { 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.tsx
import { 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>  );}

Component files

8 files

shimmer-map/artwork.tsx
import {  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.ts
export const SHIMMER_MAP_CONFIG = {  backgroundColor: "#e6e5df",  duration: 1_450,  highlightColor: "#ffffff",} as const;
shimmer-map/group.tsx
import {  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.ts
import {  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.ts
import 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.tsx
import { 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.ts
import { 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.ts
import {  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 };}

API reference

Generated directly from the exported component props.

ShimmerMap

ShimmerMap.Group