About this animation
A scroll-driven photo edit with cumulative adjustments and a live histogram, with configurable image, chapter copy, fonts, and grain opacity.
Why this one
A conventional comparison shows two finished images. This piece explains the decisions between them. Each sweep adds one adjustment to the previous result; the histogram moves with the edit, and the final chapter introduces fine grain and a soft vignette inside the image itself.
How it works
onLayoutmeasures the container. A single continuous chapter progress comes fromuseAnimatedScrollHandlerand the scroll offset divided by the measured height.- Five Skia image layers use cumulative color matrices. RAW, Exposure, and Color use
ColorMatrixfilters; Contrast and Grain evaluate the same matrix coefficients inside a photo shader before the nonlinear curve (CanvasKit does not implement runtime image filters). Exposure applies a 0.40-stop gain; color adds warmth +12 and saturation 1.25; contrast adds a 1.20 gain and an S curve with a 0.04 black floor. Matrix offsets use normalized 0–1 values. - Between 20% and 80% of each interval, a smoothstep clipping rectangle reveals the next image from left to right. The image stays fixed. A 2-point light edge and translucent BEFORE/AFTER pills follow the clipping boundary.
- Precomputed 24-bin RGB distributions interpolate with the same sweep. They describe the bundled lake photo; they are an art-directed visualization, not a live pixel-analysis tool. Replace
HISTOGRAMSinsettings.tswhen adapting the photo. - Each digit column rolls only during its carry, with distance-based fading and a final-quarter transition for continuous units. Headline windows finish before the next headline enters.
- The CTA is part of the scroll content. Photo taps offer chapter navigation, native snapping stays disabled on web, and reduced motion replaces the curtain with 200 ms chapter fades.
Install
npx expo install @shopify/react-native-skia react-native-reanimated react-native-workletsCopy this folder without demo/ into your project. Use your project's Reanimated/worklets configuration. On web, initialize CanvasKit before importing/rendering the component, for example with Skia's WithSkiaWeb loader. The catalog host must register this demo with its Skia-aware lazy loader.
Icons: Lucide (ISC). The demo identity uses the supplied Orrin kit mark. No fonts or demo assets are imported by the reusable component.
Usage
import { PhotoEditScroll } from './photo-edit-scroll';
export default function EditorStory() {
return <PhotoEditScroll source={require('./lake.jpg')} />;
}With typography, copy, and personality controls:
<PhotoEditScroll
source={require('./lake.jpg')}
fontFamily="Geist_400Regular"
monoFontFamily="GeistMono_400Regular"
titleFontFamily="InstrumentSerif_400Regular"
italicFontFamily="InstrumentSerif_400Regular_Italic"
grainOpacity={0.06}
snapToChapters={false}
chapterCopy={[
{ title: 'Start with light.', subtitle: 'Keep every detail.' },
{ detail: 'Raise exposure by 0.40 stops.' },
]}
actionLabel="Save the recipe"
onComplete={() => saveRecipe()}
/>Load custom fonts in your app before rendering. The host container needs a bounded height; the component fills it with flex: 1.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
source | DataSourceParam | Required | Local photo or Skia-compatible image source. |
chapterCopy | readonly PhotoEditChapterCopy[] | undefined | Five optional copy overrides, ordered RAW through Grain. |
fontFamily | string | undefined | Interface font family. |
titleFontFamily | string | undefined | Editorial headline font family. |
italicFontFamily | string | undefined | Italic editorial font family. |
monoFontFamily | string | undefined | Font family for tabular values. |
colorScheme | 'dark' | 'light' | 'auto' | 'dark' | Appearance; auto follows the device. |
snapToChapters | boolean | true | Chapter snapping on iOS and Android only. |
grainOpacity | number | 0.08 | Scales the final texture opacity. |
actionLabel | string | 'Keep this edit' | CTA label. |
header | React.ReactNode | undefined | Optional top-left identity supplied by the host. |
onComplete | () => void | undefined | Called when the final CTA is pressed. |
onChapterChange | (chapter: number) => void | undefined | Called after a chapter becomes active. |
onInteraction | () => void | undefined | Called immediately when a finger, mouse, or wheel interrupts playback. |
style | StyleProp<ViewStyle> | undefined | Container style. |
testID | string | undefined | Test identifier. |
duration | number | 750 | Scroll hint travel duration in milliseconds. Spring physics and reduced-motion timing are unchanged. |
PhotoEditScrollRef exposes goToChapter(index), scrollToProgress(progress), and reset(). Continuous progress is clamped to 0–4. Autoplay belongs to the demo: a cancellable animation-frame clock drives actual scroll with (0.77, 0, 0.175, 1) easing, one chapter every 2.6 seconds. After the final hold, a fade to black hides the return to RAW. Touch or wheel input stops the clock.
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, worklets 0.13.0, and Skia 2.14.0. Uses the installed Expo Go-compatible stack; no additional native dependency is added. Supports iOS, Android, and web.
Accessibility
Each scroll chapter has an accessible header containing its title and concrete adjustment. The photo is a large labeled button for advancing a chapter; the final photo returns to RAW. The CTA and restart control have button roles and minimum 44-point hit areas. Decorative canvas, curtain labels, and rolling text are excluded from the accessibility tree. Reduced motion removes the sweeping curtain and bouncing scroll hint, replaces chapter transitions with 200 ms fades, and disables animated imperative scrolling. There is no parallax, zoom, or velocity trail.




