Tilt-shift Miniature
Blur the top and bottom of a photo, leave a sharp band across the middle and punch up the colours, and a real place starts to look like a tiny model village. The blur closes in when it comes into view, then the sharp band drifts slowly up and down like a camera hunting for focus.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-tilt-shift-miniatureand give the wrapper a size. The image fills it, cropped to cover. - It works with any image, even from another site. Photos taken from high up, looking down at a slant, look the most like models.
Options
Add any of these attributes to the same element.
data-focus | Where the sharp band sits, as a percentage from the top. Default 55. |
data-width | Half the height of the sharp band, as a percentage. Default 10. |
data-blur | The blur at the far top and bottom. Default 8 px. |
data-drift | How far the band wanders up and down, as a percentage. Default 8; 0 keeps it still. |
data-duration | Time for one full wander. Default 9000 ms. |
data-saturate | How much the colours are boosted. Default 1.45. |
With reduced motion the effect is there from the start and stays still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Tilt-shift Miniature: blurs the top and bottom of a photo, leaving a
// sharp band across the middle, and punches up the colour, so a real scene
// looks like a tiny model. The blur closes in when it comes into view and
// the sharp band drifts slowly up and down, like a camera hunting for focus.
// Usage: <figure data-tilt-shift-miniature><img src="photo.jpg" alt="…"></figure>
// The image (any <img>, or an inline <svg>) fills the box, cropped to cover.
// Works best on photos taken from high up, looking down at a slant.
// Optional: data-focus (where the sharp band sits, % from the top, default
// 55), data-width (half the height of the sharp band, %, default 10),
// data-blur (blur at the far edges, px, default 8), data-drift (how far the
// band wanders, %, default 8; 0 keeps it still), data-duration (one full
// wander, ms, default 9000), data-saturate (colour boost, default 1.45).
var DEFAULTS = { focus: 55, width: 10, blur: 8, drift: 8, duration: 9000, saturate: 1.45 };
// Each blur layer is masked away inside the sharp band and a little beyond, a little
// further for each stronger layer, so the blur builds up smoothly away from the band.
var masks = '';
for (var k = 1; k <= 3; k++) {
var inner = 'calc(var(--tsm-w) + ' + (k - 1) + ' * var(--tsm-s))';
var outer = 'calc(var(--tsm-w) + ' + k + ' * var(--tsm-s))';
var g = 'linear-gradient(#000 0,#000 calc(var(--tsm-f) - ' + outer + '),transparent calc(var(--tsm-f) - ' + inner + '),' +
'transparent calc(var(--tsm-f) + ' + inner + '),#000 calc(var(--tsm-f) + ' + outer + '),#000 100%)';
masks += '.tsm-b' + k + '{-webkit-mask-image:' + g + ';mask-image:' + g + '}';
}
var css =
'.tsm{position:relative;overflow:hidden;--tsm-f:55%;--tsm-w:50%;--tsm-s:9%}' +
'.tsm-src{opacity:0}' +
'.tsm-layer{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;max-width:none}' +
'.tsm-vig{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% var(--tsm-f),transparent 55%,rgba(0,0,0,.28))}' +
masks;
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// An image URL for the layers: the <img> itself, or the inline <svg> as a data URL.
function sourceUrl(node) {
if (node.tagName.toLowerCase() !== 'svg') return node.currentSrc || node.src;
var vb = node.viewBox && node.viewBox.baseVal;
var clone = node.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (vb && vb.width) { clone.setAttribute('width', vb.width); clone.setAttribute('height', vb.height); }
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
}
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
function setup(el, src) {
var d = el.dataset;
var focus = num(d.focus, DEFAULTS.focus), width = num(d.width, DEFAULTS.width);
var blur = num(d.blur, DEFAULTS.blur), drift = num(d.drift, DEFAULTS.drift);
var duration = num(d.duration, DEFAULTS.duration), sat = num(d.saturate, DEFAULTS.saturate);
var url = sourceUrl(src);
var fit = src.tagName.toLowerCase() === 'svg' ? '' : getComputedStyle(src).objectPosition;
// A sharp, colour-boosted copy, then three blurrier copies on top. Blurred copies are
// scaled up a touch so their soft edges never show.
for (var k = 0; k <= 3; k++) {
var im = document.createElement('img');
im.src = url;
im.alt = '';
im.setAttribute('aria-hidden', 'true');
im.className = 'tsm-layer' + (k ? ' tsm-b' + k : '');
if (fit) im.style.objectPosition = fit;
var b = blur * [0, 0.3, 0.6, 1][k];
im.style.filter = 'saturate(' + sat + ') contrast(1.08)' + (k ? ' blur(' + b.toFixed(1) + 'px)' : '');
if (k) im.style.transform = 'scale(' + (1 + b / 120).toFixed(3) + ')';
el.appendChild(im);
}
var vig = document.createElement('span');
vig.className = 'tsm-vig';
vig.setAttribute('aria-hidden', 'true');
el.appendChild(vig);
src.classList.add('tsm-src');
var state = { f: focus, w: width, t: 0 };
function apply() {
el.style.setProperty('--tsm-f', state.f.toFixed(2) + '%');
el.style.setProperty('--tsm-w', state.w.toFixed(2) + '%');
}
if (reduced()) { apply(); return; }
// Start fully sharp; the blur closes in from the edges once it is seen.
state.w = 60;
apply();
var last = 0;
var timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(60, now - last) : 16;
last = now;
state.t += dt / duration;
state.f = focus + Math.sin(state.t * Math.PI * 2) * drift;
apply();
}
});
var shown = false;
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) {
if (!shown) {
shown = true;
anime.animate(state, { w: width, duration: 1800, delay: 250, ease: 'inOut(3)', onUpdate: apply });
}
last = 0;
if (drift) timer.play();
} else timer.pause();
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-tilt-shift-miniature]').forEach(function (el) {
if (el._tsm) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._tsm = true;
el.classList.add('tsm');
setup(el, src);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-tilt-shift-miniature style="height:400px">
<img src="village.jpg" alt="A village seen from a hilltop">
</figure>