Skip to content
Native Animation
Resource State Transition animation preview

This preview uses fictional demo content. See the props below for the customization options available in this component.

npx nativeanimation add resource-state-transition
Skeleton

Resource State Transition

A data region with animated loading, empty, error, and refresh states, with configurable content, labels, palette, and fonts.

$9 for this animation

Add to your project

Copy the component and its assets into your app with the Native Animation CLI, or paste the agent prompt into Claude Code, Codex or Cursor once the MCP server is connected.

About this animation

A data region with animated loading, empty, error, and refresh states, with configurable content, labels, palette, and fonts.

Why this one

Loading-to-content transitions solve the first response. This region also keeps the useful content mounted through refresh and refresh failure. Render props let the host retain inputs, selections, local component state, and its own data layout without adopting a fetching framework. Errors never infer emptiness or erase explicitly supplied previous data.

How it works

  1. The discriminated state selects a body cause. success, refreshing, and error with previousData share one stable content subtree.
  2. A body substitution fades out for 80 ms, waits 20 ms, and fades in over 140 ms with a 6-point lift. Default skeleton rows retire with a 35 ms stagger; this exit takes 150 ms. Only one body text tree exists.
  3. A separate status transition updates the reserved footer without fading or disabling usable content. A failed refresh leaves the content interactive and places recovery beside the notice.
  4. onLayout measures the host slot and the largest presented region. A 220 ms minimum-height adjustment keeps room for observed states; text uses intrinsic layout, never a scaled transform.
  5. A changed requestKey remounts the presentation boundary and clears transitions and pending retry focus. It also intentionally resets content belonging to the old query.
  6. Reduced motion uses 90 ms exit, 20 ms pause, and 90 ms entry, removes displacement and row travel, and keeps all actions. Loading placeholders are static; there is no continuous shimmer.

Install

Copy this folder, excluding demo, into your project. Install the versions compatible with your Expo SDK:

npx expo install react-native-reanimated react-native-worklets @shopify/react-native-skia expo-linear-gradient

Target stack: Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Skia 2.14.0, expo-linear-gradient ~58.0.3. Skia web requires CanvasKit initialization before rendering this component. The demo additionally uses expo-blur ~58.0.5 and the repository's fonts; neither is required by the component. The demo uses the SDK 58 BlurView API, with an opaque themed fallback on Android. Every blur and clipping wrapper has its own radius.

Usage

import { useRef, useState } from 'react';
import { Text, View } from 'react-native';
import { ResourceStateTransition, type ResourceState, type ResourceStateTransitionRef, type ResourceLabels } from './resource-state-transition';

type Entry = { id: string; label: string };
const labels: ResourceLabels = {
  loading: 'Loading your history',
  emptyTitle: 'No entries yet',
  emptyDescription: 'Your first entry starts here.',
  errorTitle: 'Couldn’t load history',
  retry: 'Try again',
  refresh: 'Refresh',
  refreshing: 'Updating',
  refreshFailed: 'Couldn’t update. Your saved history is still here.',
  emptyAction: 'Add an entry',
  updated: 'You’re up to date',
};

function HistoryRegion({ retry, refresh }: { retry(): void; refresh(): void }) {
  const control = useRef<ResourceStateTransitionRef>(null);
  const [state] = useState<ResourceState<Entry[]>>({ status: 'loading' });
  // Your request adapter updates state; this component performs no fetching.
  return (
    <View style={{ height: 400 }}>
      <ResourceStateTransition
        ref={control}
        requestKey="history:current-account"
        state={state}
        labels={labels}
        accessibilityLabel="Hydration history"
        onRetry={retry}
        onRefresh={refresh}
        renderContent={entries => entries.map(entry => <Text key={entry.id}>{entry.label}</Text>)}
      />
    </View>
  );
}

For a customized instance, add colorScheme="light", minHeight={300}, motionScale={0.5}, your font families, and a complete palette. Supply renderEmpty={() => <YourEmptyState />} or renderError={({ description, retry }) => <YourRecovery description={description} onRetry={retry} />} to own those bodies. The renderer must provide its own action semantics, labels, and focus handling if it replaces a default control.

Props

PropDefaultContract
state: ResourceState<T>RequiredHost-controlled cause and data; see table below.
requestKey: stringRequiredStable query identity; changing it clears presentation and old-query content.
renderContent(data: T): ReactNodeRequiredStable child types and keys preserve content state across refresh.
labels: ResourceLabelsRequiredEvery state/action string; keys below.
accessibilityLabel: stringRequiredStable resource heading and accessible name.
onRetry(): voidRequiredRequests recovery; never called by animation completion.
onRefresh?(): voidNoneRequests refresh when usable data exists.
onEmptyAction?(): voidNoneEnables the empty-state action.
renderLoading?(): ReactNodeThree skeleton rowsFull loading body replacement.
renderEmpty?(): ReactNodeInbox, title, description, optional actionFull empty body replacement.
renderError?({description, retry}): ReactNodeAlert, title, description, retryInitial-error body replacement; generic description fallback supplied.
minHeight?: number240Finite nonnegative minimum, bounded by measured slot. Intrinsic content can require more space.
colorScheme?: 'light' | 'dark' | 'auto'autoComplete built-in Night/Paper appearance.
palette?: ResourcePaletteFrom appearanceAll nine required keys: background, surface, text, muted, border, accent, onAccent, success, error.
fontFamily?: stringSystemBody and controls.
titleFontFamily?: stringSystemEmpty/error heading.
monoFontFamily?: stringNoneIntegration hint; the host's content renderer owns numeric typography.
motionScale?: number1Finite displacement multiplier clamped to 0–1.5. Zero retains fades and semantics.
style?: StyleProp<ViewStyle>NoneFlex root overrides; the host supplies the slot and safe-area insets.
testID?: stringresource-state-transitionRoot; -retry, -refresh, -empty-action identify controls.

Labels

Every key is required. Empty descriptions omit their layout; use meaningful nonempty action labels when enabling callbacks.

KeyPurposeDemo value
loadingInitial-loading message/announcementLoading your history
emptyTitleConfirmed-empty headingNo entries yet
emptyDescriptionOptional empty explanationYour first entry starts here.
errorTitleInitial failure and missing-description fallbackCouldn’t load history
retryRecovery controlTry again
refreshRefresh controlRefresh
refreshingNonblocking refresh statusUpdating
refreshFailedNotice beside retained contentCouldn’t update. Your saved history is still here.
emptyActionOptional empty actionAdd an entry
updatedSuccessful response confirmationYou’re up to date

States and transitions

StateBodyFooter/actions
{status:'loading'}Structure-only placeholdersLoading label; busy
{status:'success', data:T}Host content, even for an empty collectionUpdated + optional Refresh
{status:'empty'}Confirmed emptyOptional Add an entry in body
{status:'error', description:string}Initial failureTry again in body
{status:'refreshing', data:T}Content remains mounted and interactiveUpdating; busy
{status:'error', description:string, previousData:T}Same content subtreeRefresh-failed notice + Try again

All transitions are host-driven. Supported flows include loading→empty, loading→error→success, and success→refreshing→error(previousData)→refreshing→success. Two updates 50 ms apart cancel pending substitutions; the newest identity wins. This region does not enforce minimum loading time, so a fast host response can start directly at success. Refresh progress never disables content controls.

Ref and controlled behavior

requestRetry() requests onRetry only in error. requestRefresh() requests onRefresh only when usable data exists and state is not refreshing. Each invocation calls its callback at most once. reset() restarts presentation only; it never changes remote state or invokes callbacks. Props always own the current cause. There is no uncontrolled mode or fetch/cache layer.

The host must reject obsolete responses before updating props:

const requestId = ++latestRequestId;
const response = await loadHistory(queryKey);
if (requestId !== latestRequestId) return;
setState(response.length ? { status: 'success', data: response } : { status: 'empty' });

Advance that ID when the query changes, and include the query identity in the host's response check. requestKey resets presentation; it cannot determine whether the host passed an obsolete payload. Keep previous data and child keys stable during refresh. A null or empty success payload is passed through to renderContent; provide its accessible fallback there. Nonfinite motion/minimum inputs have safe defaults.

Demo and simulated content

Tidewell is the kit demo identity, with a recolored droplet tile and Instrument Serif wordmark. History is simulated local copy: Today · 48 fl oz and Yesterday · 56 fl oz. No network, account, storage, or health tracking is implemented.

Replay: loading at 0; success at 850 ms; refreshing at 1900; failed refresh at 2600; retry presentation at 3200; success at 3700; loading at 4600. Autoplay repeats at 5000. Row reveal and a brief cyan connecting stroke confirm the first successful response before 3 seconds. Empty and Initial error show additional branches. Every real control cancels autoplay and pending simulated responses. Unmount clears timers and animations. There are no rolling counters.

Compatibility

  • Targets iOS, Android, and web.
  • There is no ScrollView. The flex host owns available space, insets, compact content, virtualization, and overflow handling. The region does not truncate error messages or silently infer empty state.
  • Demo omits its editorial headline and brand in short slots or large type. It reduces context to two complete data rows. The component uses a smaller static skeleton in short slots.
  • At 200% text, the host must provide a larger/vertical layout and an explicit way to open full content when its slot is insufficient. Arbitrary content and localized errors cannot be guaranteed to fit a fixed 300×400 slot. Custom renderers own accessible overflow and recovery focus.
  • Announcements are discrete, not frame-driven. Native announcements use AccessibilityInfo; web uses polite live regions. Retry restores focus only after the host publishes a new failed recovery result and the retry control finishes entering; pressing Retry never immediately refocuses the outgoing control. Success does not move focus. Custom error renderers must manage their own focus target.
  • Blur is confined to the demo's bottom theme strip, with radius and clipping directly on BlurView. The themed surface remains readable when blur is unavailable.

Credits

Icons: Lucide (ISC), copied exactly from the internal kit. No reference artwork is shipped. Static skeleton composition informed the loading pattern only; timings, state orchestration, copy, and Tidewell art direction are original to this implementation.

Accessibility

Loading and error announcements occur only on discrete state changes; web uses polite live regions. Retry is a labeled button. Custom content and error renderers own their accessible labels, overflow, and focus. Reduced motion removes spatial content travel and decorative drawing while preserving status changes and retry actions.