Film Strip
Pictures run past on a strip of film with sprocket holes along both edges. Let it roll smoothly, or switch to projector mode: each frame stops in the middle, then the next is pulled in with a blink of the shutter while the light flickers.
Night Drive
Bloom
Tides
Neon
How to use it
- Paste the snippet just before
</head>. - Add
data-film-stripto a box holding your pictures. They become the frames and repeat for as long as it runs. - Set the frame size with
--fst-wand--fst-hon the box if you want it bigger or smaller.
Options
Add any of these attributes to the same element.
data-mode | roll moves smoothly; step stops on each frame like a projector. Default roll. |
data-speed | Speed when rolling, in px a second. Default 45. |
data-hold | How long each frame rests in step mode. Default 900 ms. |
data-direction | left, right, up or down. Default left. |
data-flicker | Flicker the light over the frames. On in step mode; add it to a rolling strip too, or set false. |
data-color | Colour of the film. Default #141414. |
--fst-w / --fst-h | CSS variables for the frame size. Default 128 × 92 px. |
The frames are copied as many times as it takes to fill the box, so the strip never shows a gap. It only runs while on screen, and with reduced motion it stands still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Film Strip: pictures run past on a strip of film with sprocket holes,
// either rolling smoothly or jumping a frame at a time like film through
// a projector, with the shutter blinking and the light flickering.
// Usage: <div data-film-strip><img src="1.jpg" alt="…"> <img src="2.jpg" alt="…"> …</div>
// The children are the frames; they repeat for as long as it runs. Set the
// frame size with the CSS variables --fst-w and --fst-h (default 128 × 92 px).
// Optional: data-mode ("roll", default, or "step": a frame at a time),
// data-speed (px a second when rolling, default 45), data-hold (ms each
// frame rests in step mode, default 900), data-direction ("left",
// default, "right", "up" or "down"), data-flicker (flicker the light;
// on by default in step mode, "false" to turn off), data-color (colour of
// the film, default #141414).
var DEFAULTS = { speed: 45, hold: 900 };
var css =
'.fst{position:relative;overflow:hidden;--fst-c:#141414}' +
'.fst-track{display:flex;width:max-content;will-change:transform}' +
'.fst-v .fst-track{flex-direction:column;width:auto;height:max-content}' +
'.fst-frame{position:relative;flex:none;padding:calc(var(--fst-h,92px)*.2) 5px;background:var(--fst-c)}' +
'.fst-v .fst-frame{padding:5px calc(var(--fst-w,128px)*.16)}' +
'.fst-frame::before,.fst-frame::after{content:"";position:absolute;left:0;right:0;height:calc(var(--fst-h,92px)*.2);background:linear-gradient(90deg,#0000 22%,#f2ede4 22% 64%,#0000 64%) 0 50%/calc(var(--fst-w,128px)/6 + 10px/6) 38% repeat-x}' +
'.fst-frame::before{top:0}.fst-frame::after{bottom:0}' +
'.fst-v .fst-frame::before,.fst-v .fst-frame::after{top:0;bottom:0;left:auto;right:auto;width:calc(var(--fst-w,128px)*.16);height:auto;background:linear-gradient(#0000 22%,#f2ede4 22% 64%,#0000 64%) 50% 0/38% calc(var(--fst-h,92px)/4 + 10px/4) repeat-y}' +
'.fst-v .fst-frame::before{left:0}.fst-v .fst-frame::after{right:0}' +
'.fst-frame>img,.fst-frame>svg,.fst-frame>video{display:block}' +
'.fst-frame>*{width:var(--fst-w,128px)!important;height:var(--fst-h,92px)!important;margin:0;border-radius:2px;object-fit:cover}' +
'.fst-light{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at 50% 50%,rgba(255,244,214,.18),rgba(0,0,0,.28) 85%)}' +
'.fst-shutter{position:absolute;inset:0;pointer-events:none;background:#000;opacity: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 items = Array.prototype.slice.call(el.children);
if (!items.length) return;
var dir = d.direction || 'left';
var vertical = dir === 'up' || dir === 'down';
el.classList.add('fst');
if (vertical) el.classList.add('fst-v');
if (d.color) el.style.setProperty('--fst-c', d.color);
var track = document.createElement('div');
track.className = 'fst-track';
items.forEach(function (n) {
var f = document.createElement('div');
f.className = 'fst-frame';
f.appendChild(n);
track.appendChild(f);
});
el.appendChild(track);
var step = d.mode === 'step';
var light = null, shutter = null;
if (d.flicker !== 'false' && (step || d.flicker !== undefined)) {
light = document.createElement('div');
light.className = 'fst-light';
light.setAttribute('aria-hidden', 'true');
el.appendChild(light);
}
if (step) {
shutter = document.createElement('div');
shutter.className = 'fst-shutter';
shutter.setAttribute('aria-hidden', 'true');
el.appendChild(shutter);
}
var s = el._fstS = {
track: track, n: items.length, vertical: vertical, sign: dir === 'right' || dir === 'down' ? 1 : -1,
step: step, light: light, shutter: shutter, pos: 0, set: 0, frame: 0, adjust: 0, raf: 0, last: 0,
visible: false, rest: 0, moving: null
};
measure(el);
new ResizeObserver(function () { measure(el); }).observe(el);
if (reduced()) return;
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible) kick(el);
}).observe(el);
}
// Clone the frames until the strip covers the box twice over, so it can wrap seamlessly.
function measure(el) {
var s = el._fstS;
var frames = s.track.children;
var first = frames[0], last = frames[s.n - 1];
s.set = s.vertical
? last.offsetTop + last.offsetHeight - first.offsetTop
: last.offsetLeft + last.offsetWidth - first.offsetLeft;
s.frame = s.set / s.n;
if (!s.set) return;
var span = s.vertical ? el.clientHeight : el.clientWidth;
var copies = Math.ceil((span + s.frame) / s.set) + 1;
// In step mode a frame rests in the middle, as if in the projector's gate.
s.adjust = s.step ? ((((span - s.frame) / 2) % s.frame) + s.frame) % s.frame - s.frame : 0;
var have = frames.length / s.n - 1;
for (var c = have; c < copies; c++) {
for (var i = 0; i < s.n; i++) {
var copy = frames[i].cloneNode(true);
copy.setAttribute('aria-hidden', 'true');
copy.querySelectorAll('[id]').forEach(function (m) { m.removeAttribute('id'); });
s.track.appendChild(copy);
}
}
place(el);
}
function place(el) {
var s = el._fstS;
// pos counts up; the strip shows it modulo one set, in the chosen direction.
var p = ((s.pos % s.set) + s.set) % s.set;
var off = (s.sign < 0 ? -p : p - s.set) + s.adjust;
s.track.style.transform = s.vertical ? 'translate3d(0,' + off.toFixed(2) + 'px,0)' : 'translate3d(' + off.toFixed(2) + 'px,0,0)';
}
function kick(el) {
var s = el._fstS;
if (s.raf || !s.visible) return;
s.last = performance.now();
s.raf = requestAnimationFrame(function tick(now) {
s.raf = 0;
if (!s.visible || !s.set) return;
var d = el.dataset, dt = Math.min(50, now - s.last);
s.last = now;
if (!s.step) {
s.pos += (+d.speed || DEFAULTS.speed) * dt / 1000;
} else if (!s.moving) {
s.rest += dt;
if (s.rest >= (+d.hold || DEFAULTS.hold)) {
// Pull the next frame down fast, with the shutter closed.
s.rest = 0;
var from = Math.round(s.pos / s.frame) * s.frame;
s.moving = { from: from, t: 0 };
}
}
if (s.moving) {
s.moving.t = Math.min(1, s.moving.t + dt / 260);
var e = s.moving.t, k = e < 0.5 ? 4 * e * e * e : 1 - Math.pow(-2 * e + 2, 3) / 2;
s.pos = s.moving.from + s.frame * k;
s.shutter.style.opacity = (Math.sin(Math.PI * e) * 0.55).toFixed(3);
if (e >= 1) s.moving = null;
}
if (s.light && Math.random() < 0.35) s.light.style.opacity = (0.75 + Math.random() * 0.25).toFixed(2);
place(el);
s.raf = requestAnimationFrame(tick);
});
}
function init() {
document.querySelectorAll('[data-film-strip]').forEach(function (el) {
if (el._fst) return;
el._fst = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-film-strip data-mode="step" style="--fst-w: 160px; --fst-h: 110px">
<img src="1.jpg" alt="">
<img src="2.jpg" alt="">
<img src="3.jpg" alt="">
</div>