Reachy_Mini / src /components /ReachiesCarousel.jsx
tfrere's picture
tfrere HF Staff
Initial commit: Reachy Mini Website
5c85958
raw
history blame
7.16 kB
import React, { useState, useEffect, useMemo } from 'react';
import { Box } from '@mui/material';
// Load all images from reachies/small-top-sided folder dynamically with Vite
const imageModules = import.meta.glob('../assets/reachies/small-top-sided/*.png', { eager: true });
/**
* Component that loads all PNG images from reachies/small-top-sided folder,
* stores them in memory and displays them in sequence with overlapping fade transition.
*
* Images are loaded dynamically and displayed one after another
* in a fixed frame, with a fade in/out transition between each image.
*/
export default function ReachiesCarousel({
width = 100,
height = 100,
interval = 1000, // Display duration of each image in ms (faster)
transitionDuration = 150, // Fade transition duration in ms (very sharp) - DEPRECATED, use fadeInDuration and fadeOutDuration
fadeInDuration = 350, // Fade-in duration for incoming image (slower, Apple/Google style)
fadeOutDuration = 120, // Fade-out duration for outgoing image (faster, Apple/Google style)
zoom = 1.8, // Zoom factor to enlarge the sticker
verticalAlign = 'center', // Vertical alignment: 'top', 'center', 'bottom', or percentage (e.g.: '60%')
darkMode = false,
sx = {}
}) {
// Extract URLs of loaded images and sort them for consistent order
const imagePaths = useMemo(() => {
const paths = Object.values(imageModules)
.map(module => {
// With eager: true, module is already loaded, access .default
return typeof module === 'object' && module !== null && 'default' in module
? module.default
: module;
})
.filter(Boolean) // Filter null/undefined values
.sort(); // Sort for consistent order
return paths;
}, []);
const [currentIndex, setCurrentIndex] = useState(0);
const [previousIndex, setPreviousIndex] = useState(null);
const [isTransitioning, setIsTransitioning] = useState(false);
const [fadeOutComplete, setFadeOutComplete] = useState(false);
// Preload all images in memory for smooth transitions
useEffect(() => {
imagePaths.forEach(imagePath => {
const img = new Image();
img.src = imagePath;
});
}, [imagePaths]);
// Function to get a random index different from current
const getRandomIndex = (currentIdx, total) => {
if (total <= 1) return 0;
let newIndex;
do {
newIndex = Math.floor(Math.random() * total);
} while (newIndex === currentIdx && total > 1);
return newIndex;
};
// Automatically change image with overlap and random selection
useEffect(() => {
if (imagePaths.length > 0) {
const timer = setInterval(() => {
// Save previous index BEFORE changing to guarantee crossfade
const prevIdx = currentIndex;
setPreviousIndex(prevIdx);
setIsTransitioning(true);
setFadeOutComplete(false); // Reset at start of transition
// Select a random image different from current
const newIndex = getRandomIndex(currentIndex, imagePaths.length);
setCurrentIndex(newIndex);
// Outgoing image starts disappearing after a delay to create more overlap
// Both images remain visible together longer
const overlapDelay = Math.min(fadeInDuration * 0.4, fadeOutDuration * 2); // 40% of fade-in or 2x fade-out
setTimeout(() => {
setFadeOutComplete(true);
}, overlapDelay);
// Reset transition state after longest duration (fade-in)
setTimeout(() => {
setIsTransitioning(false);
setPreviousIndex(null);
setFadeOutComplete(false);
}, Math.max(fadeInDuration, fadeOutDuration));
}, interval);
return () => clearInterval(timer);
}
}, [imagePaths.length, interval, currentIndex, fadeInDuration, fadeOutDuration]);
if (imagePaths.length === 0) {
return (
<Box
sx={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...sx,
}}
/>
);
}
return (
<Box
sx={{
position: 'relative',
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // Prevent zoom overflow
...sx,
}}
>
{imagePaths.map((imageSrc, index) => {
const isActive = index === currentIndex;
const isPrevious = index === previousIndex && isTransitioning;
// Calculate vertical position according to alignment
let topValue, transformY;
if (verticalAlign === 'top') {
topValue = 0;
transformY = '0';
} else if (verticalAlign === 'bottom') {
topValue = '100%';
transformY = '-100%';
} else if (typeof verticalAlign === 'string' && verticalAlign.includes('%')) {
// Custom percentage
topValue = verticalAlign;
transformY = '-50%';
} else {
// Default: center
topValue = '50%';
transformY = '-50%';
}
// Crossfade style Apple/Google: outgoing disappears faster than incoming appears
const baseOpacity = darkMode ? 0.8 : 0.9;
let opacity = 0;
let transitionStyle = 'none';
// Crossfade logic: both images must be visible simultaneously
if (isActive) {
// Incoming image: slow and progressive fade-in (premium style)
opacity = baseOpacity;
transitionStyle = `opacity ${fadeInDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`; // Smooth ease-out
} else if (isPrevious) {
// Outgoing image: fast fade-out (disappears quickly to make room)
// Starts visible, then disappears after fadeOutDuration
opacity = fadeOutComplete ? 0 : baseOpacity;
transitionStyle = `opacity ${fadeOutDuration}ms cubic-bezier(0.4, 0, 1, 1)`; // More aggressive ease-out
}
// Otherwise opacity stays at 0 (invisible)
return (
<Box
key={`${imageSrc}-${index}`} // Unique key to force re-render
component="img"
src={imageSrc}
alt={`Reachy ${index + 1}`}
sx={{
position: 'absolute',
width: width * zoom,
height: height * zoom,
objectFit: 'cover',
objectPosition: 'center top', // Align top of image to top
opacity,
transform: `translate(-50%, ${transformY})`, // No scale
transition: transitionStyle,
pointerEvents: 'none',
// Position zoomed image with custom vertical alignment
left: '50%',
top: topValue,
zIndex: isActive ? 2 : (isPrevious ? 1 : 0), // Active image on top
willChange: 'opacity', // GPU optimization
backfaceVisibility: 'hidden', // Avoid rendering artifacts
WebkitBackfaceVisibility: 'hidden',
}}
/>
);
})}
</Box>
);
}