About this animation
A purchase-recovery panel with animated restore states and configurable labels, palette, fonts, and entitlement details.
How it works
Recovery remains in one readable surface. Cancellation is neutral, pending never implies success, and missing purchases lead to account help instead of another purchase. A confirmed entitlement reveals its plan and a pearl border halo; Continue is always an explicit user action. No store branding, reference imagery, store SDK, receipt validation, or payment processing is included.
- The host supplies
state,operation, and a uniqueoperationId. The panel has no uncontrolled business state. - Each content change first fades the outgoing content upward by 6 points over 140 ms, then fades the incoming content in from 6 points below over 200 ms. Text and action rows never crossfade. The panel animates to the incoming content’s measured height during entry. Outgoing content cannot receive input or reader focus.
- Content layers sequence opacity and translation;
onLayoutsupplies the target height.stateProgressdrives check drawing, icon scale, medallion radius, and border light. Loading and press feedback use separate clocks. - Confirmed access draws the check in 180 ms and opens the pearl border over 280 ms. The medallion settles with an explicit mass-1 spring.
- Business callbacks run from input or ref calls only. Animation completion never grants access or calls a host action.
Install
Copy the files listed in piece.json, preserving their relative paths.
npx expo install @shopify/react-native-skia@2.14.0 expo-linear-gradient@~58.0.3 react-native-reanimated@4.7.1 react-native-workletsReact and React Native are peer requirements. Follow the matching Expo SDK setup for Reanimated/worklets. For web, initialize CanvasKit before mounting Skia components; an Expo web host must serve canvaskit.wasm. Only the demo uses expo-blur@~58.0.5 and the app typography. The production component has no theme, catalog, or sibling imports.
Usage
import {
PurchaseRecoveryPanel,
type RecoveryLabels,
} from './purchase-recovery-panel';
const labels: RecoveryLabels = {
idleTitle: 'Already have access?',
idleDescription: 'Restore a previous purchase on this account.',
purchasing: 'Checking purchases',
restoring: 'Checking purchases',
pending: 'Waiting for confirmation',
canceled: 'Purchase canceled',
notFound: 'No purchases found',
error: 'Couldn’t restore purchases',
restored: 'Access restored',
restore: 'Restore purchases',
retry: 'Try again',
continue: 'Continue',
done: 'Done',
support: 'Contact support',
accountHelp: 'Review account',
unavailable: 'Restore using the app where you made your purchase.',
invalidResult: 'Access could not be confirmed',
};
<PurchaseRecoveryPanel
state={result.state}
operationId={result.operationId}
entitlement={result.entitlement}
labels={labels}
accessibilityLabel="Purchase recovery"
onRestore={startRestore}
onRetry={retryOperation}
onContinue={openConfirmedEntitlement}
onDismiss={closePanel}
/>;Theme and motion customization:
<PurchaseRecoveryPanel
{...hostProps}
colorScheme="dark"
motionScale={0.65}
fontFamily={interfaceFont}
titleFontFamily={headingFont}
monoFontFamily={detailFont}
style={{ maxWidth: 480 }}
onAccountHelp={openAccountSettings}
onContactSupport={openSupport}
/>Assign the next unique operation ID before starting an adapter request. Compare the response ID against the host's current attempt before applying it; isCurrentOperation(currentId, resultId) is exported for this check. Closing the panel must invalidate the host's visual response subscription while allowing the store transaction to complete independently. Reopening uses a fresh operation ID.
States and transitions
| Host state | Display and action | Next state controller |
|---|---|---|
idle | Recovery explanation; Restore or channel help | Host starts restore on request. |
purchasing | Host purchase label; indeterminate arc; Done | Host purchase adapter; this panel never starts a purchase. |
restoring | Checking purchases; indeterminate arc; Done | Host restore adapter. |
pending | Waiting explanation, static clock, Done | Host confirmation; no deadline or accelerated success. |
canceled | Neutral X, no red, Done | Host may dismiss. No automatic retry. |
notFound | Account help and support when provided; otherwise Done | Host account/help flow. No “Buy again”. |
error | Host detail and Try again; contract errors use Done | Host supplies new attempt ID. |
restored | Check, confirmed plan, Continue | Only touch calls Continue with entitlement ID. |
Missing entitlement or empty attempt ID overrides the supplied result with the accessible contract error. No Continue is rendered. The UI cannot identify a stale response if the host relabels it as current; the host must preserve response IDs.
Purchase initiates store payment in the host. Restore asks the store for prior purchases. Entitlement synchronization verifies and updates host account access. They are separate operations; this component implements none of them. On web, provide your own adapter or set restoreAvailable={false}, provide labels.unavailable, and connect onAccountHelp to the correct channel. There is no pretend native restore fallback.
Demo and motion
All results, accounts, support messages, and entitlement data are simulated. Autoplay: checking purchases at 0 ms, confirmed fixture at 2000 ms, pending at 4000 ms, cancellation at 6000 ms, idle at 8000 ms; repeat every 10000 ms. Each state has at least 1660 ms of fully visible reading time after its 340 ms transition. The success check and halo appear at 2000 ms. Replay runs one cycle. No purchases and Restore error demonstrate the remaining branches. Real touch stops autoplay and invalidates previous timers. Generation tokens protect delayed fixture responses. Timers and animations are cleaned on unmount.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
state | RecoveryState | required | Authoritative host state. |
operation | purchase | restore | restore | Identifies the host operation, including retry routing. |
operationId | string | required | Stable, unique ID per attempt; empty values show a contract error. |
entitlement | {id, planLabel, accessUntilLabel?} | absent | Confirmed access. ID and plan label must be nonempty. |
errorDescription | string | absent | Host error explanation; blank values disappear. |
restoreAvailable | boolean | true | False presents the host's channel explanation and account help. |
onRestore | () => void | required | Starts recovery once per attempt. |
onRetry | (operationId: string) => void | required | Requests retry once per attempt. Host assigns the next ID. |
onContinue | (entitlementId: string) => void | required | Explicit Continue only; never automatic. |
onDismiss | () => void | required | Closes UI once per attempt; does not cancel store work. |
onAccountHelp | () => void | absent | Account/channel action, especially for no purchases. |
onContactSupport | () => void | absent | Adds the lower support action. |
labels | RecoveryLabels | required | All content strings; full table below. |
colorScheme | light | dark | auto | auto | Full Paper/Night palette. |
palette | RecoveryPalette | theme palette | Complete override: background, surface, text, muted, border, accent, onAccent, success, error. |
fontFamily | string | system | Body and controls. |
titleFontFamily | string | system | Panel heading. |
monoFontFamily | string | system | Host-formatted entitlement detail. |
style | StyleProp<ViewStyle> | absent | Flex root style; host owns safe areas. |
testID | string | purchase-recovery-panel | Root and stable control suffix prefix. |
accessibilityLabel | string | required | Region name. |
motionScale | number | 1 | Finite amplitude clamped to 0–1.5; zero retains semantic fades. |
Label keys and demo values
| Key | Demo value / meaning |
|---|---|
idleTitle | Already have access? |
idleDescription | Restore a previous purchase on this account. |
purchasing | Checking purchases |
restoring | Checking purchases |
pending | Waiting for confirmation |
canceled | Purchase canceled |
notFound | No purchases found |
error | Couldn’t restore purchases |
restored | Access restored |
restore | Restore purchases |
retry | Try again |
continue | Continue |
done | Done |
support | Contact support |
accountHelp | Review account |
unavailable | Restore using the app where you made your purchase. |
invalidResult | Access could not be confirmed |
descriptions | Optional map keyed by state, omitted in flow when blank. Demo pending: “You can close this panel. Your store will confirm the result.” Cancellation: “Nothing changed. Your existing access is safe.” No purchases: “Check the account used for your original purchase.” Error: “Your access has not changed. Please try again.” Restored: “Your previous purchase is ready to use.” |
The demo plan is “Aster Plus” with “Active through 11/15/2026”. Dates are already formatted by the host; the panel never parses or validates them. Every label is required even when that branch is currently inactive. Error explanations must be safe for display; do not pass raw SDK exceptions.
Ref
requestRestore() and requestRetry() have the same guards and deduplication as touch. dismiss() calls only onDismiss. reset() replays the current visual entry; it does not clear remote state, deduplication, entitlement, or transaction work. Controlled props always win. Ref calls do not create a new operation ID.
Control suffixes: -restore, -retry, -continue, -dismiss, -account-help, -support; confirmed plan: -entitlement.
Compatibility
Target: Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, worklets 0.13.0, Skia 2.14.0. Supports iOS, Android, and web with Expo Go-compatible native dependencies; web needs the CanvasKit setup above.
The web autoplay is checked with Playwright at 320×694 and 320×568, including screenshots during exits, entrances, and settled states.
Implementation notes
Content exits before the next title or button row becomes visible; the incoming layer owns input and accessibility. Top-right controls are absent. No scrolling or decorative counters are present. Compact layout removes decoration before actions; the two small web viewports are checked visually. The confirmation begins at 2000 ms, before 3 seconds. Every BlurView has its own clipping radius. No digit rollers are used. Business callbacks are never attached to animation completion.
Accessibility
Actions have button roles, supplied labels, stable test IDs, and minimum 48-point targets. Status is announced once per content transition in JS on native; web uses a polite live region. No frame-by-frame progress announcements. State content exits over 140 ms before the next layer enters over 200 ms. Both layers stay mounted for measurement, but their visible phases never overlap. Panel height waits for exit, then interpolates over the 200 ms entry. Outgoing actions are hidden from accessibility and ignore input. Loading keeps its spinner and “Checking purchases” label visible. Reduced motion switches content and height immediately; the loading arc becomes static, with the same label and actions. No flight or looping motion remains.
The root measures its actual host slot using onLayout. Compact slots reduce medallion size and remove the empty plan reservation. Text stays in normal flow with font scaling enabled. Demo uses no ScrollView, reserves the top-right 56-point region, limits tablet width to 480, removes its decorative headline below 650 points or at large text, and puts controls beside the panel in landscape. At 200% font size, the host must allocate enough vertical space and offer an explicit expanded account/support detail surface for long content. The component does not apply safe-area insets or truncate errors. The host owns focus entry and restoring focus to the opener after dismissal.
The demo control band uses intensity-45 blur with its own radius and overflow clipping, over a readable opaque fallback. Production material uses a light border, double shadow, and accent tint without requiring blur.




