Scaling featured images between posts in Elementor

If you have ever wanted an Elementor featured image transition that feels smooth and seamless, this little script does the job without heavy plugins or extra markup. It works right out of the box on WordPress with Elementor, adding a smooth featured image scaling effect to related posts or Elementor loops.

The script takes the featured image from a related post or any image inside an Elementor loop and smoothly scales it to match the featured image position on the next post or page you visit. Instead of just fading or snapping into place, it grows in size so it feels like it follows you to the next screen.

The best part is that no extra classes or custom HTML are needed. It targets the existing markup that Elementor and WordPress already use, so you can drop the code in and go.

Note: you will need Elementor Pro if you want to use this inside an Elementor loop since the loop builder is part of Pro.

It also works smoothly between different object-fit styles. Whether your images are cover, contain, or switching between them, the script adjusts the scaling so the transition looks natural. It can also scale between different corner styles such as square to rounded, rounded to square, or keeping the shape consistent during the animation.

How it works

Detects the featured image or loop image that has been clicked

Captures its position and size on the current page

Transitions it to the featured image position on the next post or page you visit

Scales it smoothly to create a growth effect

Handles scaling between object-fit: cover and object-fit: contain

Adjusts for different corner styles so rounded or square edges transition cleanly

The code


(function () {
/**
* IMAGE PRE-HIDE SCRIPT:
* Runs immediately on page load (before DOM is ready).
* Purpose: If there's a stored animation payload from the previous page,
* hide the matching featured image right away to prevent a flicker before animation.
*/
try {
var raw = sessionStorage.getItem('wp-img-xfade');
if (raw) {
var data = JSON.parse(raw);
if (data && data.attachmentId) {
var css = 'img.wp-image-' + data.attachmentId + '{visibility:hidden!important}';
var style = document.createElement('style');
style.id = 'wp-img-xfade-prehide';
style.appendChild(document.createTextNode(css));
document.documentElement.appendChild(style);
}
}
} catch (_) {}

/**
* IMAGE CLICK + TRANSITION ANIMATION SCRIPT:
* Handles two things:
* 1. On click in the post loop: store position/size of clicked featured image and navigate.
* 2. On single post load: animate a clone of the old image into place over the featured image.
*/

/* =====================
USER-CONFIGURABLE SETTINGS
===================== */
var CFG = window.FIT_CONFIG || {};

// Animation duration in milliseconds (min: 100, max: 5000)
var DURATION = Math.max(100, Math.min(5000, parseInt(CFG.durationMs, 10) || 600));

// Easing function for the animation
// Try: 'ease', 'ease-in', 'ease-out', 'linear', or cubic-bezier values
var EASING = CFG.easing || 'cubic-bezier(.2,0,.2,1)';

// Fade-out duration for the image at the end (in seconds)
var FADE_OUT_DURATION = 0.25; // seconds

/* ===================== */
var DUR_SEC = DURATION / 1000;

function isPlainClick(e, a) {
if (e.defaultPrevented) return false;
if (e.button !== 0) return false;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false;
if (a.target && a.target.toLowerCase() === '_blank') return false;
try { if (new URL(a.href, location.href).origin !== location.origin) return false; } catch (_) { return false; }
return true;
}

function resolveSrc(img) {
return img.currentSrc || img.getAttribute('src') || img.getAttribute('data-src');
}

function findAnchorWithWpImage(start) {
var el = start;
while (el && el !== document.body) {
if (el.tagName === 'A') {
var img = el.querySelector('img[class*="wp-image-"]');
if (img && resolveSrc(img)) return { anchor: el, img: img };
}
el = el.parentElement;
}
return null;
}

function getTopFixedOffset() {
var total = 0;
var admin = document.getElementById('wpadminbar');
if (admin && getComputedStyle(admin).position === 'fixed') total += admin.getBoundingClientRect().height;
var candidates = document.querySelectorAll('header, .site-header, .elementor-location-header, [data-elementor-type="header"]');
for (var i = 0; i < candidates.length; i++) {
var el = candidates[i];
var cs = getComputedStyle(el);
if (cs.position === 'fixed' && Math.round(el.getBoundingClientRect().top) === 0) {
total += el.getBoundingClientRect().height;
break;
}
}
return Math.round(total);
}

function readCornerRadiiPx(el) {
var cs = getComputedStyle(el);
function splitPair(v) {
var parts = (v || '0').toString().trim().split(/\s+/);
if (parts.length === 1) parts.push(parts[0]);
return parts;
}
function toPxOrPct(s) { return s.endsWith('%') ? s : parseFloat(s) || 0; }
var TL = splitPair(cs.borderTopLeftRadius).map(toPxOrPct);
var TR = splitPair(cs.borderTopRightRadius).map(toPxOrPct);
var BR = splitPair(cs.borderBottomRightRadius).map(toPxOrPct);
var BL = splitPair(cs.borderBottomLeftRadius).map(toPxOrPct);
return [TL, TR, BR, BL];
}

function cornersToPercentString(corners, w, h) {
function pct(val, base) {
if (typeof val === 'string' && val.endsWith('%')) return parseFloat(val);
var px = Number(val) || 0;
return Math.max(0, (px / base) * 100);
}
var TL = corners[0], TR = corners[1], BR = corners[2], BL = corners[3];
var hx = [ pct(TL[0], w), pct(TR[0], w), pct(BR[0], w), pct(BL[0], w) ];
var hy = [ pct(TL[1], h), pct(TR[1], h), pct(BR[1], h), pct(BL[1], h) ];
return hx[0] + '% ' + hx[1] + '% ' + hx[2] + '% ' + hx[3] + '% / ' +
hy[0] + '% ' + hy[1] + '% ' + hy[2] + '% ' + hy[3] + '%';
}

// CLICK HANDLER: store image data and navigate
document.addEventListener('click', function (e) {
var found = findAnchorWithWpImage(e.target);
if (!found) return;
var anchor = found.anchor;
var img = found.img;
if (!isPlainClick(e, anchor)) return;

var rect = img.getBoundingClientRect();
var classes = img.getAttribute('class') || '';
var m = classes.match(/wp-image-(\d+)/);
var attachmentId = m ? m[1] : null;
var src = resolveSrc(img);
if (!src) return;

var cs = getComputedStyle(img);
var startCorners = readCornerRadiiPx(img);
var startRadiusPercent = cornersToPercentString(startCorners, rect.width, rect.height);
var payload = {
src: src,
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
startRadiusPercent: startRadiusPercent,
attachmentId: attachmentId,
topBars: getTopFixedOffset(),
objectFit: cs.objectFit || '',
objectPosition: cs.objectPosition || ''
};
sessionStorage.setItem('wp-img-xfade', JSON.stringify(payload));
e.preventDefault();
setTimeout(function () { window.location.href = anchor.href; }, 60);
}, true);

// LOAD HANDLER: play animation into place
document.addEventListener('DOMContentLoaded', function () {
var raw = sessionStorage.getItem('wp-img-xfade');
if (!raw) return;
sessionStorage.removeItem('wp-img-xfade');

var data = JSON.parse(raw);
var target = data.attachmentId
? document.querySelector('img.wp-image-' + data.attachmentId)
: document.querySelector('img[class*="wp-image-"]');
if (!target) return;

var targetSrc = resolveSrc(target);
if (!targetSrc) return;

var targetCS = getComputedStyle(target);
var prevVis = target.style.visibility;
target.style.visibility = 'hidden';

var clone = document.createElement('img');
clone.src = data.src;
Object.assign(clone.style, {
position: 'fixed',
top: data.top + 'px',
left: data.left + 'px',
width: data.width + 'px',
height: data.height + 'px',
zIndex: '9999',
objectFit: data.objectFit || '',
objectPosition: data.objectPosition || '',
pointerEvents: 'none',
opacity: '1',
// Adjust these values to change animation speed/easing
transition: [
'top ' + DUR_SEC + 's ' + EASING,
'left ' + DUR_SEC + 's ' + EASING,
'width ' + DUR_SEC + 's ' + EASING,
'height ' + DUR_SEC + 's ' + EASING,
'border-radius ' + DUR_SEC + 's ' + EASING,
'opacity ' + FADE_OUT_DURATION + 's linear'
].join(', '),
willChange: 'top, left, width, height, border-radius'
});
clone.style.borderRadius = data.startRadiusPercent;
document.body.appendChild(clone);

function animateToTarget() {
var r = target.getBoundingClientRect();
var targetCorners = readCornerRadiiPx(target);
var targetRadiusPercent = cornersToPercentString(targetCorners, r.width, r.height);
var destTopBars = getTopFixedOffset();
var topPx = r.top + (destTopBars - (data.topBars || 0));

clone.style.top = topPx + 'px';
clone.style.left = r.left + 'px';
clone.style.width = r.width + 'px';
clone.style.height = r.height + 'px';
clone.style.objectFit = targetCS.objectFit || data.objectFit || '';
clone.style.objectPosition = targetCS.objectPosition || data.objectPosition || '';
clone.style.borderRadius = targetRadiusPercent;
}

var started = false;
var pre = new Image();
pre.onload = function () {
clone.src = targetSrc;
requestAnimationFrame(function () {
animateToTarget();
started = true;
});
};
pre.src = targetSrc;

setTimeout(function () { if (!started) animateToTarget(); }, 200);
setTimeout(function () {
target.style.visibility = prevVis || 'visible';
var preStyle = document.getElementById('wp-img-xfade-prehide');
if (preStyle && preStyle.parentNode) preStyle.parentNode.removeChild(preStyle);
clone.style.opacity = '0';
}, DURATION + 20);
setTimeout(function () {
if (clone.parentNode) clone.parentNode.removeChild(clone);
}, DURATION + 300);
});
})();


AI assisted code

Where to add it

Option 1. Elementor Custom Code

Go to Elementor > Custom Code > Add New

Paste your script

Set Location to Head

Leave Priority at the default unless you need it earlier

Publish and assign to the whole site

Note: do not set it to the footer. The script should run with the image available early so it can measure and animate correctly.

Option 2. Enqueue via functions.php

Create a file for your script, for example:
/wp-content/themes/your-child-theme/js/cm-featured-scale.js

Then add this to your child theme functions.php:

// Enqueue the Featured Image Scale script in the head.
function cm_enqueue_featured_scale_script() {
wp_enqueue_script(
'cm-featured-scale',
get_stylesheet_directory_uri() . '/js/cm-featured-scale.js',
array(), // dependencies, e.g. array('jquery') if used
'1.0.0', // version for cache busting
false // false loads in head, true loads in footer
);
}
add_action('wp_enqueue_scripts', 'cm_enqueue_featured_scale_script');

Or if you prefer to add the JS inline :

function cm_inline_featured_scale_script() {
wp_register_script('cm-featured-scale-inline', false);
wp_enqueue_script('cm-featured-scale-inline');

$script = <<<JS
// your JavaScript goes here
JS;

wp_add_inline_script('cm-featured-scale-inline', $script);
}
add_action('wp_enqueue_scripts', 'cm_inline_featured_scale_script');

Why use it

Keeps users visually engaged between clicks

Adds personality without heavy animation libraries

Works with any Elementor loop or related post section

Adjusts for different object-fit settings automatically

Scales cleanly between square and rounded corners

Possible future features

Right now this is a lightweight script. If there is interest, I may turn it into a plugin with:

Easy settings for animation speed, scaling type, and easing

An option to add a simple class so the animation can work outside of the Elementor loop, for example scaling images that link directly to other pages