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.
How to use it
- Paste the snippet just before
</head>. - Add
data-caption-slide-upto a<figure>holding a picture. - Put the caption in a
<figcaption>, or any child markeddata-caption. Each line inside it rises in turn.
Options
Add any of these attributes to the same element.
data-peek | How much of the caption shows at rest, in px. The first line stays in view and the rest slide up. Default 0. |
data-duration | How long the slide takes. Default 550 ms. |
data-stagger | Gap between the caption’s lines. Default 70 ms. |
data-zoom | How far the picture scales up while the caption shows. Default 1.06; 1 for none. |
data-autoplay | Slide 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.
<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><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>