About this animation
A pinned gallery that turns vertical scroll into horizontal photo travel, with configurable chapters, intro content, fonts, and accent color.
Why this one
One familiar vertical gesture controls the entire itinerary. Interior image parallax moves 18% slower than each card, while oversized chapter numbers, resting poses, and layered photographic backdrops give each stop its own composition. There is no separate horizontal swipe to discover.
How it works
- Measure the component with
onLayout; intro and gallery viewport follow its height. - Reserve a vertical pin distance of
chapterCount × 0.9 × height, plus one viewport for the pinned scene. - Read vertical offset on the UI thread with
useAnimatedScrollHandler. Derive one continuous chapter progress from it. - Render the gallery as an absolute sibling of the scroll view, above a spacer that reserves its scroll distance. The scene enters from below, stays at a constant vertical position throughout the pin interval, and exits upward. Map progress into a horizontal rail, with a 20% resting window around each chapter and smoothstep transitions.
- Offset oversize images inside clipped cards; derive scale, perspective, darkening, headline visibility, and progress from the same chapter value. Backdrop photos fade in above the preceding layer.
- Release the scene at the end. Render the closing CTA inside the scrolling content. Native chapter snapping is disabled on web so mouse-wheel scrolling remains continuous.
Install
npx expo install expo-blur@~58.0.5 expo-image@~58.0.17 expo-linear-gradient@~58.0.3 react-native-reanimated@4.7.1 react-native-worklets@0.13.0Copy this folder without demo/. No shared theme, remote assets, Skia setup, or custom native module is required. Icons: Lucide (ISC); their copied paths are rendered as SVG image sources.
Usage
import { PinnedHorizontalScroll } from './pinned-horizontal-scroll';
const chapters = [
{
id: 'coast', image: require('./coast.jpg'), title: 'Walk the coast.',
subtitle: 'Take your time.', eyebrow: 'THE COAST',
description: 'Follow 3 miles of shoreline.', days: 'Day 1–2',
},
];
<PinnedHorizontalScroll chapters={chapters} />;A customized composition:
<PinnedHorizontalScroll
chapters={chapters}
intro={<Intro />}
closing={<BookingSection />}
accentColor="#0F766E"
cardWidthRatio={0.78}
cardHeightRatio={0.58}
parallaxIntensity={0.18}
fontFamily="YourInterfaceFont"
serifFontFamily="YourEditorialFont"
italicFontFamily="YourItalicFont"
monoFontFamily="YourCounterFont"
onIndexChange={(index) => trackStop(index)}
/>Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
| chapters | readonly CoastChapter[] | required | Ordered images, two-line titles, categories, details, day ranges, and optional accents |
| intro | ReactNode | undefined | Normal first viewport |
| closing | ReactNode | undefined | Normal closing section; put the CTA here |
| fontFamily | string | system | Interface text |
| serifFontFamily | string | system | First title line |
| italicFontFamily | string | system | Second title line |
| monoFontFamily | string | system | Large numbers and chapter counter |
| accentColor | string | #0F766E | Progress highlight |
| colorScheme | light / dark / auto | light | Container surface theme; supplied slots own their colors |
| cardWidthRatio | number | 0.78 | Width fraction, clamped to 0.6–0.9 |
| cardHeightRatio | number | 0.58 | Height fraction, clamped to 0.4–0.64 |
| parallaxIntensity | number | 0.18 | Interior lag, clamped to 0–0.3 |
| snapToChapters | boolean | true | Native snapping only |
| onIndexChange | (index: number) => void | undefined | Nearest chapter callback on JS |
| onInteraction | () => void | undefined | Touch or manual scroll; stop caller autoplay |
| style | StyleProp<ViewStyle> | undefined | Root container style |
| testID | string | undefined | Test identifier |
duration | number | 300 | Reset curtain fade duration in milliseconds. Spring physics and reduced-motion timing are unchanged. |
chapters | readonly CoastChapter[] | Required | Ordered chapters; an empty list leaves intro and closing content usable. |
intro | ReactNode | undefined | Normal scrolling introduction. |
closing | ReactNode | undefined | Normal scrolling closing section, including any CTA. |
fontFamily | string | undefined | Interface font; defaults to the system font. |
serifFontFamily | string | undefined | Editorial heading font. |
italicFontFamily | string | undefined | Italic editorial font. |
monoFontFamily | string | undefined | Tabular counter font. |
accentColor | string | '#0F766E' | Highlight color. Defaults to teal. |
colorScheme | 'light' | 'dark' | 'auto' | 'light' | Surface theme. Defaults to light. |
cardWidthRatio | number | 0.78 | Card width as a fraction of measured width. Defaults to 0.78. |
cardHeightRatio | number | 0.58 | Card height as a fraction of measured height. Defaults to 0.58. |
parallaxIntensity | number | 0.18 | Interior image lag. Defaults to 0.18. |
snapToChapters | boolean | true | Snap on native platforms only. Defaults to true. |
onIndexChange | (index: number) => void | undefined | Called when the nearest chapter changes. |
onInteraction | () => void | undefined | Called on touch or manual scroll, so the caller can stop autoplay. |
style | StyleProp<ViewStyle> | undefined | Container style. |
testID | string | undefined | Testing identifier. |
followLabel | string | 'SCROLL TO FOLLOW THE COAST' | Label for the chapter scroll hint. |
exploreLabel | string | 'SCROLL TO EXPLORE' | Label for the opening scroll hint. |
The ref exposes goTo(chapter, duration = 2600), stop(), and reset(). Use -1 for the intro and chapters.length for closing. Reset fades to black, returns to the top, and reveals the introduction. Autoplay belongs to the demo and stops on touch or drag; chapter buttons remain usable after stopping it.
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, and Worklets 0.13.0. Compatible with Expo Go's bundled modules. Android glass uses BlurTargetView and the SDK 31+ blur method; older Android versions retain a translucent pill. Supports iOS, Android, and web.
Accessibility
Each chapter title has the header role. Chapter markers and the first-screen scroll hint are buttons with labels and hit areas of at least 44 points, providing a tap alternative to scrolling. The final demo CTA has a descriptive label and live confirmation. Reduced motion removes interior parallax, zoom, perspective, horizontal card travel, and hint bobbing; chapters fade over 200 ms. The gallery stays outside the scrolling content in both motion modes; only its entry and exit move vertically. Noninteractive gallery layers let scroll gestures reach the underlying scroll view, while chapter markers remain tappable.
Demo timing
The headline and first photographic preview enter in 850 ms. At 700 ms autoplay starts a 1,700 ms scroll into the first pinned card. Further chapters take 2,600 ms with (0.77, 0, 0.175, 1), followed by the closing section and a black reset. The CTA compresses to 0.97 on press and shows a local request confirmation.




