109

Phone Screen Scroll

£10 or the whole Vault, £99

A real 3D phone, built in three.js, stands pinned in the page and turns as you scroll: front, three-quarter, edge-on, then the back with its camera module, while its screen changes between your screenshots. One img tag per screen. GSAP scrubs the pose; three.js draws it.

ScrollTrigger Lenis advanced
7 more details
webgl-shaderscroll-scrubpinningprocedural-3dpointer-tiltproduct-showcasekeyboard-navigation

This demo reads better at your own screen size than in the frame below:

Released

About this effect

A phone that turns while you scroll, built for the moment a landing page has to show an app. The phone is drawn in code with three.js, so there is no model file to license or load: a bevelled green-grey titanium frame, side buttons and a USB-C port, a dynamic island, a frosted forest green glass back with a raised, polished camera plateau whose lens rings are polished metal over coated glass, a crisp vector status bar drawn in the screen itself, and a front where the screenshot is clipped to the true curve of the display's rounded corners. Every material is lit by a small studio baked to an environment map, so the reflections are real reflections that slide across the metal as the phone turns.

Read the full effect overview

The stage holds still while the page scrolls past it. Scroll and the phone turns through its views: front, three-quarter, edge-on, and the back with the camera module, pausing there for a beat, then round to the next resting angle while the screen changes behind it. A small tilt follows the pointer and the phone floats a little between scrolls. A step rail of native buttons jumps to any screen, and the arrow keys move along it.

Adapting it is markup and colour. Each screen is one img element, changed by its src. The number of screens, the scroll distance and the rail come from the list itself. The turn is set by data attributes on the root (how many turns per change, whether it floats, whether it tilts) and a resting angle on each step; the phone's frame and back colours are CSS tokens, and the status bar takes light or dark glyphs from the screen under it. With reduced motion, no WebGL, a blocked CDN or no JavaScript, the same screenshots appear as flat pictures in device frames with their captions.

Phone Screen Scroll - GSAP animation effect preview

What's included

13 items
  • A procedural three.js phone with no model file: bevelled green-grey titanium frame, side buttons, USB-C port and speaker holes, dynamic island and a frosted forest green glass back
  • A raised camera plateau in polished glass, with polished metal lens rings over coated glass, lit by a baked studio environment so the reflections move as the phone turns
  • Concentric rounded screen corners: the screenshot is clipped to the display's curve by a rounded-rectangle mask, with an even bezel all the way round
  • A vector status bar (clock, signal, wifi, battery) drawn into the screen, centred on the island with a corner radius of safe margin, in white or dark glyphs to suit each screenshot, even mid-dissolve
  • One ScrollTrigger scrubs one timeline into one pose object: it turns the phone through front, three-quarter, edge-on and back, holds on the camera module, and changes the screen while the back faces the viewer
  • Each screen is one img element; the screen count, scroll distance, rail and timeline all come from the list, with no data array in the script
  • Turn choreography is data attributes: data-spin for whole turns per change (0 for a calm rock and dissolve), data-yaw for each resting angle, data-idle, data-tilt and data-intro
  • Phone frame and back colours are CSS tokens, with a recolour() call to repaint at runtime
  • A step rail of native buttons that moves the scroll position, with roving arrow keys, visible focus and aria-current
  • Pointer tilt and idle float in the render loop, which sleeps when nothing changes and wakes on scroll, pointer, resize or the stage coming into view
  • Reduced motion, no WebGL, a blocked CDN or no JavaScript all show the screens as flat framed pictures with captions
  • Lenis smooth scrolling on the shared GSAP clock, with a clean handoff inside the product preview frame
  • Complete teardown with revert(): canvas, textures, environment map, renderer and context are all released

Perfect for

4 use cases
  • App landing pages that need a real phone showing several screens
  • Product pages for a mobile app, wallet, game or habit tracker
  • Case studies that walk through a shipped app screen by screen
  • Launch pages that want the camera-module back view as part of the story

How it works

1 section

The stage is pinned inside a section whose height comes from the list of screens, one screen-tall beat each, so the page has its final length before any script runs. One ScrollTrigger spans that list and scrubs a single GSAP timeline that writes a plain pose: how far the phone has turned, how far it leans, a small swell and which screenshot is showing. The drawing loop reads that pose every frame it draws and adds the pointer tilt and idle float, which it owns; it never tweens, and it sleeps when nothing changed. GSAP also decides whether the phone exists at all under the visitor's motion preference, plays the load entrance and the caption change, shares one clock with the smooth scroll and tears everything down. three.js only draws.

Plugins ScrollTrigger
Difficulty Advanced
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid effect

Purchase to unlock the code.

Buying Phone Screen Scroll opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse free effects

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

The inline script is not optional. The framed screenshots are the fallback for no JavaScript, no WebGL and reduced motion, and without this they would paint for the half second it takes three.js to arrive and then be swapped for the stage. The probe runs before first paint, stamps html.gl when a canvas is coming, and the stylesheet lays the page out as the pinned stage from that class. No JavaScript, no class, the fallback shows; the effect removes the class again if it cannot build the phone after all. It probes WebGL 2, which current three.js requires.

2. Add the markup anywhere in your <body>. One li per screen:

Code snippet omitted: it ships with the download.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

Why the import map rather than a plain <script src> for three.js: three ships as ES modules only, and its old UMD build logs a deprecation warning on every page load. The shim imports it as a module (dynamically, so a blocked CDN falls back to your framed screenshots instead of a blank stage), puts it on window, and then loads assets/script.js as an ordinary script, so the effect stays a plain file you can drop into any build.

Already using three.js as a module? Skip the shim and make sure window.THREE is set before assets/script.js runs:

Code snippet omitted: it ships with the download.

Using It With Your Own Design

What the effect needs from your markup: a [data-phone-scroll] root holding a [data-phone-stage] and an ol[data-phone-steps]. The stage holds the [data-phone-slot] (the box the phone stands in), and optionally the caption and the rail. Every [data-step] carries one img[data-screen], a [data-step-label] and a [data-step-line]. The script reads the page: the number of screens, the scroll distance, the rail buttons and the timeline all follow the list, and there is no data array in the script. Add a screen by copying a whole li; remove one by deleting it. Change a screen by changing its src.

What is only the demo's styling: the dark stage, the accent colour, the type, the caption layout and the cue line. Delete the cue and repaint the tokens at the top of style.css. The rest of the file is layout the effect depends on.

The phone's finish is CSS tokens on the root: --phone-frame (the frame, a green-grey titanium, and the lens rings, which are the same metal a touch lighter), --phone-back (the frosted glass back, a deep forest green, and the polished camera plateau a shade darker), --phone-light (the floor bounce in the studio reflections) and --screen-bg (what the display shows while a screenshot loads). Any CSS colour works, including color-mix(). --phone-h is the phone's height on the stage; --dwell is the scroll distance per screen (100svh by default).

CSS the effect depends on:

  • The html.gl rules. Under .gl the stage is position: sticky; height: 100svh, and the ol is pulled up over it with margin-top: -100svh so each li is one 100svh scroll beat. That is plain CSS, so the page has its final height before any script runs and nothing shifts when the canvas arrives. Keep it, and give the section no overflow: hidden ancestor, which would break sticky.
  • The li content is visually hidden but not removed under .gl: screen readers still read each screen's image description, label and line, because the canvas itself is aria-hidden.
  • The slot's size is the phone's on-screen size and its centre is where the phone is drawn. Resize the slot in CSS and the phone follows.

Choosing Your Screens

The screen is a plain texture, so almost any portrait screenshot works, but a few habits help:

  • Portrait, about 9:19 to 9:20. The display is 853 by 1854 in proportion. The shader cover-fits any other ratio, cropping rather than stretching, so a landscape image shows only its middle. 853 by 1854 or larger is sharp on a retina display; there is no benefit above about 1200 wide.
  • Screenshots of a real UI read best. The phone is a device, and a photograph on it looks like a wallpaper. The bundled screens are concept screenshots of Hush, a fictional sleep and focus sounds app, laid out as a real interface for this effect with stock photography; they show no real product.
  • Leave the top 6.5 percent as a plain strip of the screen's own colour, and start your header below it and inset from the rounded corners. The phone draws its own status bar there (the clock, signal, wifi and battery, as vectors, in the true position on either side of the dynamic island and a corner radius in from the edge), so a screenshot that already has one shows two. Export screenshots without the status bar, or paint the strip over.
  • Tell it whether the screen is dark or light with data-tone="dark" or "light" on the step: dark screens get white status glyphs, light ones dark glyphs, and the colour follows the picture all through a dissolve. The 3D phone measures the top of the screenshot itself when the attribute is missing (an image from another origin cannot be read, so give those the attribute); the flat fallback, which draws its status bar in CSS, always needs it.
  • The clock reads 9:41. Set data-status-time="9:41" on the root to change it in the 3D phone; the flat fallback's clock is the content of .phone-frame::after in style.css.
  • Screens that differ in overall tone dissolve best. The change happens while the back of the phone faces the viewer, so it is rarely seen; with data-spin="0" it is a visible soft dissolve, and similar screens hide the join.

Test the middle of a change, not only the rests.

Options

Set these as data attributes on [data-phone-scroll].

Attribute Values Default Description
data-spin 0, 360, 720, -360 360 Degrees the phone turns on each change, in whole turns. 360 turns once through the edge and the back; 720 twice; a negative value turns the other way; 0 is a calm rock and dissolve with no turn. Other values round to the nearest whole turn, so the phone always lands facing you
data-idle 0, 1 1 The gentle float and sway between scrolls. 0 makes the loop fully render-on-demand
data-tilt 0 to 2 1 Pointer tilt strength. 0 turns the tilt off
data-intro 0, 1 1 The load entrance (a short rise and turn). 0 skips it
data-cue text none The small hint in the corner of the stage. Empty or absent hides it

Set this on each [data-step]:

Attribute Values Default Description
data-yaw degrees, e.g. -24 0 for the first, then alternating -18 and 16 The angle that screen rests at: 0 faces you, a small negative or positive angle is a three-quarter view. The turn to the next screen starts from here

Add ?still=1 to the URL to switch off the float, the tilt and the entrance together (useful for screenshots). Add data-smooth="off" on <html>, or ?smooth=off in the URL, to turn Lenis off.

The change itself is authored in script.js as three constants next to the timeline (HOLD, BACK_HOLD, DRIFT): how long each screen rests, how long the back is held to the viewer, and how far it drifts while held, so the reflections slide across the camera module.

Accessibility

  • Reduced motion: the phone never starts. The page is the list of screens, each in a flat device frame with its label and line, stacked and centred. The stage, the canvas and the rail are not built, and a change of the motion preference while the page is open builds or removes the phone live.
  • Without JavaScript, without WebGL, or with a blocked CDN: the same list. One fallback, three failure modes, and it is never hidden until a canvas has actually been created.
  • Keyboard: the step rail is a row of native buttons in the tab order with a visible focus ring. Enter or Space moves the scroll position to that screen; the arrow keys, Home and End move along the rail and follow. Page Down and the space bar scroll the section as usual.
  • Screen readers: the canvas is aria-hidden; the list underneath stays readable, with each screen's image description, label and line. The rail buttons are named "Screen 3 of 5: The mix" and the current one carries aria-current="step".
  • Touch: scrolling stays native. The canvas takes no pointer events.
  • Contrast: the caption and cue clear 4.5:1 on the stage colour; measure again if you repaint --stage, --ink or --accent.

Dependencies

Dependency Version Required
three.js 0.180.0 Yes, as an ES module (see Quick Start)
GSAP core 3.15.0 (3.12+) Yes: see below
ScrollTrigger 3.15.0 (3.12+) Yes: scrubs the timeline
Lenis 1.3.17 Optional: smooth scrolling on the shared GSAP clock

What GSAP does here, exactly. One ScrollTrigger scrubs one GSAP timeline that writes a plain pose object: yaw, pitch, a small swell and which screenshot is showing (a fraction is the dissolve). The load entrance and the caption change are played tweens, gsap.matchMedia() decides whether the phone exists at all, gsap.context().revert() is the teardown, and gsap.ticker is the one clock the render loop shares with Lenis and ScrollTrigger. GSAP does not touch the three.js scene graph.

What three.js and the render loop do. The loop reads pose and adds the pointer tilt and the idle float, which it owns outright, then draws. It never tweens. It runs only while the stage is on screen and only while something changed (a scroll, a pointer move, a resize, or the float itself), and sleeps otherwise.

Everything three.js is used for (WebGLRenderer, MeshPhysicalMaterial, ExtrudeGeometry, LatheGeometry, PMREMGenerator, ShaderMaterial) is long-stable API, so pinning a different version is a one-line change in the import map. If GSAP is not present, ScrollTrigger is missing or three.js does not load, the framed screenshots show.

Browser Support

Anything with WebGL 2, which is every current browser. Support is probed before three.js is asked for a renderer, deliberately: three logs its own failure to the console as errors before it throws, so catching the exception would hide nothing and a visitor with WebGL disabled would get a console full of red on a page that had quietly fallen back. Probed first, that visitor simply gets the framed screenshots and a clean console. In a browser old enough to lack import maps the module never runs and the framed screenshots are what shows.

If the graphics context is lost (a driver reset, a tab juggled on a phone) the effect rebuilds its environment map when the browser restores it and carries on where it was.

Performance

The phone is a few dozen meshes, so a few dozen draw calls, of physically based materials lit by one baked environment map; the cost is the fragment pass at the canvas size, not the geometry. Pixel ratio is capped at 2 (1.5 on touch devices). The loop stops when the stage leaves the screen or the tab is hidden, and with data-idle="0" it draws only when the scroll, the pointer or the window changes. On a phone the idle float alone is drawn at half rate.

To buy back frames on low-end hardware, set data-idle="0" first: it removes the only continuous cost. After that, lower the --phone-h token, which shrinks the area shaded.

Image Credits

The five bundled screens are concept screenshots of Hush, a fictional calm sleep and focus sounds app, designed as HTML for this effect and rendered to images; they show no real product. The photographs inside them are from Pexels under the Pexels licence (photo ids 2635817, 10762369, 15222306, 5851472, 249614, 26953466, 7112541, 6869257 and 8956014). Each master is 853 by 1854 pixels, the proportion of the phone's display, with the top 6.5 percent left as a plain strip because the effect draws the status bar itself. The masters are in assets/img-src/, baked to WebP with bun scripts/build-template-assets.ts phone-screen-scroll --force. To replace one, save your own portrait screenshot (853 by 1854 or larger, status bar removed or the strip kept plain) over the master or point its entry in assets/img-manifest.json at it, run that command, and set data-tone on the step to match.

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.