About this animation
A photo-based placement preview with a physical product landing, with configurable room image, product image, accent color, and fonts.
Why this one
A coherent room photo replaces the need for ARKit or camera permissions. Center-out surface detection, a spring landing with a compressed contact shadow, and two perspective rings make placement feel tangible. Moving toward the window reduces the product's apparent size. Dimensions remain legible in a dark label above the product.
How it works
onLayoutmeasures the container; the room fills it with a local image.- One scan progress value reveals 35 Skia dots in a center-out wave and drives detection status.
place()releases a separate flight value into a spring with damping 12 and stiffness 160. The first contact triggers two rings and briefly compresses the blurred shadow.- Simultaneous pan, pinch, and rotation gestures manipulate continuous shared values. Depth derives from normalized Y; size stays between 0.6 and 1.4.
- Double tap or True size restores the original transform. Move, Size, and Rotate provide touch and screen-reader alternatives.
- Demo timers call the ref API: scan at 0.15 s, drop at 1.15 s, move at 2.6 s, return at 3.65 s, dimensions at 4.35 s, reset at 5.55 s, repeat after 6.6 s. Any touch stops the loop and interrupts the current animation.
Install
npx expo install expo-image expo-blur expo-linear-gradient react-native-reanimated react-native-worklets react-native-gesture-handler @shopify/react-native-skiaCopy this folder without demo/. Wrap the host app in GestureHandlerRootView. On web, initialize CanvasKit before importing the component with Skia's LoadSkiaWeb/WithSkiaWeb lazy loading pattern. The catalog must register this demo lazily; it must not import Skia before initialization. No native camera or AR permissions are required.
Usage
import { useEffect, useRef } from 'react';
import { ViewInRoomDrop, type ViewInRoomDropRef } from './view-in-room-drop';
function ProductPreview() {
const preview = useRef<ViewInRoomDropRef>(null);
useEffect(() => {
preview.current?.scan();
const timer = setTimeout(() => preview.current?.place(), 1000);
return () => clearTimeout(timer);
}, []);
return (
<ViewInRoomDrop
ref={preview}
roomSource={require('./room.jpg')}
productSource={require('./product.png')}
onAddToBag={() => { /* Connect to your cart. */ }}
/>
);
}Personalize the material and physics:
<ViewInRoomDrop
roomSource={room}
productSource={cutout}
title="Studio Camera"
price="$899"
dimensions="5.1 × 3.6 × 2.9 in"
widthLabel="5.1 in"
accentColor="#D8F3A5"
colorScheme="light"
springDamping={15}
springStiffness={180}
fontFamily="YourInterfaceFont"
titleFontFamily="YourHeadingFont"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
roomSource | ImageSourcePropType | Required | Local room photograph. |
productSource | ImageSourcePropType | Required | Transparent product cutout. |
title | string | Camera | Product name. |
price | string | $1,299 | Formatted price. |
dimensions | string | 5.1 × 3.6 × 2.9 in | Dimension summary. |
widthLabel | string | 5.1 in | Fixed real-world width annotation. |
brand | ReactNode | — | Optional brand content. |
accentColor | string | #D8F3A5 | Reticle, rings, annotations, and CTA color. |
fontFamily | string | System | Interface font. |
titleFontFamily | string | System | Heading font. |
monoFontFamily | string | System | Numeric font. |
colorScheme | light | dark | auto | dark | Product sheet appearance. |
springDamping | number | 41 | Landing damping. Values below the damping needed for ζ ≥ 0.8 are raised to that minimum. |
springStiffness | number | 160 | Landing stiffness. |
onStateChange | (state) => void | — | Detecting, found, and placed milestones. |
onInteraction | () => void | — | Touch notification for stopping autoplay. |
onAddToBag | () => void | — | Cart callback. |
onClose | () => void | — | Upper-left close callback. |
accessibilityLabel | string | Place product on the table | Manipulation surface label. |
style | StyleProp<ViewStyle> | — | Root style override. |
testID | string | — | Root test identifier. |
Ref methods: scan(), place(), move(x, y), resize(scale), rotate(radians), reset(). Movement is normalized to container size and bounded to the table. Place the product in a room photograph with a tabletop near 64% of the frame height. This is a visual simulation, not a calibrated AR measurement system. The width label describes the supplied product's real dimensions; depth changes its apparent size.
Compatibility
Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Skia 2.14.0. No extra dependencies or camera permissions.
Accessibility
Buttons expose roles and labels with at least 44 pt touch targets. The placement surface exposes adjustable size actions, a reset action, and gesture instructions. Important state changes and cart confirmation are announced; status text uses a polite live region. Reduced motion removes flight, rings, grid particles, depth scaling, spring travel, and all blur. Placement and annotation visibility fade over 200 ms; manual movement updates directly. Button feedback uses a separate shared value.
Icons: Lucide (ISC). The demo uses the Orrin kit tile and wordmark, a local room photograph, and a local transparent camera cutout. No assets or theme imports are required by the reusable component.
Implementation notes
- Legibility: status uses a dark glass pill; dimensions use a dark label above the product; product details use an opaque glass tint.
- Viewer close button: the header leaves the rightmost 66 pt empty. The piece's close button is on the left.
- Compact layouts: at 320×694 and 390×844, the product contact is anchored at 64% of height, the bottom sheet uses horizontal insets, and the four gesture alternatives fit in one row. Annotations sit above the product instead of between its contact point and the sheet.
- First three seconds: autoplay starts the scan at 0.15 s and the spring drop at 1.15 s, so impact occurs before 3 s.
- Effects: the contact shadow is a blurred Skia ellipse, the reticle and impact rings are Skia geometry, and glass uses actual BlurView sampling. No moving block is used as a shine or text overlay.
Spring damping floors use Reanimated 4.7.1’s default mass of 4 when the configuration does not set a mass. Stiffness and mass remain unchanged.




