About this animation
A feed ending with a scroll-drawn check and elastic load-more action, with configurable stories, copy, fonts, and accent color.
Why this one
An endless spinner gives readers no sense of arrival. This pattern makes completion a reward: the check draws with the final 180 points of scrolling, the circle fills, and ten fine particles accompany a small lift. A tensioned arc makes the next page feel attached to the finger. Three skeletons resolve individually, and the completion block moves to the new end.
How it works
- A native ScrollView measures its content and viewport. A UI-thread shared scroll offset derives the remaining distance and check stroke.
- A simultaneous pan tracks only upward travel after reaching the bottom. Progressive resistance caps the pull without a hard stop.
- Releasing beyond the threshold or tapping the button triggers the same guarded async request. Three compact placeholders are inserted above the completion block.
- After data arrives, one shared page progress crossfades the three stories sequentially. Shimmer stays clipped inside each skeleton shape.
- A successful page commits once and returns to the relocated end. Touching interrupts automatic scrolling; failed requests expose a retry button.
Install
npx expo install @shopify/react-native-skia expo-image expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-workletsWrap the app in GestureHandlerRootView. Load Skia/CanvasKit before rendering on web (for example, with Skia's WithSkiaWeb loading boundary). Keep demo/ out of the files copied into your product. No assets, fonts, or theme imports outside this folder are required by the component.
Usage
import { CaughtUpFeedEnd, type FeedStory } from './caught-up-feed-end';
const stories: FeedStory[] = [{
id: 'dawn',
title: 'A slower start to the day',
category: 'Field notes',
readingTime: '4 min read',
image: require('./assets/dawn.jpg'),
imageLabel: 'A landscape at dawn',
}];
<CaughtUpFeedEnd
stories={stories}
onLoadMore={async () => fetchOlderStories()}
/>A customized feed:
<CaughtUpFeedEnd
stories={stories}
onLoadMore={fetchOlderStories}
colorScheme="dark"
accentColor="#38BDF8"
fontFamily="YourLoadedSans"
titleFontFamily="YourLoadedSerif"
pullThreshold={64}
revealDuration={420}
title="A good place to pause"
description="Everything from your favorite writers, read."
onStateChange={state => updateReadingStatus(state)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
stories | readonly FeedStory[] | Required | Initial articles with unique IDs, headline, category, reading time, image, and image label. |
onLoadMore | () => Promise<readonly FeedStory[]> | Required | Fetches up to three older articles. Reject or return an empty page to offer retry. |
title | string | You're all caught up | Completion headline. |
description | string | You've read everything from the last 3 days | Completion description. |
buttonLabel | string | See older stories | Visible and accessible load label. |
accentColor | string | #0EA5E9 | Check, arc, and button accent; use a six-digit hex color. |
colorScheme | 'light' | 'dark' | 'auto' | auto | Color treatment. |
fontFamily | string | System | Body font. Load it in the host app. |
titleFontFamily | string | Body font | Editorial font. |
pullThreshold | number | 58 | Pull distance after resistance, in points. Keep below the 170-point asymptote. |
revealDuration | number | 380 | Crossfade duration per article, in milliseconds. |
onStateChange | (state) => void | — | caught-up, loading, loaded, or error. |
onInteraction | () => void | — | Stops playback when the reader touches or manually scrolls. |
style | StyleProp<ViewStyle> | — | Root container style; root defaults to flex: 1. |
testID | string | — | Root test identifier. |
The forwarded CaughtUpFeedEndRef exposes scrollToEnd(), pull(), loadMore(), and reset(). The demo owns autoplay; the production component has no demo-specific props. Incoming articles should have IDs distinct from earlier pages. The initial list is read directly from props; appended pages remain until reset.
Timing and layout
The demo has a 0.35-second reading pause, a 1-second scroll to the reward (visible before 3 seconds), an elastic pull at 2 seconds, and loading at 2.5 seconds. The mock request resolves after 800 ms; stories crossfade at 380 ms each. The next loop starts at 7.4 seconds. Touching stops that loop and leaves real interaction available.
At 320×694 and 390×844, the header and bottom appearance control stay in flow. The feed fills the remaining height and scrolls; cards use available width with 20-point side padding. The upper-right 58 points of the header are reserved for the catalog close control. Cards use minimum heights and can grow with multiline editorial titles; completion copy stays centered in a 240-point measure.
Compatibility
Built for Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, and Skia 2.14.0. Uses modules included in this project's Expo Go stack. Web requires CanvasKit initialization before mounting.
Accessibility
The button has a button role, explicit label, loading state, and native accessibility activation action. It is a 48-point touch target and provides the same action as the overscroll gesture. Important transitions announce completion, loading, page arrival, or failure; status copy uses a polite live region. Images have descriptions and decorative canvases/skeletons are hidden from assistive technology.
Reduced motion removes particles, the check lift, shimmer, press scaling, and article translation. Each incoming article uses a 200 ms crossfade; automatic scrolling jumps to its destination. The check uses a 200 ms opacity change instead of drawing its stroke. There are no blur or trail effects.
Icons: Lucide (ISC), copied from the project kit. Demo brand: Lumen, using the kit's amber tile and lamp glyph. Photos are bundled inside demo/assets/.
Implementation notes
Copy stays in normal flow; flexible card heights prevent headline clipping at 320×694 and 390×844. The scrolling feed absorbs the difference in screen height, and the completion copy has a 240-point measure. The header reserves the upper-right 58 points for the viewer close control. The completion burst is scheduled around 1.35 seconds into autoplay. Shimmer is clipped to empty skeleton shapes, never placed over rendered text. Reduced motion replaces the elastic arc with a 200 ms fade.




