About this animation
A three-bottle bundle builder with product flights into a box and configurable bottle data, labels, accent color, and fonts.
Why this one
Dashed slots become solid as they fill, and a synchronized lid, drawn bow, rolling price, and savings seal make the discount feel earned. The demo uses five coherent local product cutouts and the Tidewell brand from the catalog kit.
How it works
onLayoutmeasures the container. The box sits at 12–44% of its height, the horizontal shelf at 48–72%, and the summary and action at 76–96%.- Three stable slots reserve products immediately. Tap a shelf card or its 44-point plus area to add to the first empty slot; duplicate variants are ignored.
- A vertical pan previews the same bottle outside the scroll clipping region. A release inside the box selects it; a release outside springs it back. Horizontal movement remains available to the shelf.
- Each bottle has a continuous flight progress. Its horizontal motion is linear, its vertical motion follows an arc, and its rotation peaks at 8 degrees before a short
{ damping: 58, stiffness: 320 }(effective mass 4) spring seats it. - One closure progress drives the perspective lid, bottle visibility, bow path trim, price transition, savings seal, original-price strike, and fine sparkles. It starts after the final flight and lasts 500 ms.
- Tap an open-box bottle to remove it. Tap the closed box to remove the last occupied slot and reopen it.
reset()empties the box;onAddToBaghands the three selected products to your checkout.
Install
npx expo install @shopify/react-native-skia expo-image react-native-gesture-handler react-native-reanimated react-native-workletsUse GestureHandlerRootView at the app root and the Reanimated/worklets configuration appropriate to Expo SDK 58. For web, load CanvasKit before importing this component, using Skia's WithSkiaWeb or your existing deferred Skia loader.
Icons: Lucide (ISC). The copied paths live in icons.ts.
Usage
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import BundleBuilderBox from './bundle-builder-box';
const bottles = [
{ id: 'ink', name: 'Ink', price: 58, image: require('./ink.png') },
{ id: 'sage', name: 'Sage', price: 58, image: require('./sage.png') },
{ id: 'clay', name: 'Clay', price: 58, image: require('./clay.png') },
];
export function BundleScreen() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BundleBuilderBox bottles={bottles} onAddToBag={addBundleToCart} />
</GestureHandlerRootView>
);
}Customization and external control:
const builder = useRef<BundleBuilderBoxRef>(null);
<BundleBuilderBox
ref={builder}
bottles={bottles}
bundlePrice={142}
accentColor="#0F766E"
kraftColor="#D6C3A5"
colorScheme="light"
fontFamily="YourInterfaceFont"
monoFontFamily="YourNumberFont"
flightDuration={320}
onSelectionChange={updateSelection}
onAddToBag={addBundleToCart}
/>;
builder.current?.add('sage');
builder.current?.remove('sage');
builder.current?.reset();Props
| Prop | Type | Default | Description |
|---|---|---|---|
bottles | BundleBottle[] | Required | Stable ID, variant name, image source, and dollar price. At least three unique products are recommended. |
bundlePrice | number | 142 | Complete bundle price. |
buttonLabel | string | Add trio to bag | Checkout button text and accessibility label. |
shelfLabel | string | Choose your colors | Shelf instruction. |
bundleLabel | string | Trio | Summary bundle name. |
savingsLabel | string | Save 18% | Circular seal copy; customize with your price. |
accentColor | string | #0F766E | Active slots, ribbon, seal, and button. |
kraftColor | string | #D6C3A5 | Box material. |
fontFamily | string | System | Interface font. |
monoFontFamily | string | System | Slot numerals and tabular price font. |
colorScheme | light | dark | auto | auto | Surface and text appearance. Set the enclosing screen background to match. |
flightDuration | number | 320 | Flight duration in milliseconds before seating. |
onSelectionChange | (bottles: BundleBottle[]) => void | — | Selected products in slot order. |
onAddToBag | (bottles: BundleBottle[]) => void | — | Complete bundle checkout callback. |
style | StyleProp<ViewStyle> | — | Root style; the root defaults to flex: 1. |
testID | string | — | Root test identifier. |
The ref exposes add(id), remove(id), and reset(). Autoplay is confined to the demo: additions at 1.0, 1.5, and 2.0 seconds; completion by 2.9 seconds; reset at 6 seconds; a new color combination every 7.4 seconds.
Compatibility
Expo SDK 58; React Native 0.88.0; Reanimated 4.7.1; worklets 0.13.0; Skia 2.14.0. No new native dependency is added to this project.
Accessibility
All product cards offer button roles, variant and price labels, selected states, and screen-reader activation actions. Selected bottles offer removal actions. The closed box offers a labeled reopen/remove action; checkout is disabled until three slots are occupied. The selection count is a polite live region and the total has a dollar amount label.
Reduced motion replaces bottle flights with a 180 ms slot fade, disables drag in favor of tap, removes the bow and sparkles, removes rotation and bounce, and uses short fades for completion. Gesture taps use Gesture Handler rather than nested React Native press handlers; checkout pressure uses its own shared value.




