← Cards & images

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.

Mountain LakeLate summer, just after noon

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-ken-burns to a container holding one image, and give it a size or an aspect-ratio.

Options

Add any of these attributes to the same element.

data-zoomHow far it zooms in. Default 1.25.
data-durationTime for each move. Default 9000 ms.
data-from, data-toFocus points to drift between, e.g. data-from="30% 40%" data-to="70% 60%". Default: a new spot each move.
data-onceMake 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.

1 · Snippet
<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>
2 · Your HTML
<div data-ken-burns style="aspect-ratio: 16 / 9">
  <img src="photo.jpg" alt="…">
</div>