# Phone Screen Scroll

> 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.

Canonical: https://gsapvault.com/effects/phone-screen-scroll
Live demo: https://gsapvault.com/demos/phone-screen-scroll/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | paid |
| Price | £10 |
| Difficulty | advanced |
| Plugins | ScrollTrigger |
| Techniques | webgl-shader, scroll-scrub, pinning, procedural-3d, pointer-tilt, product-showcase, keyboard-navigation |
| Uses Lenis | Yes |

## Lighthouse, as measured

Google Lighthouse on the demo, 29 September 2026. A measurement of the demo as shipped, not a promise for your page.

| Category | Score |
|----------|-------|
| Accessibility | 100 |
| Best practices | 100 |

No performance score is published: it depends on the deployment (server compression, caching, CDN, connection and device) rather than on the code, so measure it where it will live.

## Overview

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.

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.

## Features

- 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

## 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

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

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.

## Documentation

How this effect works and how it goes into a page. The reference you use once you own the files (worked examples, events, the programmatic API, the class list) ships with the download.

### 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.

## What You Get

- `index.html`: working demo page
- `assets/script.js`: commented, readable source
- `assets/style.css`: effect styles
- `README.md`: full documentation with examples and framework integration notes
- `START-HERE-AI.md`: product-specific copy-paste prompt for AI-assisted setup
- `LICENSE.txt`: standard license terms
- Lifetime updates: re-download anytime from your library

## Get the Code

This is a premium effect. The standard license costs £10 one-time and covers unlimited personal and commercial projects with no attribution required for our code. Bundled third-party assets retain their own licences and attribution requirements. The only restrictions: no redistribution of the code itself and no competing effect libraries.

- [Buy Phone Screen Scroll](https://gsapvault.com/effects/phone-screen-scroll)
- [The Vault (£99 one-time, best value): every collection in the Vault library, plus future items added to those collections](https://gsapvault.com/effects)

## Judge the Code Quality First

These related effects are free with complete source published, written to the same production standard (cleanup functions, reduced-motion support, framework-agnostic):

- [Scroll Progress Indicator](https://gsapvault.com/effects/scroll-progress.md): A precise GSAP reading-progress indicator in four shapes: a top bar, an SVG ring, a side rail, and a numeric percentage.
- [CSS Scroll Reveal](https://gsapvault.com/effects/css-scroll-reveal.md): Native CSS scroll-driven reveals for crisp fade, slide, and scale entrances with accessible static fallbacks and no animation JavaScript.
- [Parallax Hero](https://gsapvault.com/effects/parallax-hero.md): A pinned hero that pulls its layers into depth from one scrubbed ScrollTrigger: a headline sandwiched inside the stack, ridges or photographs climbing over it, and a sun that sets behind the far layer.

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
