← Hover & cursor

Caption Slide-up

Hover a picture and its caption slides up over the bottom on a soft dark fade, the title first and the details just behind, while the picture eases back a touch. Move away and it slips down out of sight.

Lakes12 photos · 2026
Aurora8 photos · 2026
Dunes15 photos · 2026

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-caption-slide-up to a <figure> holding a picture.
  3. Put the caption in a <figcaption>, or any child marked data-caption. Each line inside it rises in turn.

Options

Add any of these attributes to the same element.

data-peekHow much of the caption shows at rest, in px. The first line stays in view and the rest slide up. Default 0.
data-durationHow long the slide takes. Default 550 ms.
data-staggerGap between the caption’s lines. Default 70 ms.
data-zoomHow far the picture scales up while the caption shows. Default 1.06; 1 for none.
data-autoplaySlide up and down on a timer while the pointer isn’t over it. Neighbouring pictures take turns. Handy for demos and touch screens.

The script gives the caption a dark fade and white text, which you can restyle with your own CSS. A tap shows it on touch screens, and keyboard focus does too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Caption Slide-up: hover a picture and its caption slides up over it from
    // the bottom edge on a soft dark fade, its lines following one after another,
    // while the picture eases back a touch. Move away and it slides down again.
    // Usage: <figure data-caption-slide-up><img src="…" alt="…"><figcaption>…</figcaption></figure>
    // The caption is the <figcaption>, or the child marked data-caption. Each
    // child of the caption (a title, a line of detail…) rises in turn.
    // Optional: data-peek (px of the caption that shows at rest, default 0; the
    // first line stays put), data-duration (ms, default 550), data-stagger (ms
    // between the caption's lines, default 70), data-zoom (how far the picture
    // scales, default 1.06; 1 for none), data-autoplay (slide up and down on a
    // timer while the pointer isn't over it; neighbours take turns; handy for
    // demos and touch screens).
    var DEFAULTS = { peek: 0, duration: 550, stagger: 70, zoom: 1.06 };

    var css =
      '.csu{position:relative;overflow:hidden;isolation:isolate}' +
      '.csu-cap{position:absolute;left:0;right:0;bottom:0;z-index:1;margin:0;padding:40px 16px 14px;color:#fff;' +
      'background:linear-gradient(rgba(12,6,28,0),rgba(12,6,28,.55) 40%,rgba(12,6,28,.85));will-change:transform}' +
      '.csu-cap>*{display:block}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var peek = num(d.peek, DEFAULTS.peek);
      var duration = num(d.duration, DEFAULTS.duration);
      var stagger = num(d.stagger, DEFAULTS.stagger);
      var zoom = num(d.zoom, DEFAULTS.zoom);
      var cap = el.querySelector('[data-caption]') || el.querySelector('figcaption');
      if (!cap) return null;
      el.classList.add('csu');
      cap.classList.add('csu-cap');
      var pic = null;
      for (var i = 0; i < el.children.length; i++) {
        if (el.children[i] !== cap) { pic = el.children[i]; break; }
      }
      // With a peek, the first line stays in view; the rest rise in.
      var lines = [].slice.call(cap.children);
      if (peek > 0) lines = lines.slice(1);
      var on = false;

      function rest() { return Math.max(0, cap.offsetHeight - peek); }
      function park() {
        anime.utils.set(cap, { y: rest() });
        if (lines.length) anime.utils.set(lines, { opacity: 0, y: 14 });
      }
      function enter() {
        if (on) return;
        on = true;
        var t = reduced() ? 0 : duration;
        anime.animate(cap, { y: 0, duration: t, ease: 'out(4)' });
        if (lines.length) {
          anime.animate(lines, { opacity: 1, y: 0, duration: t * 0.9, delay: anime.stagger(reduced() ? 0 : stagger, { start: t * 0.18 }), ease: 'out(3)' });
        }
        if (pic && zoom !== 1) anime.animate(pic, { scale: zoom, duration: t * 2, ease: 'out(3)' });
      }
      function leave() {
        if (!on) return;
        on = false;
        var t = reduced() ? 0 : duration * 0.8;
        anime.animate(cap, { y: rest(), duration: t, ease: 'inOut(3)' });
        if (lines.length) anime.animate(lines, { opacity: 0, y: 10, duration: t * 0.5, ease: 'in(2)' });
        if (pic && zoom !== 1) anime.animate(pic, { scale: 1, duration: t * 1.6, ease: 'out(3)' });
      }
      park();
      // The caption's height changes with the width: keep it tucked away.
      new ResizeObserver(function () { if (!on) anime.utils.set(cap, { y: rest() }); }).observe(cap);

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        el._csuReal = true;
        enter();
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        el._csuReal = false;
        leave();
      });
      // Touch: a tap brings the caption up, another tap sends it down.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        el._csuHold = Date.now() + 5000;
        if (on) leave(); else enter();
      });
      el.addEventListener('focusin', enter);
      el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) leave(); });

      return {
        enter: enter,
        leave: leave,
        isOn: function () { return on; },
        busy: function () { return el._csuReal || el.contains(document.activeElement) || (el._csuHold && Date.now() < el._csuHold); }
      };
    }

    function autoplay(el, api, offset) {
      var visible = false, timer = null, mine = false;
      function tick() {
        if (!visible) return;
        if (api.busy()) { mine = false; timer = setTimeout(tick, 800); return; }
        if (mine) { api.leave(); mine = false; timer = setTimeout(tick, 1500); }
        else if (!api.isOn()) { api.enter(); mine = true; timer = setTimeout(tick, 1900); }
        else timer = setTimeout(tick, 800);
      }
      new IntersectionObserver(function (entries) {
        var was = visible;
        visible = entries[0].isIntersecting;
        if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 400 + offset); }
      }).observe(el);
    }

    var count = 0;

    function init() {
      document.querySelectorAll('[data-caption-slide-up]').forEach(function (el) {
        if (el._csu) return;
        el._csu = true;
        var api = setup(el);
        if (!api) return;
        var auto = el.dataset.autoplay;
        if (auto === undefined || auto === 'false' || reduced()) return;
        // Pictures take turns, so a gallery ripples rather than all at once.
        autoplay(el, api, (count++ % 3) * 1100);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-caption-slide-up>
  <img src="lakes.jpg" alt="A mountain lake">
  <figcaption>
    <b>Lakes</b>
    <span>12 photos</span>
  </figcaption>
</figure>

<!-- title always showing: -->
<figure data-caption-slide-up data-peek="34">…</figure>