About this animation
Inline citations that open an evidence panel and return to their marker, with configurable text segments, sources, palette, and fonts.
Why this one
Reading position is part of the citation contract. Evidence opens inside the component without moving the paragraph; sources belong to a claim, and Close returns focus to its exact marker. There is no hover dependency, remote favicon, global portal, automatic navigation, or factual verification.
How it works
- Pass stable text/claim segments and host-owned source objects. Claims resolve their source IDs locally, preserving their order and removing missing IDs and duplicates.
- Words wrap in normal React Native layout. Each citation owns a real 48 × 48 control in that same flow, with a 24 × 24 visual marker. No hit slop overlaps another line.
- A reserved evidence area stays in layout even when closed. A spring drives its opacity, vertical offset, and corner radius. A separate 320 ms underline travels backward from the citation through only the selected claim; the title rule completes the connection.
- Source changes fade out for 80 ms, pause for 20 ms, then fade in for 120 ms. Only one evidence snapshot is mounted. Removed evidence is hidden immediately; available sources are resolved again from the current props.
- Selection and callbacks stay on JS. Timers, keyboard/back subscriptions, and UI animations are cleaned up on unmount.
Install
Copy this folder without demo/ into your app. React and React Native are peer requirements.
npx expo install @shopify/react-native-skia react-native-reanimated react-native-workletsVerified dependency versions: Skia 2.14.0 and Reanimated 4.7.1, with Expo SDK 58 / React Native 0.88.0 and the project's Reanimated/worklets setup. Web requires Skia's CanvasKit initialization before importing this component. Icons: Lucide (ISC); exact paths copied from the internal kit.
Usage
import { InlineSourceReveal, type Source } from './inline-source-reveal';
const sources: Source[] = [{
id: 'reading', title: 'Reading habits, revisited',
domainLabel: 'research.example', url: 'https://research.example/reading',
excerpt: 'Short sessions were easier to repeat over time.',
}];
<InlineSourceReveal
segments={[{
id: 'routine', type: 'claim',
text: 'Small daily reading sessions help a routine stick.',
sourceIds: ['reading'], citationLabel: '1',
}]}
sources={sources}
labels={{
sourceHeading: 'Evidence', openSource: 'Open source', close: 'Close source',
previous: 'Previous source', next: 'Next source', unavailable: 'Source unavailable',
}}
accessibilityLabel="Reading response with citations"
onOpenSource={source => showSourceDetail(source)}
/>The host implements showSourceDetail. It may show a full accessible detail screen or open a validated URL. The component never invokes a browser or fetches a source.
Controlled selection and appearance knobs:
const [claim, setClaim] = useState<string | null>(null);
const evidenceRef = useRef<InlineSourceRevealRef>(null);
<InlineSourceReveal
{...contentProps}
ref={evidenceRef}
activeClaimId={claim}
onActiveClaimChange={setClaim}
onSourceChange={sourceId => recordSourceSelection(sourceId)}
colorScheme="dark"
motionScale={0.8}
fontFamily={bodyFont}
titleFontFamily={mediumFont}
monoFontFamily={numberFont}
formatSourcePosition={(index, total) => `${index + 1}/${total}`}
/>Props
| Prop | Default | Contract |
|---|---|---|
segments: Segment[] | Required | Ordered plain text or claims with unique stable IDs. Claim: id, text, sourceIds, citationLabel. Plain text: id, type: 'text', text. Either supports paragraphBreakBefore to start a new paragraph. |
readingFontSize?: number | 16 compact / 18 regular | Reading font size in points, with 1.45× line height. Controls keep their interface typography. |
sources: Source[] | Required | Unique stable id, title, domainLabel, url, excerpt; optional dateLabel disappears when empty. |
activeClaimId?: string | null | Uncontrolled, closed | Controlled props prevail over imperative requests. Invalid selection closes the presentation. Do not switch modes during an instance's lifetime. |
onActiveClaimChange?(id) | — | Requests opening or closing once per action. Reopening the current marker closes it. |
onOpenSource(source) | Required | Explicit business action only; disabled for missing/empty URLs. Receives the current full source. |
onSourceChange?(sourceId) | — | Explicit Previous/Next selection only; filtered claim-relative order, bounded ends. |
labels | Required | See the complete label table below. |
formatSourcePosition?(index,total) | 1/2 | Zero-based index; default position has a reserved 56 pt tabular column. Supply a concise localized value. |
colorScheme?: 'light' | 'dark' | 'auto' | auto | Complete Paper/Night appearance. |
palette?: Palette | Theme palette | Full background, surface, text, muted, border, accent, onAccent, success, error override. Host owns the surrounding background. |
fontFamily?, readingFontFamily?, titleFontFamily?, monoFontFamily? | System | Host-loaded fonts; no global theme imports. |
motionScale?: number | 1 | Clamped 0–1.5; nonfinite values use 1. Zero removes travel and sweep. |
style?: StyleProp<ViewStyle> | — | Root uses flex:1, measured with onLayout. No screen dimensions or safe-area padding. |
testID?: string | inline-source-reveal | Suffixes: -citation-ID, -close, -open-source, -previous, -next. |
accessibilityLabel: string | Required | Describes the reading region. |
| Label key | Demo value | Used for |
|---|---|---|
openSource | Open | Visible CTA and accessible name. |
close | Close source | Close icon accessible name. |
previous | Previous source | Previous control accessible name. |
next | Next source | Next control accessible name. |
unavailable | Source unavailable | Empty or missing evidence fallback and announcement. |
sourceHeading | Evidence | Panel heading and marker accessible name prefix. |
Ref and controlled behavior
open(claimId), close(), nextSource(), previousSource(), and reset() are exposed by InlineSourceRevealRef. Open/close request onActiveClaimChange; they never mutate a controlled claim. Next/Previous operate on local source selection and notify onSourceChange. reset() clears local source selection and uncontrolled selection without any business callback; it leaves a controlled claim intact. The host should discard stale asynchronous responses by ID before replacing props.
States and transitions
| State / trigger | Result |
|---|---|
| Closed → citation / ref open | Claim stays stationary; panel spring and underline reveal. |
| Open → another citation | Retarget from current reveal progress; evidence changes serially; new marker becomes return target. |
| Open → same citation / Close / Escape / Android Back | Hide evidence; restore marker focus. Back is consumed while open. |
| Previous / Next | Bounded claim-relative source change; callback at selection time, independent of animation completion. |
| Removed source | Re-resolve available IDs, fall back to first remaining source; none means unavailable with disabled link and Close. |
| Missing claim / empty segments | Closed presentation; no exception. |
| Reduced motion / motionScale 0 | No travel or sweep; evidence uses 90 ms exit, 20 ms pause, 90 ms entry. |
Demo and layout
Lumen, the article “The quiet art of reading,” its reading tips, three citations, evidence, domains, and URLs are fictional. The article uses Instrument Serif at 1.18× its nominal size, independently of the Geist interface. “Open” stays on one line and demonstrates the host callback with a local detail card; it does not navigate. Replay and Night/Paper stay below the content.
Autoplay opens claim 1 at 600 ms, changes its source at 1900 ms, closes at 2700 ms, opens claim 2 at 3400 ms, switches to claim 3 at 4200 ms, closes at 4900 ms, and repeats at 5500 ms. Pointer/touch/keyboard interaction cancels queued playback.
The demo uses no scroll at 320 × 568, 320 × 694, or 390 × 844. Short reading tips and a compact evidence panel preserve room for the 48 pt controls. At large system text, one outer scroll container keeps the content reachable with its indicator hidden. At 200% text it retains the first claim and removes the brand/headline. Landscape removes the brand, reduces headline spacing, and places reading and evidence side by side. The source CTA gets its own row at large text. Long source previews are bounded; full values pass unchanged to the host callback.
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Skia 2.14.0 on iOS, Android, and web. These native modules are available in the matching Expo Go client. Initialize CanvasKit before mounting on web.
Accessibility
React Native Text retains system scaling. Controls are 48 pt minimum, with press-in feedback. Web Pressable provides Enter/Space and Tab; Escape closes. Closing restores web focus to the originating citation with preventScroll. The evidence label is plain, nonfocusable text on web; opening never transfers browser focus to it. Native accessibility focus uses a platform-guarded node handle and announces only the current source. Closed/transitioning content is excluded from accessibility; disabled actions expose their state. The host owns full source detail and should provide a vertical detail layout for large text, including the untruncated title/domain/excerpt.
Implementation notes
- All interface and evidence text comes from props; empty date labels leave no spacer. All demo controls stay away from the upper-right viewer dismissal area.
- The paragraphs keep identical geometry before/after opening. Markers own actual flow space, not overlapping hit slop. No labels crossfade or stack; missing evidence cannot remain actionable.
- The first autoplay reveal starts at 600 ms and its fine underline completes before 1 second. It never covers letters. No rolling-number effect is needed for the static source fraction.
- Layout budgets: normal reading minimum 160 pt, compact 136 pt, evidence slot minimum 204 pt compact / 220 pt regular; controls remain 48 pt. Compact reading omits paragraph spacers. Landscape uses columns. No safe area is applied twice.
- There are no BlurViews or permanent ambient animation, so no square blur artifacts or idle rendering clock. All springs specify mass 1.
- Business navigation is tied to the explicit CTA, never to animation completion. Source IDs are filtered against current host data.




