← Cards & images

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.

Wander

Good morning, Ana

Alpine LakeSwitzerland · 4.9 ★
Northern LightsNorway · 4.8 ★
Trending this week

Night CityTokyo

Coral CoastFiji

Sunset RidgeUtah

Desert DunesMorocco · 4.7 ★
Plan a trip
Made with Wander

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-phone-screen-scroll to 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-modeflick scrolls a screenful at a time with pauses; smooth scrolls slowly and steadily. Default flick.
data-stepShare of the screen each flick moves. Default 0.6.
data-pauseRest between flicks. Default 900 ms.
data-speedSpeed in smooth mode. Default 40 px a second.
data-colorColour of the frame. Default #111.
data-resumeHow 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.

1 · Snippet
<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>
2 · Your HTML
<div data-phone-screen-scroll style="width: 240px">
  <img src="app-screenshot.png" alt="Our app's home screen">
</div>