← Cards & images

Story Viewer

Full-bleed stories, one after another, with a row of progress bars along the top that fill as each one plays and the picture slowly drifting in. Tap the right side to skip ahead, the left side to go back, and press and hold to pause.

Aanimacious2h

Stayed up for this ✨

Aanimacious2h

Swim before breakfast

Aanimacious2h

Golden hour, every hour

Aanimacious2h

See younext week

Tap the right side to skip, the left to go back; hold to pause

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-story-viewer and an aria-label to a container whose children are the stories, and give it a size (a 9 / 16 aspect-ratio looks like a phone). A story’s first image, SVG or video fills it.

Options

Add any of these attributes to the same element.

data-durationHow long each story plays. Default 5000 ms. A story can have its own data-duration.
data-loopStart again after the last story.

For a true full-screen viewer, put it in a fixed layer that covers the window. It only plays while on screen. With the keyboard, the arrow keys move between stories and Space pauses. From code, animacious.story(element, 0) jumps to a story.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Story Viewer: full-bleed stories, one after another, with a row of
    // progress bars along the top that fill as each one plays. Tap the right
    // side to skip ahead, the left side to go back, and press and hold to
    // pause. The picture in each story drifts in slowly while it plays.
    // Usage:
    //   <div data-story-viewer aria-label="Our week" style="aspect-ratio: 9 / 16">
    //     <div><img src="one.jpg" alt="…"><p>Caption</p></div>
    //     <div>…</div>
    //   </div>
    // Each child is a story and fills the viewer. Give the viewer a size.
    // Optional: data-duration (ms per story, default 5000; a story can have
    // its own data-duration), data-loop (start again after the last story).
    // Script use: animacious.story(element, index) jumps to a story.
    var DEFAULTS = { duration: 5000 };

    var css =
      '[data-story-viewer]{position:relative;overflow:hidden;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer}' +
      '[data-story-viewer]:focus-visible{outline:3px solid #a855f7;outline-offset:3px}' +
      '[data-story-viewer]>:not(.stv-bars):not(.stv-sr){position:absolute;inset:0;overflow:hidden}' +
      '[data-story-viewer]>.stv-off{visibility:hidden}' +
      '[data-story-viewer]>*>img:first-child,[data-story-viewer]>*>svg:first-child,[data-story-viewer]>*>video:first-child{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}' +
      '.stv-bars{position:absolute;left:8px;right:8px;top:8px;z-index:20;display:flex;gap:4px;pointer-events:none}' +
      '.stv-bars>span{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.38);overflow:hidden}' +
      '.stv-bars i{display:block;height:100%;background:#fff;transform-origin:0 50%;transform:scaleX(0)}';
    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;
      var stories = Array.from(el.children);
      var n = stories.length;
      if (!n) return;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var current = -1, fill = null, drift = null, held = false, visible = false, done = false, holdTimer = 0, wasHold = false;

      var bars = document.createElement('div');
      bars.className = 'stv-bars';
      bars.setAttribute('aria-hidden', 'true');
      bars.innerHTML = stories.map(function () { return '<span><i></i></span>'; }).join('');
      el.appendChild(bars);
      var fills = Array.from(bars.querySelectorAll('i'));
      var live = null;

      if (!inThumb) {
        el.setAttribute('role', 'region');
        el.setAttribute('aria-roledescription', 'stories');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        live = document.createElement('span');
        live.setAttribute('aria-live', 'polite');
        live.className = 'stv-sr';
        live.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)';
        el.appendChild(live);
      }

      function lengthOf(i) { return +stories[i].dataset.duration || +d.duration || DEFAULTS.duration; }
      function picture(s) { var p = s.firstElementChild; return p && /^(IMG|SVG|VIDEO|svg)$/.test(p.tagName) ? p : null; }

      function go(i) {
        if (i < 0) i = 0;
        if (i >= n) {
          if (d.loop === undefined) { done = true; if (fill) fill.pause(); return; }
          i = 0;
        }
        done = false;
        var prev = current;
        current = i;
        if (fill) fill.pause();
        if (drift) drift.pause();
        fills.forEach(function (f, k) { anime.utils.set(f, { scaleX: k < i ? 1 : 0 }); });
        stories.forEach(function (s, k) {
          s.classList.toggle('stv-off', k !== i);
          if (!inThumb) s.setAttribute('aria-hidden', k === i ? 'false' : 'true');
        });
        if (live) live.textContent = 'Story ' + (i + 1) + ' of ' + n;
        var s = stories[i], pic = picture(s);
        if (reduced()) { anime.utils.set(fills[i], { scaleX: 1 }); return; }
        if (prev !== i) anime.animate(s, { opacity: [0, 1], scale: [1.03, 1], duration: 320, ease: 'out(3)' });
        if (pic) drift = anime.animate(pic, { scale: [1, 1.1], duration: lengthOf(i), ease: 'linear' });
        fill = anime.animate(fills[i], { scaleX: [0, 1], duration: lengthOf(i), ease: 'linear', onComplete: function () { go(current + 1); } });
        if (held || !visible) pause();
      }

      function pause() { if (fill) fill.pause(); if (drift) drift.pause(); }
      function resume() { if (held || !visible || done) return; if (fill) fill.play(); if (drift) drift.play(); }
      el._stv = function (i) { go(i); };

      if (!inThumb) {
        // Press and hold pauses; a quick tap moves on (or back, on the left third).
        el.addEventListener('pointerdown', function (e) {
          if (e.button > 0) return;
          wasHold = false;
          clearTimeout(holdTimer);
          holdTimer = setTimeout(function () { wasHold = true; held = true; pause(); }, 220);
        });
        var lift = function () {
          clearTimeout(holdTimer);
          if (held) { held = false; resume(); }
        };
        el.addEventListener('pointerup', lift);
        el.addEventListener('pointercancel', lift);
        el.addEventListener('pointerleave', lift);
        el.addEventListener('click', function (e) {
          if (wasHold) { wasHold = false; return; }
          var r = el.getBoundingClientRect();
          go(e.clientX - r.left < r.width / 3 ? current - 1 : current + 1);
        });
        el.addEventListener('contextmenu', function (e) { if (held) e.preventDefault(); });
        el.addEventListener('keydown', function (e) {
          if (e.key === 'ArrowRight') { e.preventDefault(); go(current + 1); }
          else if (e.key === 'ArrowLeft') { e.preventDefault(); go(current - 1); }
          else if (e.key === ' ') {
            e.preventDefault();
            held = !held;
            if (held) pause(); else resume();
          }
        });
      }

      // Only plays while on screen.
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (current < 0) { if (visible) go(0); return; }
        if (visible) resume(); else pause();
      }, { threshold: 0.3 }).observe(el);
      stories.forEach(function (s, k) { if (k) s.classList.add('stv-off'); });
    }

    function init() {
      document.querySelectorAll('[data-story-viewer]').forEach(function (el) {
        if (el._stv) return;
        el._stv = true;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.story = function (el, i) { if (el && typeof el._stv === 'function') el._stv(i); };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-story-viewer data-loop aria-label="Our week" style="aspect-ratio: 9 / 16; width: 300px">
  <div><img src="monday.jpg" alt="…"><p>Monday</p></div>
  <div><img src="tuesday.jpg" alt="…"><p>Tuesday</p></div>
  <div><img src="wednesday.jpg" alt="…"><p>Wednesday</p></div>
</div>