Phone Screen Scroll
A phone mockup that scrolls through a long screenshot by itself, flick by flick like someone reading, then glides back to the top. Visitors can scroll it themselves too, and it carries on a few seconds after they stop.
Good morning, Ana
Where to next?
Trending this week
Night CityTokyo
Coral CoastFiji
Sunset RidgeUtah
Plan a trip
How to use it
- Paste the snippet just before
</head>. - Add
data-phone-screen-scrollto an element holding a tall screenshot (or any long content), and give it a width. The frame, notch and home bar are drawn for you.
Options
Add any of these attributes to the same element.
data-mode | flick scrolls a screenful at a time with pauses; smooth scrolls slowly and steadily. Default flick. |
data-step | Share of the screen each flick moves. Default 0.6. |
data-pause | Rest between flicks. Default 900 ms. |
data-speed | Speed in smooth mode. Default 40 px a second. |
data-color | Colour of the frame. Default #111. |
data-resume | How long after someone scrolls before it carries on. Default 3000 ms. |
The screen is a real scrolling area: the wheel, a swipe, the keyboard and a mouse drag all work, and it doesn’t pull the page with it at either end. The phone keeps a 9 : 19.5 shape unless you set an aspect-ratio. With reduced motion it stays still for visitors to scroll.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Phone Screen Scroll: wraps a long screenshot (or any tall content) in a
// phone mockup and scrolls through it by itself, flick by flick like a
// person reading, then glides back to the top and starts again. Visitors
// can scroll it themselves (wheel, swipe or mouse drag); it picks up
// again a few seconds after they stop.
// Usage: <div data-phone-screen-scroll style="width: 240px"><img src="app-screenshot.png" alt="…"></div>
// Give the phone a width; it keeps a 9 / 19.5 shape unless you set an aspect-ratio.
// Optional: data-mode ("flick", default, or "smooth" for one slow steady
// scroll), data-step (share of the screen each flick moves, default 0.6),
// data-pause (ms between flicks, default 900), data-speed (px per second
// in smooth mode, default 40), data-color (frame colour, default #111),
// data-resume (ms after someone scrolls before it carries on, default 3000).
var DEFAULTS = { step: 0.6, pause: 900, speed: 40, color: '#111', resume: 3000 };
var css =
':where(.pss){aspect-ratio:9/19.5}' +
'.pss{position:relative;box-sizing:border-box;border-radius:15%/6.9%;background:var(--pss-c,#111);box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.14),0 0 0 1px rgba(0,0,0,.35),0 18px 40px rgba(15,15,30,.3)}' +
'.pss-screen{position:absolute;inset:3.4%;border-radius:12%/5.5%;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:none;background:#fff;-webkit-mask-image:-webkit-radial-gradient(white,black)}' +
'.pss-screen::-webkit-scrollbar{display:none}' +
'.pss-screen>img,.pss-screen>svg{display:block;width:100%;height:auto}' +
'.pss-island{position:absolute;left:35%;right:35%;top:4.6%;height:3%;border-radius:999px;background:#000;z-index:2;pointer-events:none}' +
'.pss-bar{position:absolute;left:36%;right:36%;bottom:4.4%;height:4px;border-radius:999px;background:rgba(15,15,30,.55);z-index:2;pointer-events:none}' +
'.pss.pss-grab .pss-screen{cursor:grabbing}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
el.classList.add('pss');
if (d.color) el.style.setProperty('--pss-c', d.color);
var screen = document.createElement('div');
screen.className = 'pss-screen';
while (el.firstChild) screen.appendChild(el.firstChild);
el.appendChild(screen);
['pss-island', 'pss-bar'].forEach(function (c) {
var n = document.createElement('span');
n.className = c;
n.setAttribute('aria-hidden', 'true');
el.appendChild(n);
});
var thumb = !!el.closest('[aria-hidden="true"]');
if (!thumb) {
screen.tabIndex = 0;
screen.setAttribute('role', 'region');
screen.setAttribute('aria-label', el.getAttribute('aria-label') || 'Phone screen');
}
var s = el._pssS = { screen: screen, v: { y: 0 }, anim: null, wait: 0, visible: false, userUntil: 0, driving: false };
if (reduced()) return;
function hold() {
s.userUntil = performance.now() + (+d.resume || DEFAULTS.resume);
if (s.anim) { s.anim.pause(); s.anim = null; }
}
screen.addEventListener('wheel', hold, { passive: true });
screen.addEventListener('touchstart', hold, { passive: true });
screen.addEventListener('keydown', hold);
// Mouse drag scrolls it too, like a finger would.
var drag = null;
screen.addEventListener('pointerdown', function (e) {
hold();
if (e.pointerType !== 'mouse' || e.button > 0) return;
drag = { y: e.clientY, top: screen.scrollTop };
el.classList.add('pss-grab');
screen.setPointerCapture(e.pointerId);
e.preventDefault();
});
screen.addEventListener('pointermove', function (e) {
if (!drag) return;
hold();
screen.scrollTop = drag.top - (e.clientY - drag.y);
});
function up() { drag = null; el.classList.remove('pss-grab'); }
screen.addEventListener('pointerup', up);
screen.addEventListener('pointercancel', up);
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible && !s.started) { s.started = true; schedule(el, 700); }
}, { threshold: 0.2 }).observe(el);
}
function schedule(el, ms) {
var s = el._pssS;
clearTimeout(s.wait);
s.wait = setTimeout(function () { next(el); }, ms);
}
function next(el) {
var s = el._pssS, d = el.dataset, screen = s.screen;
var now = performance.now();
if (!s.visible || now < s.userUntil) { schedule(el, Math.max(400, s.userUntil - now)); return; }
var max = screen.scrollHeight - screen.clientHeight;
var at = screen.scrollTop;
if (max < 2) { schedule(el, 1500); return; }
var pause = d.pause !== undefined ? +d.pause : DEFAULTS.pause;
var to, dur, ease;
if (at >= max - 1) {
// At the bottom: glide back up to the top.
to = 0; dur = Math.min(1800, 600 + max * 0.6); ease = 'inOut(3)';
} else if (d.mode === 'smooth') {
to = max; dur = (max - at) / (+d.speed || DEFAULTS.speed) * 1000; ease = 'linear';
} else {
// A flick: quick to start, easing to a stop.
to = Math.min(max, at + screen.clientHeight * (+d.step || DEFAULTS.step));
dur = 950; ease = 'out(4)';
}
s.v.y = at;
s.anim = anime.animate(s.v, {
y: to,
duration: dur,
ease: ease,
onUpdate: function () { screen.scrollTop = s.v.y; },
onComplete: function () {
s.anim = null;
schedule(el, to === 0 ? pause * 1.6 : to >= max - 1 ? pause * 1.8 : pause);
}
});
}
function init() {
document.querySelectorAll('[data-phone-screen-scroll]').forEach(function (el) {
if (el._pss) return;
el._pss = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-phone-screen-scroll style="width: 240px">
<img src="app-screenshot.png" alt="Our app's home screen">
</div>