Skip to main content

Product Collection

product-collection.tsx
import {loadFont} from '@remotion/google-fonts/Inter'; import React, {forwardRef, useImperativeHandle, useRef} from 'react'; import { Easing, Img, Interactive, Sequence, interpolate, useCurrentFrame, useVideoConfig, type InteractiveBaseProps, type InteractivitySchema, type SequenceControls, } from 'remotion'; loadFont('normal', { subsets: ['latin'], weights: ['500', '600', '700'], }); type ProductCardProps = InteractiveBaseProps & { readonly count: number; readonly discount: string; readonly image: string; readonly imageBackgroundColor: string; readonly imageFit: 'contain' | 'cover'; readonly index: number; readonly originalPrice: string; readonly price: string; readonly title: string; }; const productCardSchema = { ...Interactive.baseSchema, title: { type: 'text-content', default: 'Product title', description: 'Product title', }, image: { type: 'asset', default: 'https://remotion.media/elements/product-discount-callout-gray-runner.png', description: 'Product image', }, imageFit: { type: 'enum', default: 'cover', description: 'Image fit', variants: { contain: {}, cover: {}, }, }, imageBackgroundColor: { type: 'color', default: '#dbe1e9', description: 'Image background', }, price: { type: 'text-content', default: '$100', description: 'Current price', }, originalPrice: { type: 'text-content', default: '', description: 'Original price (leave empty to hide)', }, discount: { type: 'text-content', default: '', description: 'Promotion (leave empty to hide)', }, count: {type: 'hidden'}, index: {type: 'hidden'}, } as const satisfies InteractivitySchema; const ProductCardInner = forwardRef< HTMLDivElement, ProductCardProps & {readonly controls: SequenceControls | undefined} >( ( { controls, count, discount, image, imageBackgroundColor, imageFit, index, name, originalPrice, price, title, ...sequenceProps }, ref, ) => { const outlineRef = useRef<HTMLDivElement>(null); 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', }, ); useImperativeHandle(ref, () => outlineRef.current as HTMLDivElement, []); return ( <Sequence layout="none" {...sequenceProps} controls={controls} name={name ?? '<ProductCard>'} outlineRef={outlineRef} > <div ref={outlineRef} 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), }} > <div style={{ backgroundColor: '#ffffff', boxSizing: 'border-box', color: '#1d1d19', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', width: '100%', }} > <div style={{ backgroundColor: imageBackgroundColor, height: 335, overflow: 'hidden', position: 'relative', width: '100%', }} > <Img alt={title} name="Product image" showInTimeline={false} src={image} style={{ height: '100%', objectFit: 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', }} /> {discount.trim() === '' ? null : ( <div 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', }} > {discount} </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, }} > <h3 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', }} > {title} </h3> <div style={{ alignItems: 'baseline', display: 'flex', fontVariantNumeric: 'tabular-nums', gap: 10, height: 48, marginTop: 'auto', minWidth: 0, }} > <div style={{ fontSize: 48, fontWeight: 700, letterSpacing: -2.3, lineHeight: 1, maxWidth: 148, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > {price} </div> {originalPrice.trim() === '' ? null : ( <div style={{ color: '#7b776e', fontSize: 22, fontWeight: 500, maxWidth: 74, overflow: 'hidden', textDecoration: 'line-through', textDecorationThickness: 1.5, textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > {originalPrice} </div> )} </div> </div> </div> </div> </Sequence> ); }, ); const ProductCard = Interactive.withSchema({ Component: ProductCardInner, componentName: '<ProductCard>', componentIdentity: null, schema: productCardSchema, supportsEffects: false, }) as React.FC<ProductCardProps>; 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> <ProductCard count={5} discount="20% off" image="https://remotion.media/elements/product-discount-callout-gray-runner.png" imageBackgroundColor="#dbe1e9" imageFit="contain" index={0} name="Cloudline Runner card" originalPrice="$148" price="$118" title="Cloudline Runner" /> <ProductCard count={5} discount="" image="https://images.unsplash.com/photo-1523275335684-37898b6baf30?fm=jpg&fit=crop&w=900&q=90" imageBackgroundColor="#deded8" imageFit="contain" index={1} name="Minimal Steel Watch card" originalPrice="" price="$185" title="Minimal Steel Watch" /> <ProductCard count={5} discount="Save $31" image="https://images.unsplash.com/photo-1511499767150-a48a237f0083?fm=jpg&fit=crop&w=900&q=90" imageBackgroundColor="#c8d8e6" imageFit="cover" index={2} name="Studio Sunglasses card" originalPrice="$125" price="$94" title="Studio Sunglasses" /> <ProductCard count={5} discount="New" image="https://images.unsplash.com/photo-1505740420928-5e560c06d30e?fm=jpg&fit=crop&w=900&q=90" imageBackgroundColor="#f1c647" imageFit="cover" index={3} name="Studio Headset card" originalPrice="" price="$179" title="Studio Headset" /> <ProductCard count={5} discount="" image="https://images.unsplash.com/photo-1507473885765-e6ed057f782c?fm=jpg&fit=crop&w=900&q=90" imageBackgroundColor="#e5b17a" imageFit="cover" index={4} name="Sculptural Table Lamp card" originalPrice="" price="$149" title="Sculptural Table Lamp" /> </Interactive.Div> ); };