About this animation
A scroll-driven zoom through photo-filled type into destination content, with configurable chapters, mask font asset, text fonts, and surface color.
How it works
- Measure the host with
onLayoutand measure each chapter's scroll content. Chapter boundaries adapt to wrapped text and galleries. - Build a cached word outline from the font supplied through
titleFont. Broad letterforms give the photograph enough surface to read before the reveal. - Magnify the mask, keeping the photograph at viewport size. An aperture inside the chosen A or O counter grows to cover every viewport corner. The full photograph fades in above the paper before the mask fades out, keeping the reveal filled with imagery.
zoomScalechanges the mask only, so it does not multiply the image resolution requirement. - Hold the photograph while a glass-backed product section scrolls into view. Its description, amenities, local photo gallery, price, and action are ordinary accessible content in the ScrollView.
- Derive masks and editorial fades from one continuous progress value. The next chapter starts at its measured boundary. Chapter snapping is optional and only enabled on native platforms.
- The demo calls
seek()andreset()for autoplay, and shows a sample date-selection summary when a stay is chosen. Direct interaction cancels the scripted scroll.
Install
npx expo install @shopify/react-native-skia expo-blur expo-image expo-linear-gradient react-native-reanimated react-native-workletsCopy TextMaskZoomScroll.tsx, icons.ts, and index.ts into your project. On web, initialize CanvasKit before mounting with Skia's LoadSkiaWeb or your existing deferred loader. The host supplies and loads interface font families. Supply your own mask font through the required titleFont prop; font files and licenses in demo/assets/ belong to the demo and are not part of the component delivery. Icons use local Lucide paths rendered as SVG by Expo Image, avoiding extra WebGL canvases.
Usage
import TextMaskZoomScroll from './text-mask-zoom-scroll';
<TextMaskZoomScroll
titleFont={require('./assets/YourDisplayFont.ttf')}
chapters={[{
word: 'STAY', image: require('./coastal-home.jpg'), portalIndex: 2,
accent: '#DCE9AB', heading: 'Stay a little longer.',
emphasis: 'Leave with more.', description: 'A private cove and coffee on the terrace.',
stay: 'Coastal House', location: 'COASTAL COLLECTION',
rating: '4.96', rooms: '3 rooms', access: 'Sea access', price: '$420',
gallery: [{ source: require('./terrace.jpg'), label: 'Your terrace' }],
note: '3-night minimum',
}]}
onStayPress={(index) => openDatePicker(index)}
/>const story = useRef<TextMaskZoomScrollRef>(null);
<TextMaskZoomScroll
ref={story}
chapters={destinations}
titleFont={require('./assets/YourDisplayFont.ttf')}
snapToChapters={false}
zoomScale={32}
backgroundColor="#F7F3EE"
textColor="#243D34"
mutedColor="#66766B"
surfaceColor="rgba(20,44,36,0.94)"
surfaceTextColor="#FAF6EB"
surfaceMutedColor="#C1CFC4"
borderColor="#FFFFFF24"
fontFamily="YourSans"
titleFontFamily="YourSerif"
italicFontFamily="YourSerifItalic"
monoFontFamily="YourMono"
onChapterChange={trackChapter}
/>
// In a timer or event:
story.current?.seek(0.88, 1400);The local .ttf in these examples is supplied by your app. Skia loads it through useFont; it does not need to be registered as a React Native font family. Load any interface fonts separately before mounting the component. Keep the license for your chosen font with your app.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
chapters | readonly TextMaskChapter[] | Required | Word, image, accent, heading, emphasis, description, stay, rating, rooms, access, price, and portal index. Optional location, badge, note, and gallery with source/label pairs. Use a word with an A or O at portalIndex. |
titleFont | DataSourceParam | Required | Font source accepted by Skia useFont, such as a local .ttf loaded with require(). Use broad letterforms with an A or O counter for the portal. |
fontFamily | string | System | Interface font. |
titleFontFamily | string | System | Editorial and product headings. |
italicFontFamily | string | System | Editorial emphasis. |
monoFontFamily | string | System | Labels and formatted prices. |
title | string | Places worth\nthe journey. | Initial editorial title. |
collectionLabel | string | A collection of remarkable places | Initial eyebrow. |
scrollLabel | string | Step inside | Scroll alternative label. |
sectionLabel | string | THE STAY | Product section eyebrow. |
priceSuffix | string | / night | Price unit; prices are already formatted chapter strings. |
stayCtaLabel | string | View stay | Each product button's text and accessible label. |
onStayPress | (index: number) => void | — | Selected chapter action; falls back to onCtaPress. |
ctaLabel | string | Explore stays | Footer button text and accessible label. |
onCtaPress | () => void | — | Footer action. |
footerTitle | string | Your next story\nstarts here. | Footer heading. |
footerDescription | string | Find a place that feels like you. | Footer copy. |
onInteraction | () => void | — | Direct touch/pointer interaction notification. |
onChapterChange | (index: number) => void | — | Active chapter callback through scheduleOnRN. |
zoomScale | number | 40 | Maximum mask magnification, minimum 2. |
backgroundColor | string | #F7F3EE | Paper around the mask. |
textColor / mutedColor | string | #243D34 / #66766B | Paper ink and secondary copy. |
surfaceColor | string | rgba(20,44,36,0.94) | Product and footer material. |
surfaceTextColor / surfaceMutedColor | string | #FAF6EB / #C1CFC4 | Product and footer copy. |
actionTextColor | string | #172721 | Ink on accent-colored buttons. |
borderColor | string | #FFFFFF24 | Material and amenity borders. |
snapToChapters | boolean | true | Native-only snapping. |
brand | ReactNode | — | Noninteractive top-left brand slot. |
style | StyleProp<ViewStyle> | — | Host container styling. |
testID | string | — | Root ID; controls append -enter-N, -stay-N, -next-N, or -confirm. |
The ref exposes seek(progress, durationMs = 2600) and reset(). Integer progress selects a chapter's beginning, and the fractional part advances by one viewport within it: 0.68 reveals the first product sheet and 0.88 brings more of it into view. chapters.length + 0.1 reveals the footer. Product sections can be taller than a viewport; ordinary scroll accesses all content.
Layout and assets
The component fills its host and uses the host's dimensions, including embedded layouts. Product content has a maximum width of 680 points, wraps naturally, and stays scrollable in landscape and short containers. It does not apply safe-area insets: the host screen owns them. Product badges sit below the section label on the left, with 56 points reserved on the right for host controls. The initial scroll hint fades out as soon as scrolling begins and does not repeat in later chapters. Keep the upper-right area clear for host controls when supplying a brand.
Supply local hero images covering the viewport at 3× density. Mask magnification does not enlarge the photograph, so zoomScale does not increase the image resolution requirement. Supply gallery photos at 3× their rendered size.
Compatibility
Expo SDK 58, RN 0.88.0, Reanimated 4.7.1, worklets 0.13.0, Skia 2.14.0. Uses SDK 58 BlurTargetView with Android SDK 31+ blur and its older-device fallback. Supports iOS, Android, and web. Rendering and performance depend on the host device.
Accessibility
Reduced motion removes mask enlargement, scripted travel, and press springs; photographs use a short fade. Product descriptions, gallery labels, prices, and controls are real accessible ScrollView content. The first chapter offers a 48-point scroll button, products offer a 50-point action, and a next-chapter button provides a touch alternative. Typography retains system font scaling and product content grows to fit. Interface text and palette colors are configurable.




