About this animation
A photo grid with drag selection and a checkmark wave, with configurable photos, title, fonts, and accent color.
Why this one
Select a story with one sweep instead of tapping every photo. Each cell responds with a spring and a 25 ms distance-based ripple. Starting on a selected photo reverses the operation; retracing the sweep restores the selection that existed before the gesture. Proportional edge scrolling extends the same range beyond the visible rows.
How it works
- A three-column square grid measures its own container with
onLayout. - A composed Gesture Handler tap and pan handles native input. Holding for 300 ms enters selection; ordinary browsing scrolls normally.
- The sweep snapshots the original selection. A single shared selection array drives the photo scale, blue tint, and drawn Lucide checkmarks. Pressure uses a separate shared value.
- A UI frame callback scrolls proportionally within 64 pt of an edge, bounded by the measured scroll content and recalculates the current reading-order cell. React updates only when the selected range changes.
- The first selected photo opens the spring-driven dark glass drawer. Its count rolls on the UI thread after React commits the new number. Higher digits move only during their carry, and each digit fades by distance so resting values stay crisp.
- Delete removes stable photo IDs and reflows the remaining cells with a layout spring. Selection, pressure, drawer, and layout springs share damping 28, stiffness 240, and mass 1 (damping ratio 0.904), with overshoot clamped. The other actions call the application's integration callback.
Install
npx expo install @shopify/react-native-skia expo-blur expo-image react-native-gesture-handler react-native-reanimated react-native-workletsCopy this folder without demo/. Wrap the application in GestureHandlerRootView. Load Skia's CanvasKit before rendering on web using the installed Skia web setup; this component needs a lazy Skia entry in the catalog. No font loading is required unless you provide custom fonts.
Usage
import DragSelectGrid from './drag-select-grid';
<DragSelectGrid photos={[
{ id: 'coast', source: require('./coast.jpg'), label: 'Coastal cliffs' },
]} />const grid = useRef<DragSelectGridRef>(null);
<DragSelectGrid
ref={grid}
photos={photos}
accentColor="#3B82F6"
waveDelay={25}
autoScrollSpeed={460}
bottomInset={bottomSafeAreaInset}
onSelectionChange={ids => saveSelection(ids)}
onAction={(action, ids) => handlePhotoAction(action, ids)}
/>Place the component inside a bounded flex container. Keep photo IDs unique and stable; remount when replacing the data set. Deletion is local to this instance; persist it through onAction if needed. reset() clears selection without restoring deleted photos.
Pass the bottom safe-area inset through bottomInset, or leave it at zero when the host already consumes that inset. The demo reserves a conservative 34 pt on native and zero on web, without adding a safe-area dependency. The measured drawer height and the inset determine the end padding; the status remains outside the scrolling grid so it stays readable while selection disables manual scrolling.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| photos | GridPhoto[] | Required | IDs, sources, accessible descriptions, optional video duration |
| title | string | Recents | Header title |
| fontFamily | string | System | Interface font |
| titleFontFamily | string | System | Editorial font |
| monoFontFamily | string | System | Count and duration font |
| accentColor | string | #3B82F6 | Selection tint and check color |
| backgroundColor | string | #0B0B10 | Screen background |
| waveDelay | number | 25 | Milliseconds per cell from the gesture origin |
| autoScrollSpeed | number | 460 | Maximum edge scroll speed in pt/s |
| bottomInset | number | 0 | Bottom safe-area space not already consumed by the host, in points |
| onSelectionChange | (ids: string[]) => void | — | Selection changes |
| onAction | (action: GridAction, ids: string[]) => void | — | Share, favorite, move, or delete integration |
| onPhotoPress | (photo: GridPhoto) => void | — | Open a photo while browsing |
| style | StyleProp<ViewStyle> | — | Root overrides |
| testID | string | — | Root identifier |
The ref exposes enterSelection(), reset(), beginSweep(index), sweepTo(index), and endSweep(). Indices refer to the current visible data after deletion.
Compatibility
Built for Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, and Skia 2.14.0. Uses Expo Go's installed modules. Supports iOS, Android, and web. Android blur uses one BlurTargetView and dimezisBlurViewSdk31Plus; older Android versions use the translucent fallback.
Accessibility
Photos expose checkbox roles, descriptions, checked state, and an activation action. Select/Cancel and toolbar actions have labeled targets of at least 44 pt. Tapping provides an alternative to sweeping. Reduced motion removes distance delays, bounce, pressure scaling, drawer translation, rolling movement, and layout animation; checkmarks use a short fade. Status feedback uses a polite live region.
Demo timeline
Orrin's camera roll uses twelve bundled, center-cropped landscapes. At 650 ms autoplay begins the first range; it reaches photo eight by 1.14 s and settles before 2 s. At 2.95 s a reverse sweep deselects photos six through eight. The loop restarts at 4.9 s. Autoplay timers are cleaned up on unmount.
Icons: Lucide (ISC). The demo's Orrin aperture mark and gradient come from the repository kit.
Implementation review
The 320×694 and 390×844 layouts use a full-width grid with 105.3 pt and 128.7 pt square cells, respectively. The header places Select/Cancel to the left of the 35.4 pt serif title; the brand row reserves 64 pt on the right for the viewer close control. The toolbar has four 48 pt action targets. Content ends with the measured toolbar height plus the bottom inset, its 18 pt offset, and a 12 pt gap. The persistent status sits above this reserved space, including during deletion and other actions. The scroll content also ends with the measured toolbar height plus the inset. This geometry keeps the status clear at both target sizes without changing the photo proportions. All interface text uses contrasting light colors, with video durations on dark badges.
Autoplay reaches eight selected photos by 1.14 s; the final delayed check settles before 2 s. The only photo overlay is the specified 12% selection tint, clipped inside the rounded photo; check halos use a Skia blur and the action surface uses real Expo blur. No opaque block supplies a lighting effect.




