Ken Burns
A slow, cinematic pan and zoom over a still photo, the way documentaries bring old pictures to life. Every move drifts towards a new spot, zooming in and then gently back out, so the image never quite sits still.
How to use it
- Paste the snippet just before
</head>. - Add
data-ken-burnsto a container holding one image, and give it a size or anaspect-ratio.
Options
Add any of these attributes to the same element.
data-zoom | How far it zooms in. Default 1.25. |
data-duration | Time for each move. Default 9000 ms. |
data-from, data-to | Focus points to drift between, e.g. data-from="30% 40%" data-to="70% 60%". Default: a new spot each move. |
data-once | Make one move, then stay there. |
The first image, SVG, picture or video moves; anything else inside, like a caption, stays still on top. It never pans far enough to show an edge, and it pauses when off screen. With reduced motion the image stays still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Ken Burns: a slow, cinematic pan and zoom over a still image, the way
// documentaries bring photographs to life. Each move drifts towards a new
// spot, zooming in and then back out, for as long as it's on screen.
// Usage:
// <div data-ken-burns style="aspect-ratio: 16 / 9">
// <img src="photo.jpg" alt="…">
// </div>
// The first img, svg, picture or video moves; anything else (a caption, a
// heading) stays still on top. Optional: data-zoom (how far it zooms,
// default 1.25), data-duration (ms for each move, default 9000),
// data-from and data-to (focus points such as "30% 40%" to drift between,
// instead of choosing new ones each time), data-once (one move, then stop).
var DEFAULTS = { zoom: 1.25, duration: 9000 };
var css =
'[data-ken-burns]{position:relative;overflow:hidden}' +
'.kbn-media{display:block;width:100%;height:100%;object-fit:cover;transform-origin:50% 50%;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// "30% 40%" -> [0.3, 0.4]
function point(s) {
if (!s) return null;
var p = s.trim().split(/\s+/).map(function (v) { return parseFloat(v) / 100; });
return p.length === 2 && !isNaN(p[0]) && !isNaN(p[1]) ? p : null;
}
function setup(el) {
var media = el.querySelector('img,svg,picture,video');
if (!media) return;
if (media.tagName === 'PICTURE') media = media.querySelector('img') || media;
media.classList.add('kbn-media');
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) return;
var d = el.dataset;
var zoom = Math.max(1.02, +d.zoom || DEFAULTS.zoom);
var duration = +d.duration || DEFAULTS.duration;
var fixed = [point(d.from), point(d.to)];
var zoomIn = true, n = 0, anim = null, visible = false;
// Where the frame looks: a focus point (0–1 across and down) and a scale.
// Keeping the translate within (scale - 1) / 2 means no edge ever shows.
function frame(f, s) {
return { x: (0.5 - f[0]) * (s - 1) * 100 + '%', y: (0.5 - f[1]) * (s - 1) * 100 + '%', scale: s };
}
function pick(prev) {
if (fixed[0] && fixed[1]) return fixed[n % 2];
// A new spot, well away from the last one, so it always visibly drifts.
var f;
do { f = [0.15 + Math.random() * 0.7, 0.15 + Math.random() * 0.7]; }
while (prev && Math.hypot(f[0] - prev[0], f[1] - prev[1]) < 0.35);
return f;
}
var at = fixed[0] || [0.5, 0.5];
// Start a little zoomed, so even the first move has room to pan.
var scaleAt = 1 + (zoom - 1) * 0.15;
anime.utils.set(media, frame(at, scaleAt));
function next() {
n++;
var to = pick(at);
var s = zoomIn ? zoom : 1 + (zoom - 1) * 0.15;
var from = frame(at, scaleAt), dest = frame(to, s);
anim = anime.animate(media, {
x: [from.x, dest.x], y: [from.y, dest.y], scale: [from.scale, dest.scale],
duration: duration, ease: 'inOut(1.6)', autoplay: visible,
onComplete: function () {
at = to;
scaleAt = s;
zoomIn = !zoomIn;
if (d.once === undefined) next();
}
});
}
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (!anim) { if (visible) next(); return; }
if (visible) anim.play(); else anim.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-ken-burns]').forEach(function (el) {
if (el._kbn) return;
el._kbn = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-ken-burns style="aspect-ratio: 16 / 9">
<img src="photo.jpg" alt="…">
</div>