About this animation
A live feed that preserves the reading position, with configurable items, labels, palette, and fonts. New content waits in a notice band until the reader requests it.
Why this one
Reading stability is the effect. Prepending a batch does not intentionally move the current record, steal focus, or reorder existing records. The component deduplicates IDs, excludes updates to existing IDs from the unread count, restores by identity, and chooses a surviving neighbor when an anchor disappears. The host owns data and subscription behavior.
How it works
- Keep descending chronological order in the host. Each ID identifies a persistent record; duplicate IDs keep their first position and last payload.
- Capture the first visible ID and its signed top offset relative to the list viewport. A negative offset means that the row starts above the viewport.
- On iOS and Android, use FlatList's native
maintainVisibleContentPositionfor prepends. Web disables browser scroll anchoring and compensates the measured row delta before paint when the anchor is mounted. Virtualized targets are sought and corrected after measurement. - Count unseen prepended IDs while the reader is more than 32 points from the start. Updates to existing IDs and appended earlier pages do not increment the badge. Visiting records consumes their unread IDs.
- Reserve the notice band even when the capsule is invisible. A voluntary jump runs for 280 ms; dragging cancels an active jump. A request received during a drag waits for scrolling to finish, and another drag cancels the queued request.
- Remeasure variable-height rows after layout changes. Native explicit restores and anchor-removal recovery measure the target against the viewport; no fictional fixed-height
getItemLayoutis supplied.
Install
Copy this folder, excluding demo/, into your app. Dependencies are already compatible with Expo SDK 58:
npx expo install react-native-reanimated react-native-worklets @shopify/react-native-skiaUse the Reanimated/worklets setup appropriate to your host project. On web, initialize Skia's CanvasKit before mounting the component, following your host's Skia setup. No network images, external services, or additional native modules are required. Icons: Lucide (ISC); exact paths are included in icons.ts.
Usage
import { useRef, useState } from 'react';
import { View } from 'react-native';
import {
AnchoredLiveFeed,
PAPER,
type AnchoredLiveFeedRef,
type FeedItem,
} from './anchored-live-feed';
const labels = {
empty: 'No notes yet.',
showLatest: 'Show latest',
pause: 'Pause updates',
resume: 'Resume updates',
readingHere: 'Reading here',
loading: 'Loading earlier notes',
anchorRemoved: 'That note was removed. Continuing at the next available note.',
};
export function Notes({ items }: { items: FeedItem[] }) {
const feed = useRef<AnchoredLiveFeedRef>(null);
const [live, setLive] = useState(true);
return (
<View style={{ width: '100%', height: 400 }}>
<AnchoredLiveFeed
ref={feed}
items={items}
labels={labels}
live={live}
onLiveChange={setLive}
palette={PAPER}
motionScale={0.8}
formatNewCount={(count) => `${count} new notes`}
accessibilityLabel="Notes, newest first"
testID="notes"
/>
</View>
);
}The host subscribes or unsubscribes when its controlled live value changes. A pause request does not mutate or reject items. Filter stale network responses by ID and request generation before updating props. Do not reorder existing records during a prepend.
For custom content, pass renderItem={({ item, index }) => <YourRow item={item} />}. Supply complete row content and accessibility semantics. Height is measured from the rendered result. onAnchorChange can save { id, offset } for a later feed.current?.restoreAnchor(saved) call.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
items | FeedItem[] | Required | { id, title, body, timeLabel } records, newest first. Empty IDs are ignored. |
renderItem | ({ item, index }) => ReactNode | Editorial row | Replace the complete row without changing anchoring. |
onLoadEarlier | () => void | Absent | Request an earlier page on approach to the end. Guard requests in the host. |
loadingEarlier | boolean | false | Host request state; displays labels.loading. |
live | boolean | true | Controlled subscription status. |
onLiveChange | (live: boolean) => void | Absent | Requests a subscription change once per activation. Without it, the subscription control is omitted. |
onAnchorChange | (anchor: FeedAnchor) => void | Absent | Reports changed ID, signed offset, and atLatest. |
formatNewCount | (count: number) => string | String | Complete badge text and debounced announcement. If the string contains the decimal count, its first occurrence is replaced by fixed-width digit columns. Otherwise the formatted string is displayed verbatim without an extra numeric prefix. |
labels | FeedLabels | Required | All structural content; see the label table. |
colorScheme | 'light' | 'dark' | 'auto' | 'auto' | Paper, Night, or system appearance. |
palette | FeedPalette | Derived from theme | Complete override: background, surface, text, muted, border, accent, onAccent, success, error. |
fontFamily | string | System font | Body and controls. |
titleFontFamily | string | fontFamily | Row headings. |
monoFontFamily | string | fontFamily | Time labels and digits. |
motionScale | number | 1 | Travel clamped to 0–1.5; nonfinite values use 1. Zero disables travel. |
style | StyleProp<ViewStyle> | Absent | Root layout override; root uses flex: 1. |
testID | string | 'anchored-live-feed' | Root ID; stable -list, -latest, and -live suffixes. |
accessibilityLabel | string | Required | Accessible name for the list. |
Labels
| Key | Purpose | Demo value |
|---|---|---|
empty | Empty-list content | Your next idea starts here. |
showLatest | Voluntary jump action | Show latest |
pause | Pause request action | Pause updates |
resume | Resume request action | Resume updates |
readingHere | Current record marker | Reading here |
loading | Earlier-page loading state | Loading earlier notes |
anchorRemoved | Discrete accessibility announcement | That note was removed. Your place continues at the next available note. |
Empty row titles, bodies, time labels, marker labels, and loading text are omitted. The notice band remains reserved by design. No demo brand or editorial copy is embedded in the reusable component.
Ref methods
| Method | Behavior |
|---|---|
scrollToLatest() | Requests a voluntary jump; clears unread IDs. Does not change data or subscription state. |
restoreAnchor({ id, offset }) | Restores a measured signed viewport offset. Missing IDs announce removal and fall back to the start. Nonfinite offsets become zero. |
reset() | Clears unread IDs and returns the anchor to the start. Does not mutate items or call business actions. |
live and loadingEarlier are always controlled. There is no uncontrolled subscription mode and no ref method that simulates arrivals. A host replacement of the data remains authoritative.
States and transitions
| State | Trigger | Result |
|---|---|---|
| Latest, within 32 pt | Prepend | Follow the new start; no unread badge. |
| Reading farther down | Prepend | Preserve ID/offset; accumulate unseen IDs. |
| Reading | Update an existing ID | Update its content without adding an unread ID; remeasure height. |
| Reading | Anchor removed | Choose next surviving old record, then preceding survivor; announce once. No survivors: start. |
| Unread content available | Show latest | Clear count; jump in 280 ms, or immediately with reduced motion. |
| Dragging/momentum | Jump request | Queue it; another drag cancels it. |
| Jumping | Drag begins | Cancel travel and give scrolling to the user. |
| Live/paused | Subscription control | Request onLiveChange(!live); wait for host props. |
| Loading earlier | Host loadingEarlier | Static loading label; preserve reading context. |
| Empty | No valid IDs | Display empty text; controls remain available. |
Demo and simulated content
The Lumen demo uses 100 locally generated editorial records with variable heights. It opens at note-4 with offset −18; receives one note at 600 ms and two more at 850 ms; shows the accumulated capsule before 1.4 seconds; jumps at 2.1 seconds; and restores the original fixture at 4.2 seconds. autoplay repeats every five seconds. Replay runs one cycle. Touch/pointer input cancels pending demo timers; theme and subscription actions also cancel them. The demo owns every fixture reset.
Paper/Night palettes are complete. Ambient gradients are static. The bottom control band places a radius-20 BlurView over its own gradient, with an opaque readable surface as Android fallback. No photographs or generated artwork are used.
Compatibility
Target versions: Expo 58, React Native 0.88.0, Reanimated 4.7.1, worklets 0.13.0, Skia 2.14.0. Expo Go includes these modules. The demo also uses existing expo-blur and expo-linear-gradient; neither is required by the reusable component.
Platform limits: native prepend behavior depends on RN's visible-content implementation; do not reorder existing data during prepend. Web can compensate a mounted anchor synchronously; a virtualized offscreen restore requires a seek and subsequent measurement. The host should expose an explicit detail view if a row's complete content cannot be conveniently read in its chosen slot at very large text sizes. Custom rows must remain measurable and must not apply independent layout translations to the outer row container.
Implementation notes
- Text remains in flow; compact mode omits the decorative headline and brand. No exterior scroll or top-right control is present.
- The count lives in a permanently reserved band. The reading marker reserves identical geometry in every default row, avoiding marker-induced row reflow.
- The demo shows the three-note arrival while the marked row stays still, before three seconds.
- State labels have one active text layer. Digit columns reserve their width with an invisible in-flow numeral; progress resets in a layout effect.
- Every BlurView and intermediate wrapper has its own radius and overflow clipping. No business action waits for an animation completion.
Accessibility
Only the FlatList scrolls. There is no exterior ScrollView, fixed screen-height calculation, or component safe-area inset. The host supplies safe areas. The demo measures its own slot, limits width to 480 pt, and removes decorative context below 540 pt of height or at large font scale. Compact layouts reserve the top 56 points for the host close control. Rows retain complete text and can grow; they are not clamped to two lines when that would hide data. Small slots trade visible row count for usable controls.
Controls have at least 48-point targets, immediate 0.97 press feedback, labels, and disabled states. The invisible notice is excluded from touch and accessibility. New data never requests focus. Native readers receive a discrete announcement after 400 ms of count stability; web uses one polite live region. Individual count columns are hidden from accessibility. Reduced motion removes row travel, capsule scaling, rolling travel, and animated scrolling; content changes retain a short fade and all actions. Subscription labels replace each other without overlapping text layers.




