React Component (Typescript/JSX)
interface NetflixIdleScreenProps {
screens: {
backgroundImage: string;
title: string;
attributes: string[];
author: {
name: string;
url: string;
}
}[]
}
const NetflixIdleScreen = ({ screens }: NetflixIdleScreenProps) => {
const [currentScreen, setCurrentScreen] = useState(0);
const [loadingFirstImage, setLoadingFirstImage] = useState(true);
const [hasDoneOneLoop, setHasDoneOneLoop] = useState(false);
const preloadImage = (src: string) =>
new Promise((resolve, reject) => {
const image = new Image();
image.onload = resolve;
image.onerror = reject;
image.src = src;
});
useEffect(() => {
(async () => {
const imagesToPreload = screens.slice(0, 2);
await Promise.all(
imagesToPreload.map((screen) => preloadImage(screen.backgroundImage))
);
setLoadingFirstImage(false);
})();
}, []);
const handleNextAnimationIteration = async (event: AnimationEvent) => {
if (!event.animationName.includes('moveBackgroundImageFromLeftToRightWithFade')) {
return;
}
if (currentScreen === screens.length - 1) {
setHasDoneOneLoop(true);
} else if (!hasDoneOneLoop) {
preloadImage(screens[(currentScreen + 2) % screens.length].backgroundImage);
}
setCurrentScreen((prev) => (prev + 1) % screens.length);
}
return (
<div className={styles.parent}>
<div
className={styles.background}
onAnimationIteration={handleNextAnimationIteration}
style={{
backgroundImage: `url(${screens[currentScreen].backgroundImage})`,
animationPlayState: loadingFirstImage ? 'paused' : 'running'
}}
>
<div
className={styles.content}
style={{ animationPlayState: loadingFirstImage ? 'paused' : 'running' }}
>
<h1 className={styles.title}>{screens[currentScreen].title}</h1>
<ul className={styles.attributes}>
{screens[currentScreen].attributes.map((attribute, index, array) => (
<React.Fragment key={`${attribute}`}>
<li>{attribute}</li>
{index + 1 !== array.length && <li>•</li>}
</React.Fragment>
))}
</ul>
<p className={styles.attribution}>
Photo by: <a href={screens[currentScreen].author.url} className={styles.attributionLink}>{screens[currentScreen].author.name}</a>
</p>
</div>
<div className={styles.gradient}></div>
</div>
</div>
);
}
Styles (CSS Modules)
.parent {
width: 100%;
height: 100%;
background-color: black;
container-type: inline-size;
--animation-length: 15s;
}
.background {
background-color: black;
background-repeat: no-repeat;
background-size: 120%;
width: 100%;
height: 100%;
overflow: hidden;
animation: var(--animation-length) linear infinite alternate moveBackgroundImageFromLeftToRightWithFade;
animation-fill-mode: forwards;
position: relative;
}
.content {
position: absolute;
left: 5%;
bottom: 5%;
z-index: 2;
animation: var(--animation-length) linear infinite alternate contentFadeInOut;
}
.gradient {
background: linear-gradient(to top, black, transparent);
width: 100%;
height: 50%;
position: absolute;
bottom: 0;
}
.title {
font-size: 40px;
color: white;
font-family: sans-serif;
margin: 0;
}
.attributes {
list-style: none;
color: white;
font-family: sans-serif;
margin: 0;
display: flex;
gap: 10px;
padding: 0;
margin-top: 5px;
}
.attribution {
color: white;
font-family: sans-serif;
margin-top: 5px;
}
.attributionLink:visited, .attributionLink:link, .attributionLink:active {
color: white;
}
@container (width > 600px) {
.title {
font-size: 55px;
}
.gradient {
height: 45%;
}
}
@container (width > 1000px) {
.title {
font-size: 70px;
}
.attributes, .attribution {
font-size: 20px;
}
.gradient {
height: 40%;
}
}
@container (width > 1600px) {
.title {
font-size: 80px;
}
.attributes, .attribution {
font-size: 25px;
}
}
@keyframes moveBackgroundImageFromLeftToRightWithFade {
from {
opacity: 0;
background-position: left;
}
15% {
opacity: 1;
}
85% {
opacity: 1;
}
to {
background-position: right;
opacity: 0;
}
}
@keyframes contentFadeInOut {
from {
opacity: 0;
}
20% {
opacity: 0;
}
25% {
opacity: 100;
}
75% {
opacity: 100;
}
80% {
opacity: 0
}
to {
opacity: 0;
}
}