Benji Riethmeier

Netflix Screen Saver

Source Blog Post

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>&bull;</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;
    }
}