About this animation
A simulated video scrubber with panorama-based previews and configurable image, labels, accent color, and fonts. It uses a virtual clock and does not include a video player.
Why this one
A timeline usually shows a position without showing what is there. This one previews the same temporal crop in the large frame, ten thumbnails, and the floating loupe. A traveling crop turns one local panorama into a coherent simulated clip. Lowering the finger progressively reduces the gain, making an exact frame easier to find without playhead jumps.
How it works
- One UI-thread
timevalue drives the moving image crop, playhead, and preview position. Playback advances from the frame clock, with no React updates per frame. - The panorama is enlarged to 140% of each 16:9 viewport height. Time determines horizontal crop position. Ten strip cells show fixed temporal crops of the same image.
- An exclusive Pan/Tap gesture handles both drag and tap on the 64-point timeline hit area.
- Horizontal deltas are integrated with vertical gain. The 60- and 120-point thresholds blend across 40-point bands, from 1 to 0.5 to 0.25. Each sample re-anchors the horizontal origin.
- The loupe enters from 0.92 scale, follows with a spring, and stays within the timeline edges. Release uses decay 0.99, with velocity capped at 12 seconds per second.
- Time labels and accessibility values update only at second boundaries. Callbacks run on JS through
scheduleOnRN.
Install
npx expo install @shopify/react-native-skia expo-blur react-native-gesture-handler react-native-reanimated react-native-workletsWrap the application in GestureHandlerRootView. Follow the installed Skia version's CanvasKit setup for web and load Skia before importing this component (the catalog uses withSkia). The panorama is caller-supplied; the component never imports demo assets or theme files.
Usage
import { VideoScrubPreview } from './video-scrub-preview';
<View style={{ flex: 1 }}>
<VideoScrubPreview source={require('./assets/panorama.jpg')} />
</View>const player = useRef<VideoScrubPreviewRef>(null);
<VideoScrubPreview
ref={player}
source={require('./assets/panorama.jpg')}
duration={60}
initialTime={10}
detailColor="#A3E635"
springDamping={30}
colorScheme="auto"
onTimeChange={savePosition}
onTrim={openTrimEditor}
/>;
// Event handlers can call player.current?.seek(20) or setPlaying(true).Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
labels | Partial<VideoScrubPreviewLabels> | American English defaults | playing: Playing; preview: Preview; trim: Trim; play: Play video; pause: Pause video; trimAccessibility: Trim clip; videoBadge: 16:9 · ORIGINAL. Omitted keys retain their defaults. |
source | DataSourceParam | Required | Local panoramic image |
duration | number | 42 | Clip length in seconds, minimum 1 |
initialTime | number | 14 | Initial time, clamped to clip bounds |
fontFamily | string | System | Interface font |
monoFontFamily | string | System | Timecode font |
accentColor | string | #F4F4F8 | Playhead and handles |
detailColor | string | #FACC15 | Precision indicator |
colorScheme | 'dark' | 'light' | 'auto' | 'dark' | Material palette |
springDamping | number | 28 | Spring damping. Values below the damping needed for ζ ≥ 0.8 are raised to that minimum. |
onTimeChange | (seconds: number) => void | — | Whole-second changes and scrub completion |
onPlayingChange | (playing: boolean) => void | — | Playback state changes |
onTrim | () => void | — | Trim action; disabled when absent |
accessibilityLabel | string | Video timeline | Adjustable timeline label |
style | StyleProp<ViewStyle> | — | Root container styling |
testID | string | — | Root test identifier |
Ref methods: seek(seconds), setPlaying(boolean), beginScrub(), scrubBy(deltaX, depth), endScrub(). Programmatic scrub methods let a host build guided demonstrations without adding autoplay behavior to the component. Playback pauses when dragging begins and stays paused after release. Tapping seeks while preserving playback state. The speed readout is a fixed 1× indicator, not a speed selector. The demo's Trim button marks/clears a marker; the component forwards this action to the host.
Compatibility
Targets 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. These modules are included in the matching Expo Go release. SDK 58 Android blur uses BlurTargetView and dimezisBlurViewSdk31Plus; older Android versions use the tint fallback.
Accessibility
The timeline is adjustable, reports “14 seconds of 42,” and exposes one-second increment/decrement actions. Tapping the strip provides an alternative to dragging. Play and trim controls have button roles and labels, and hit areas are at least 44 points. With reduced motion, seeking and loupe position are immediate; entrance scaling and inertia are removed, and autoplay is disabled. Playback remains available when requested by the user.
Demo storyboard
At 300 ms the loupe lifts; 350–1100 ms sweeps right; 1100–1900 ms lowers the virtual finger to 150 points while the playhead slows. At 1950 ms the loupe closes, then playback starts at 2200 ms. The sequence repeats every five seconds. Layout is measured through onLayout: preview at 0.08H with 0.94W, strip at 0.56H, transport at 0.74H, editorial caption below 0.86H. The open upper-right corner is reserved for the catalog viewer's close button.
Icons: Lucide (ISC). Demo brand: Orrin, from the internal brand kit. The simulated footage is a bundled local panorama, not a video decoder.
Connecting real video (host adapter)
Install expo-video in the host app with npx expo install expo-video; it is not a dependency of this piece. The Expo SDK 58 video API provides useVideoPlayer and VideoView. This one-way example forwards simulated timeline requests to a separate player:
import { View } from 'react-native';
import { useVideoPlayer, VideoView } from 'expo-video';
import { VideoScrubPreview } from './video-scrub-preview';
export function VideoPreview() {
const player = useVideoPlayer(require('./assets/clip.mp4'));
return (
<View style={{ flex: 1 }}>
<VideoView player={player} style={{ height: 180 }} nativeControls={false} />
<VideoScrubPreview
source={require('./assets/panorama.jpg')}
duration={42}
initialTime={0}
onTimeChange={(seconds) => { player.currentTime = seconds; }}
onPlayingChange={(playing) => {
if (playing) player.play();
else player.pause();
}}
/>
</View>
);
}Use the actual clip length instead of 42. This adapter demonstrates seek/play/pause wiring, not a synchronized production player: the component still uses its own clock and image crops, and time callbacks are coarse (second boundaries and scrub completion). For a fully connected version, replace the virtual clock with player time, subscribe to timeUpdate (set timeUpdateEventInterval, for example 0.25 seconds), playingChange, sourceLoad, statusChange, and playToEnd, and handle buffering, errors, duration, and completion in the host. Keep player observations separate from seek requests to avoid feedback loops; throttle drag seeks. Genuine video thumbnails must also be supplied or decoded by the host.




