Skip to main content

Product Collection

product-collection.tsx
import {loadFont} from '@remotion/google-fonts/Inter'; import React from 'react'; import { Easing, Img, Interactive, interpolate, useCurrentFrame, useVideoConfig, } from 'remotion'; loadFont('normal', { subsets: ['latin'], weights: ['500', '600', '700'], }); type Product = { readonly title: string; readonly image: string; readonly imageFit: 'contain' | 'cover'; readonly imageBackgroundColor: string; readonly price: string; readonly originalPrice: string | null; readonly discount: string | null; }; const products: readonly Product[] = [ { title: 'Cloudline Runner', image: 'https://remotion.media/elements/product-discount-callout-gray-runner.png', imageFit: 'contain', imageBackgroundColor: '#dbe1e9', price: '$118', originalPrice: '$148', discount: '20% off', }, { title: 'Minimal Steel Watch', image: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?fm=jpg&fit=crop&w=900&q=90', imageFit: 'contain', imageBackgroundColor: '#deded8', price: '$185', originalPrice: null, discount: null, }, { title: 'Studio Sunglasses', image: 'https://images.unsplash.com/photo-1511499767150-a48a237f0083?fm=jpg&fit=crop&w=900&q=90', imageFit: 'cover', imageBackgroundColor: '#c8d8e6', price: '$94', originalPrice: '$125', discount: 'Save $31', }, { title: 'Studio Headset', image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?fm=jpg&fit=crop&w=900&q=90', imageFit: 'cover', imageBackgroundColor: '#f1c647', price: '$179', originalPrice: null, discount: 'New', }, { title: 'Sculptural Table Lamp', image: 'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?fm=jpg&fit=crop&w=900&q=90', imageFit: 'cover', imageBackgroundColor: '#e5b17a', price: '$149', originalPrice: null, discount: null, }, ]; const ProductCard = ({ product, index, count, }: { readonly product: Product; readonly index: number; readonly count: number; }) => { const frame = useCurrentFrame(); const {durationInFrames} = useVideoConfig(); const lastProductIndex = Math.max(0, count - 1); const rawScrollPosition = interpolate( frame, [24, durationInFrames - 28], [0, lastProductIndex], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ); const transitionIndex = Math.min( Math.floor(rawScrollPosition), Math.max(0, lastProductIndex - 1), ); const transitionProgress = interpolate( rawScrollPosition - transitionIndex, [0.18, 0.82], [0, 1], { easing: Easing.inOut(Easing.cubic), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ); const scrollPosition = transitionIndex + transitionProgress; const unwrappedSlotPosition = index - scrollPosition; const slotPosition = count <= 2 ? unwrappedSlotPosition : ((((unwrappedSlotPosition + count / 2) % count) + count) % count) - count / 2; const distanceFromCenter = Math.abs(slotPosition); const visibility = interpolate(distanceFromCenter, [1.02, 1.18], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); const entryStart = 8 + Math.min(index, 1) * 5; const entryProgress = interpolate( frame, [entryStart, entryStart + 18], [0, 1], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ); const x = slotPosition * 270; const y = Math.min(distanceFromCenter, 1.3) * 36 + (1 - entryProgress) * 180; const rotation = slotPosition * 5.5; const cardScale = interpolate(distanceFromCenter, [0, 1.5], [1.02, 0.84], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }); const obscuredSidePadding = interpolate( distanceFromCenter, [0.5, 1], [22, 48], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ); return ( <div style={{ height: 560, left: 360, top: 265, opacity: visibility * entryProgress, position: 'absolute', rotate: `${rotation}deg`, scale: cardScale * (0.86 + entryProgress * 0.14), transform: 'perspective(100px)', translate: `${x}px ${y}px`, width: 300, willChange: 'transform, opacity', zIndex: 100 - Math.round(distanceFromCenter * 20), }} > <Interactive.Div name="Product card" style={{ backgroundColor: '#ffffff', boxSizing: 'border-box', color: '#1d1d19', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', width: '100%', }} > <div style={{ backgroundColor: product.imageBackgroundColor, height: 335, overflow: 'hidden', position: 'relative', width: '100%', }} > <Img alt={product.title} name="Product image" src={product.image} style={{ height: '100%', objectFit: product.imageFit, objectPosition: '50% 50%', scale: interpolate( frame, [0, durationInFrames - 1], [1.06, 1.01], { easing: Easing.inOut(Easing.quad), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ), transform: 'perspective(100px)', width: '100%', willChange: 'transform', }} /> {product.discount === null ? null : ( <Interactive.Div name="Promotion" style={{ backgroundColor: '#1d1d19', color: '#f7c900', fontSize: 20, fontWeight: 700, letterSpacing: -0.3, lineHeight: 1, maxWidth: 132, overflow: 'hidden', padding: '10px 12px', position: 'absolute', right: 0, textOverflow: 'ellipsis', textTransform: 'uppercase', top: 0, whiteSpace: 'nowrap', }} > {product.discount} </Interactive.Div> )} </div> <div style={{ boxSizing: 'border-box', display: 'flex', flex: 1, flexDirection: 'column', paddingBottom: 24, paddingLeft: slotPosition > 0 ? obscuredSidePadding : 22, paddingRight: slotPosition < 0 ? obscuredSidePadding : 22, paddingTop: 22, }} > <Interactive.H3 name="Product title" style={{ WebkitBoxOrient: 'vertical', WebkitLineClamp: 3, display: '-webkit-box', fontSize: 32, fontWeight: 700, height: 98, letterSpacing: -1.4, lineHeight: 0.98, margin: 0, overflow: 'hidden', overflowWrap: 'anywhere', textTransform: 'uppercase', textWrap: 'balance', }} > {product.title} </Interactive.H3> <div style={{ alignItems: 'baseline', display: 'flex', fontVariantNumeric: 'tabular-nums', gap: 10, height: 48, marginTop: 'auto', minWidth: 0, }} > <Interactive.Div name="Current price" style={{ fontSize: 48, fontWeight: 700, letterSpacing: -2.3, lineHeight: 1, maxWidth: 148, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > {product.price} </Interactive.Div> {product.originalPrice === null ? null : ( <Interactive.Div name="Original price" style={{ color: '#7b776e', fontSize: 22, fontWeight: 500, maxWidth: 74, overflow: 'hidden', textDecoration: 'line-through', textDecorationThickness: 1.5, textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > {product.originalPrice} </Interactive.Div> )} </div> </div> </Interactive.Div> </div> ); }; export const ProductCollection = () => { const frame = useCurrentFrame(); const {durationInFrames} = useVideoConfig(); return ( <Interactive.Div name="Container" style={{ WebkitFontSmoothing: 'antialiased', backgroundColor: '#f2f3f4', boxSizing: 'border-box', color: '#1d1d19', fontFamily: 'Inter', height: '100%', isolation: 'isolate', opacity: interpolate( frame, [0, 10, durationInFrames - 8, durationInFrames - 1], [0, 1, 1, 0], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ), overflow: 'hidden', position: 'relative', scale: interpolate( frame, [0, 16, durationInFrames - 8, durationInFrames - 1], [0.97, 1, 1, 0.98], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', output: 'perceptual-scale', }, ), transform: 'perspective(100px)', translate: interpolate( frame, [0, 16, durationInFrames - 8, durationInFrames - 1], ['0px 30px', '0px 0px', '0px 0px', '0px -20px'], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ), width: '100%', willChange: 'transform, opacity', }} > <div style={{ left: 0, position: 'absolute', right: 0, top: 112, zIndex: 300, }} > <Interactive.H2 name="Collection title" style={{ fontSize: 80, fontWeight: 700, letterSpacing: -4.6, lineHeight: 0.92, margin: 0, opacity: interpolate(frame, [4, 22], [0, 1], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }), textAlign: 'center', textTransform: 'uppercase', textWrap: 'balance', transform: 'perspective(100px)', translate: interpolate(frame, [4, 22], ['0px 24px', '0px 0px'], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }), willChange: 'transform, opacity', }} > Featured Products </Interactive.H2> </div> {products.map((product, index) => ( <ProductCard key={product.title} count={products.length} index={index} product={product} /> ))} </Interactive.Div> ); };