Dirck Mulder
Blocks||4 min read

Build a MacBook Scroll Animation in React

Create a scroll-driven animation where a MacBook lid opens and reveals a screenshot as the user scrolls down the page.

Product screenshots are essential but often boring. A static image on a white background does not inspire confidence. MacbookScroll turns your screenshot into a reveal moment, letting the user scroll into the product experience rather than just looking at a picture of it.

The final result

What we are building

A scroll-driven 3D MacBook model built from plain HTML divs and CSS perspective transforms. As the user scrolls through the component's scroll container, the lid opens from -28 degrees to fully flat. The screenshot inside fades in and scales up simultaneously. No 3D library needed.

Setting up

bash
npm install framer-motion
tsx
import { useRef, useEffect, useState } from 'react';
import { motion, useScroll, useTransform } from 'framer-motion';

Building the component

The outer wrapper needs min-h-[200vh] to create enough scroll distance for the animation to play out comfortably:

tsx
<div
  ref={ref}
  className={`flex min-h-[200vh] flex-col items-center justify-start py-20 md:py-80 [perspective:800px] ${className}`}
>

The [perspective:800px] creates the 3D viewing cone. Without this, the CSS 3D transforms on child elements have no visible perspective effect.

useScroll with a target ref tracks how far through the element's scroll the user is:

tsx
const { scrollYProgress } = useScroll({
  target: ref,
  offset: ['start start', 'end start'],
});

offset: ['start start', 'end start'] means progress goes from 0 when the element's top aligns with the viewport top, to 1 when the element's bottom reaches the viewport top. That covers the full 200vh scroll range.

Transform scroll progress into animated values:

tsx
const scaleX = useTransform(scrollYProgress, [0, 0.3], [1.2, isMobile ? 1 : 1.5]);
const scaleY = useTransform(scrollYProgress, [0, 0.3], [0.6, isMobile ? 1 : 1.5]);
const rotate = useTransform(scrollYProgress, [0.1, 0.12, 0.3], [-28, -28, 0]);
const textOpacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]);

The lid rotation uses three keypoints: hold at -28 degrees until 10% scrolled, then open to 0 by 30%. The slight delay before opening builds anticipation.

The MacBook body is built from two stacked divs. The bottom half (keyboard/trackpad area) is static:

tsx
<div className='relative [perspective:800px]'>
  <div
    style={{
      transform: 'perspective(800px) rotateX(-25deg) translateZ(0px)',
      transformOrigin: 'bottom',
      transformStyle: 'preserve-3d',
    }}
    className='relative h-[12rem] w-[32rem] rounded-2xl bg-[#010101] p-2'
  >
    <div style={{ boxShadow: '0px 2px 0px 2px #171717 inset' }}
      className='absolute inset-0 flex items-center justify-center rounded-lg bg-[#010101]'>
      <span className='text-white text-sm font-medium'>DM</span>
    </div>
  </div>

The screen lid is a motion.div that shares the same origin. Its scaleX, scaleY, and rotateX animate from the scroll-driven values:

tsx
<motion.div
    style={{
      scaleX,
      scaleY,
      rotateX: rotate,
      transformStyle: 'preserve-3d',
      transformOrigin: 'top',
    }}
    className='absolute inset-0 h-96 w-[32rem] rounded-2xl bg-[#010101] p-2'
  >
    <div className='absolute inset-0 rounded-lg bg-[#272729]' />
    <img
      src={src}
      alt='screen content'
      className='absolute inset-0 h-full w-full rounded-lg object-cover object-left-top'
    />
  </motion.div>
</div>

transformOrigin: 'top' is critical. Without it, the lid scales and rotates around its center, which looks wrong. The hinge needs to be at the top edge of the lid.

The scaleY starting at 0.6 makes the closed lid look flat and thin, like a real laptop screen seen from a steep angle. As it opens, scaleY reaches 1.5, which exaggerates the open angle for visual drama on desktop.

Mobile detection adjusts the target scale values to prevent the lid from appearing comically oversized on small screens.

How to use it

tsx
<MacbookScroll
  src="/screenshot.png"
  title="See it in action"
  showGradient={true}
/>

The showGradient prop fades the bottom of the keyboard area to white, which blends cleanly into a white page background below the component.

Key takeaways

  • useScroll with offset: ['start start', 'end start'] tracks a component's entire scroll distance through the viewport with one hook call.
  • transformOrigin: 'top' on the lid makes rotation pivot from the hinge rather than the center.
  • Using three keypoints in useTransform (hold then move) lets you build deliberate timing into scroll animations without useSpring or useAnimate.