Skip to main content

Rotating Cards

product-collection.tsx
import {loadFont} from '@remotion/google-fonts/Inter'; import React, {forwardRef, useImperativeHandle, useRef} from 'react'; import { Easing, Img, Interactive, Sequence, interpolate, useCurrentFrame, type InteractiveBaseProps, type InteractiveTransformProps, type InteractivitySchema, type SequenceControls, } from 'remotion'; loadFont('normal', { subsets: ['latin'], weights: ['500', '600', '700'], }); export const productCollectionDurationInFrames = 150; type ProductCardProps = InteractiveBaseProps & Omit<InteractiveTransformProps, 'style'> & { readonly count: number; readonly index: number; readonly label: string; readonly style: React.CSSProperties | null; }; const productCardSchema = { ...Interactive.baseSchema, label: { type: 'text-content', default: 'A', description: 'Card label', }, count: {type: 'hidden'}, index: {type: 'hidden'}, ...Interactive.transformSchema, } as const satisfies InteractivitySchema; const ProductCardInner = forwardRef< HTMLDivElement, ProductCardProps & {readonly controls: SequenceControls | undefined} >(({controls, count, index, label, name, style, ...sequenceProps}, ref) => { const outlineRef = useRef<HTMLDivElement>(null); const frame = useCurrentFrame(); const lastProductIndex = Math.max(0, count - 1); const rawScrollPosition = interpolate( frame, [24, productCollectionDurationInFrames - 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', }); useImperativeHandle(ref, () => outlineRef.current as HTMLDivElement, []); return ( <Sequence layout="none" {...sequenceProps} controls={controls} name={name ?? '<ProductCard>'} outlineRef={outlineRef} > <div style={{ height: 560, left: 300, top: 40, 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 ref={outlineRef} style={{ ...style, backgroundColor: '#ffffff', borderRadius: 6, boxShadow: '0 2px 6px rgba(29, 29, 25, 0.12)', boxSizing: 'border-box', color: '#1d1d19', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', width: '100%', }} > <Interactive.Div name="Card label" style={{ alignItems: 'center', color: '#ffffff', display: 'flex', fontFamily: 'sans-serif', fontSize: 160, fontWeight: 900, height: '100%', justifyContent: 'center', letterSpacing: -8, overflow: 'hidden', position: 'relative', textShadow: '0 4px 30px rgba(0, 0, 0, 0.55)', }} > <Img alt="" name="Card background" showInTimeline={false} src={ index === 1 ? 'https://remotion.media/transition-bg-pink.jpg' : 'https://remotion.media/transition-bg-blue.jpg' } style={{ filter: index === 2 ? 'hue-rotate(-65deg)' : 'none', height: '100%', objectFit: 'cover', position: 'absolute', width: '100%', }} /> <div style={{position: 'relative'}}>{label}</div> </Interactive.Div> </div> </div> </Sequence> ); }); const ProductCard = Interactive.withSchema({ Component: ProductCardInner, componentName: '<ProductCard>', schema: productCardSchema, supportsEffects: false, }) as React.FC<ProductCardProps>; export const ProductCollection = () => { const frame = useCurrentFrame(); return ( <Interactive.Div name="Container" style={{ WebkitFontSmoothing: 'antialiased', boxSizing: 'border-box', color: '#1d1d19', fontFamily: 'Inter', height: 660, isolation: 'isolate', left: 60, opacity: interpolate( frame, [ 0, 10, productCollectionDurationInFrames - 8, productCollectionDurationInFrames - 1, ], [0, 1, 1, 0], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ), overflow: 'hidden', position: 'absolute', scale: interpolate( frame, [ 0, 16, productCollectionDurationInFrames - 8, productCollectionDurationInFrames - 1, ], [0.97, 1, 1, 0.98], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', output: 'perceptual-scale', }, ), top: 180, transform: 'perspective(100px)', translate: interpolate( frame, [ 0, 16, productCollectionDurationInFrames - 8, productCollectionDurationInFrames - 1, ], ['0px 30px', '0px 0px', '0px 0px', '0px -20px'], { easing: Easing.bezier(0.16, 1, 0.3, 1), extrapolateLeft: 'clamp', extrapolateRight: 'clamp', }, ), width: 900, willChange: 'transform, opacity', }} > <ProductCard count={3} index={0} label="A" name="Card A" style={{translate: '0px 0px'}} /> <ProductCard count={3} index={1} label="B" name="Card B" style={{translate: '0px 0px'}} /> <ProductCard count={3} index={2} label="C" name="Card C" style={{translate: '0px 0px'}} /> </Interactive.Div> ); };