loading
Assembly Loader
A loading wrapper that assembles its children in sequence to preview the next view.
iOSAndroidExpo Go
reanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimatedInstall tailwind variants
pnpm add tailwind-variantsUsage
ProjectScreen.tsximport { View } from "react-native";import { AssemblyLoader } from "@animations/ui/components/loading/assembly-loader/uniwind";export function ProjectScreen() { return ( <AssemblyLoader contentClassName="gap-4"> <View className="h-6 w-1/2 rounded-lg bg-[#e5e1d8]" /> <View className="h-40 w-full rounded-3xl bg-[#e5e1d8]" /> <View className="h-12 w-full rounded-2xl bg-[#e5e1d8]" /> </AssemblyLoader> );}assembly-loader/config.tsexport const ASSEMBLY_LOADER_CONFIG = { defaultDuration: 1_700, defaultProgress: 0.68, revealWindow: 0.18,} as const;export function getAssemblyThreshold(index: number, count: number) { if (count <= 1) { return 0.16; } return 0.08 + (index / (count - 1)) * 0.78;}assembly-loader/types.tsimport type { ReactNode } from "react";export type AssemblyLoaderBaseProps = { /** Accessible description announced while the next view is assembling. */ accessibilityLabel?: string; /** Pieces of the next view, revealed in their child order. */ children: ReactNode; /** Duration of the automatic assembly loop or controlled transition. */ duration?: number; /** Controlled assembly state from 0 (empty) to 1 (ready). */ progress?: number;};assembly-loader/uniwind/index.tsximport { Children, type ReactNode } from "react";import { View } from "react-native";import Animated, { type SharedValue,} from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { getAssemblyThreshold } from "../config";import type { AssemblyLoaderBaseProps } from "../types";import { normalizeAssemblyProgress, useAssemblyBlockStyle, useAssemblyLoader,} from "../use-assembly-loader";import { assemblyLoaderVariants, type AssemblyLoaderSlot,} from "./variants";export type AssemblyLoaderProps = AssemblyLoaderBaseProps & { className?: string; classNames?: SlotsToClasses<AssemblyLoaderSlot>; contentClassName?: string;};function AssemblyChild({ child, className, index, progress, total,}: { child: ReactNode; className: string; index: number; progress: SharedValue<number>; total: number;}) { const animatedStyle = useAssemblyBlockStyle( progress, getAssemblyThreshold(index, total), ); return ( <Animated.View className={className} style={animatedStyle}> {child} </Animated.View> );}/** Wraps custom view pieces and assembles them in their child order. */export function AssemblyLoader({ accessibilityLabel = "Assembling next view", children, className, classNames, contentClassName, duration, progress: progressProp,}: AssemblyLoaderProps) { const progress = useAssemblyLoader({ duration, progress: progressProp, }); const childNodes = Children.toArray(children); const slots = assemblyLoaderVariants(); const accessibilityValue = progressProp === undefined ? undefined : { max: 1, min: 0, now: normalizeAssemblyProgress(progressProp), }; return ( <View accessibilityLabel={accessibilityLabel} accessibilityRole="progressbar" accessibilityState={{ busy: progressProp === undefined || normalizeAssemblyProgress(progressProp) < 1, }} accessibilityValue={accessibilityValue} accessible className={slots.root({ className: [className, classNames?.root] })} > <View className={slots.content({ className: [contentClassName, classNames?.content], })} > {childNodes.map((child, index) => ( <AssemblyChild child={child} className={slots.child({ className: classNames?.child })} index={index} key={index} progress={progress} total={childNodes.length} /> ))} </View> </View> );}assembly-loader/uniwind/variants.tsimport { tv } from "tailwind-variants";export const assemblyLoaderSlots = { child: "relative", content: "w-full gap-3", root: "w-full overflow-hidden rounded-[28px] p-5",} as const;export type AssemblyLoaderSlot = keyof typeof assemblyLoaderSlots;export const assemblyLoaderVariants = tv({ slots: assemblyLoaderSlots,});assembly-loader/use-assembly-loader.tsimport { useEffect } from "react";import { cancelAnimation, Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withTiming, type SharedValue,} from "react-native-reanimated";import { ASSEMBLY_LOADER_CONFIG } from "./config";function clampProgress(progress: number) { return Math.max(0, Math.min(1, progress));}export function normalizeAssemblyProgress(progress?: number) { return clampProgress( progress ?? ASSEMBLY_LOADER_CONFIG.defaultProgress, );}export function useAssemblyLoader({ duration = ASSEMBLY_LOADER_CONFIG.defaultDuration, progress,}: { duration?: number; progress?: number;}) { const reducedMotion = useReducedMotion(); const assemblyProgress = useSharedValue( normalizeAssemblyProgress(progress), ); useEffect(() => { cancelAnimation(assemblyProgress); if (reducedMotion) { if (progress !== undefined) { assemblyProgress.set(normalizeAssemblyProgress(progress)); } return; } if (progress === undefined) { assemblyProgress.set(0); assemblyProgress.set( withRepeat( withTiming(1, { duration, easing: Easing.inOut(Easing.cubic), }), -1, true, ), ); return () => cancelAnimation(assemblyProgress); } assemblyProgress.set( withTiming(normalizeAssemblyProgress(progress), { duration, easing: Easing.out(Easing.cubic), }), ); return () => cancelAnimation(assemblyProgress); }, [ assemblyProgress, duration, progress, reducedMotion, ]); return assemblyProgress;}export function useAssemblyBlockStyle( progress: SharedValue<number>, threshold: number,) { return useAnimatedStyle(() => { const reveal = Math.max( 0, Math.min( 1, (progress.get() - Math.max( 0, threshold - ASSEMBLY_LOADER_CONFIG.revealWindow, )) / ASSEMBLY_LOADER_CONFIG.revealWindow, ), ); return { opacity: 0.28 + reveal * 0.72, transform: [ { scale: 0.965 + reveal * 0.035 }, { translateY: 10 - reveal * 10 }, ], }; });}AssemblyLoader
Wraps custom view pieces and assembles them in their child order.
