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
- Paste the snippet just before
</head>. - Add
data-story-viewerand anaria-labelto a container whose children are the stories, and give it a size (a9 / 16aspect-ratiolooks like a phone). A story’s first image, SVG or video fills it.
Options
Add any of these attributes to the same element.
data-duration | How long each story plays. Default 5000 ms. A story can have its own data-duration. |
data-loop | Start 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.
<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><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>