How to build a scroll-driven perspective tilt hero in React
A scroll-driven perspective hero in React applies a CSS perspective container and uses Framer Motion's useScroll and useTransform to map scroll progress to rotateX and scale values, then smooths them through useSpring so the tilt resolves naturally as the visitor scrolls down.
- Stack: React + Framer Motion + Lucide React, ~480 lines, zero extra runtime dependencies.
- Core Framer Motion API: useScroll, useTransform, useSpring, transformStyle: preserve-3d.
- The dashboard mockup runs on its own independent tilt (rotateX 22° to 2°), slightly more dramatic than the text layer.
- Accessible: semantic HTML, aria-hidden on decorative divs, readable content at all scroll positions.
- The 3D effect is fully visible on desktop; on mobile the same transforms apply but the perspective foreshortening is less pronounced on small viewports.
This hero opens tilted toward the viewer, the text block at 15° on the X axis and the dashboard mockup at 22°, then unfolds into a flat layout as the user scrolls. The result reads as a cinematic camera pull-back, the kind of entry sequence that signals a premium product without relying on a background video.
Anatomy
The section has two decorative layers (a thin accent line at the top edge and a blurred ambient glow beneath the mockup) then a single perspective wrapper set at 1200px. Inside that wrapper, a motion.div carries the text block and a nested motion.div for the mockup, each with its own rotateX and scale springs. The text block centers at 700px max-width; the mockup sits below it at 900px max-width with a 16:9 aspect ratio, showing a chrome toolbar and a two-column fake dashboard (sidebar nav + stats cards + bar chart).
How it works
The scroll hook attaches to the section element via useRef and reads scrollYProgress between offset ["start start", "end start"]. Two raw transforms map that progress to the tilt values: rotateX goes from 15° to 0° over the first half of the scroll range, and scale goes from 0.9 to 1.0 over the same range. opacity climbs from 0.7 to 1.0 over the first quarter. Each raw value is fed through useSpring (stiffness 60, damping 20) so the animation trails slightly and feels physical. The mockup uses separate springs with a larger starting angle (22° to 2°) and a smaller starting scale (0.88), creating a parallax-like depth between the two layers despite sharing the same scroll source.
How to build it in React
Set up the scroll source and raw transforms
Attach a ref to the section, then call useScroll with that target and the offset pair. Feed scrollYProgress into useTransform calls to derive rotateX, scale and opacity ranges. Keep the input range narrow (0 to 0.5) so the effect resolves before the section leaves the viewport.
const sectionRef = useRef<HTMLElement>(null); const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start start", "end start"], }); const rawRotateX = useTransform(scrollYProgress, [0, 0.5], [15, 0]); const rawScale = useTransform(scrollYProgress, [0, 0.5], [0.9, 1]); const rawOpacity = useTransform(scrollYProgress, [0, 0.25], [0.7, 1]);Smooth the raw values through springs
Wrap each raw motion value in useSpring with a consistent config. Low stiffness (60) and moderate damping (20) produce the trailing feel without oscillation. The spring is what separates a cinematic tilt from a stiff CSS transition.
const SPRING = { stiffness: 60, damping: 20, mass: 1 }; const rotateX = useSpring(rawRotateX, SPRING); const scale = useSpring(rawScale, SPRING); const opacity = useSpring(rawOpacity, SPRING);Apply the perspective container and motion layer
Wrap everything in a plain div with `perspective: 1200px` and `perspectiveOrigin: "50% 30%"`. The inner motion.div receives the spring values and must have `transformStyle: "preserve-3d"` and `transformOrigin: "50% 0%"` so the tilt rotates from the top edge, not the center.
<div style={{ perspective: "1200px", perspectiveOrigin: "50% 30%" }}> <motion.div style={{ rotateX, scale, opacity, transformStyle: "preserve-3d", transformOrigin: "50% 0%" }} > {/* text + mockup */} </motion.div> </div>Give the mockup its own deeper tilt
Nest a second motion.div for the mockup with its own useTransform + useSpring pair. Start it at 22° instead of 15° and only bring it back to 2° (not 0°) to preserve a subtle depth when the hero is fully scrolled in. This offset between the two layers reads as parallax.
const rawMockupRotateX = useTransform(scrollYProgress, [0, 0.5], [22, 2]); const rawMockupScale = useTransform(scrollYProgress, [0, 0.5], [0.88, 1]); const mockupRotateX = useSpring(rawMockupRotateX, SPRING); const mockupScale = useSpring(rawMockupScale, SPRING);
When to use it
This pattern works best on a SaaS or product landing page where you have a real UI screenshot or mockup to show. The tilt draws attention to the product image and frames the page as a reveal. Avoid it when the above-the-fold section already carries heavy animations, or when the primary conversion action needs to be immediately visible without any motion distraction. Because the effect is scroll-triggered, it also makes less sense on short pages where the hero fills most of the viewport height.
Used by
- Linear, Uses scroll-driven 3D perspective on its product hero to pull the dashboard mockup into view from a tilted angle.
- Vercel, Applies scale and depth transforms to product screenshots in hero sections, creating a sense of the UI emerging toward the viewer.
- Loom, Uses a cinematic perspective entry for its app mockup on the marketing homepage, transitioning from a foreshortened 3D angle to flat.
FAQ
Why useSpring instead of applying useTransform directly to the motion.div?
useTransform maps scroll progress linearly, so the tilt snaps proportionally to scroll speed and feels mechanical. useSpring adds mass and damping so the tilt lags slightly behind the scroll position, then catches up, that lag is what reads as physical weight rather than a CSS animation.
Does the effect break if the user has reduced-motion preferences?
The component does not currently check prefers-reduced-motion. To respect it, read the media query with useReducedMotion from Framer Motion and set initial rotateX and scale to their final values (0° and 1.0) so there is no transform at all.
Can I replace the built-in mockup with a real screenshot?
Yes. The mockup lives in its own motion.div and has a fixed 16:9 aspect ratio. Swap the inner content for an <img> or a Next.js <Image>, the perspective spring wrapping it stays the same. Keep the overflow:hidden and border-radius on the outer container so the image clips correctly.
How do I tune the tilt to feel heavier or snappier?
Adjust the SPRING config: lower stiffness (e.g. 30) and higher mass make it sluggish and cinematic; higher stiffness (e.g. 120) with lower damping snaps faster. The starting rotateX values (15° and 22°) control how dramatic the initial angle appears, crank them up to 25°/35° for a more exaggerated entry.