4 min read

Table of Contents
AI-Friendly Page Summary

Learn how to add a smooth featured image scaling animation between posts or pages in WordPress using Elementor. Includes JavaScript code, setup steps, and notes on future plugin features.

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

<script>
(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);
  });
})();
</script>

AI assisted code

Where to add it

Option 1. Elementor Custom Code

  1. Go to Elementor > Custom Code > Add New
  2. Paste your script
  3. Set Location to Head
  4. Leave Priority at the default unless you need it earlier
  5. 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
Author: Matthew Reilly

Leave a Reply

Your email address will not be published. Required fields are marked *

Related

Xd Figma Penpot

Penpot: A Real Alternative to XD and Figma?

UI/UX
Reading Time to Your WordPress

How to Add Reading Time to Your WordPress Posts (No Plugin Needed)

Wordpress Code
Menu Backup and Restore Wordpress Plugin

How to Back Up & Restore WordPress Menus with a Simple Plugin

Wordpress Plugin