Solution 1 :

You were going in the right direction with getBoundingClientRect. By using this and applying some calculations on it on, I was able to come up with this

let imageResizing = false;

function zoomUnzoomImage(resizeEvent) {
    if (!resizeEvent && this.classList.contains('zoomed')) {
        this.classList.remove('zoomed');
        this.style.transform = "";
        document.querySelector('.image-backdrop').classList.remove('zoomed');
        removeZoomOutListeners();
        removeResizeListener();

    } else {
        let imageCordinates
        if (resizeEvent) {
            imageCordinates = this._originalImageCordinates;
        }
        else {
            imageCordinates = getBoundingClientRect(this);
            this._originalImageCordinates = imageCordinates;
        }

        const deviceRatio = window.innerHeight / window.innerWidth;
        const imageRatio = imageCordinates.height / imageCordinates.width;

        // Scale image according to the device and image size
        const imageScale = deviceRatio > imageRatio ?
            window.innerWidth / imageCordinates.width :
            window.innerHeight / imageCordinates.height;

        const imageX = ((imageCordinates.left + (imageCordinates.width) / 2));
        const imageY = ((imageCordinates.top + (imageCordinates.height) / 2));

        const bodyX = (window.innerWidth) / 2;
        const bodyY = (window.innerHeight) / 2;


        const xOffset = (bodyX - imageX) / (imageScale);
        const yOffset = (bodyY - imageY) / (imageScale);


        this.style.transform = "scale(" + imageScale + ") translate(" + xOffset + "px," + yOffset + "px) ";
        this.classList.add('zoomed');
        document.querySelector('.image-backdrop').classList.add('zoomed');
        registersZoomOutListeners();
        registerResizeListener();
    }
}

function registersZoomOutListeners() {
    // zoom out on scroll
    document.addEventListener('scroll', scrollZoomOut);
    // zoom out on escape
    document.addEventListener('keyup', escapeClickZoomOut);
    // zoom out on clicking the backdrop
    document.querySelector('.image-backdrop').addEventListener('click', backDropClickZoomOut);
}

function removeZoomOutListeners() {
    document.removeEventListener('scroll', scrollZoomOut);
    document.removeEventListener('keyup', escapeClickZoomOut);
    document.querySelector('.image-backdrop').removeEventListener('click', backDropClickZoomOut);
}

function registerResizeListener() {
    window.addEventListener('resize', onWindowResize)
}

function removeResizeListener() {
    window.removeEventListener('resize', onWindowResize)
}

function scrollZoomOut() {
    if (document.querySelector('.zoomable-image.zoomed') && !imageResizing) {
        zoomUnzoomImage.call(document.querySelector('.zoomable-image.zoomed'));
    }
}

function backDropClickZoomOut() {
    if (document.querySelector('.zoomable-image.zoomed')) {
        zoomUnzoomImage.call(document.querySelector('.zoomable-image.zoomed'));
    }
}

function escapeClickZoomOut(event) {
    if (event.key === "Escape" && document.querySelector('.zoomable-image.zoomed')) {
        zoomUnzoomImage.call(document.querySelector('.zoomable-image.zoomed'));
    }
}

function onWindowResize() {
    imageResizing = true;
    if (document.querySelector('.zoomable-image.zoomed')) {
        debounce(
            function () {
                zoomUnzoomImage.call(document.querySelector('.zoomable-image.zoomed'), true)
                imageResizing = false;
            }, 100)()
    }
}

function getBoundingClientRect(element) {
    var rect = element.getBoundingClientRect();
    return {
        top: rect.top,
        right: rect.right,
        bottom: rect.bottom,
        left: rect.left,
        width: rect.width,
        height: rect.height,
        x: rect.x,
        y: rect.y
    };
}
function debounce(func, delay) {
    let debounceTimer
    return function () {
        const context = this
        const args = arguments
        clearTimeout(debounceTimer)
        debounceTimer
            = setTimeout(() => func.apply(context, args), delay)
    }
}

document.addEventListener('click', function (event) {
    if (event && event.target && event.target.className.includes('zoomable-image')) {
        zoomUnzoomImage.call(event.target)
    }
});
figure {
  margin: 0 0 0 0;
  display: inline-block;
  /* Stays same width as image contents */
  background-color: whitesmoke;
}

img {
  max-width: 100%;
  /* Images should fit within their container by default */
  height: auto;
  background-color: lightgrey;
  margin: auto;
  transition: transform 0.3s;
}

.zoomable-image {
  cursor: zoom-in;
}

.zoomable-image.zoomed {
  cursor: zoom-out;
  z-index: 100;
  position: relative;
}

.image-backdrop.zoomed {
  position: fixed;
  top: 0;
  right: 0;
  left: 0;
  bottom: 0;
  z-index: 50;
  background-color: rgba(255, 255, 255, 0.95);
}
<div class="image-grid">
        <img class="zoomable-image" src="https://picsum.photos/200/400?random=1" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/400/200?random=2" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/600/200?random=3" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/600/100?random=3" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/100/400?random=4" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/400/100?random=5" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/1000?random=6" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/300/400?random=7" loading="auto" />
        <img class="zoomable-image" src="https://picsum.photos/400/300?random=8" loading="auto" />
    </div>
    
<div class="image-backdrop"></div>

Solution 2 :

Here is another idea that I use. Similar to Medium’s zoom effect.

const {
  fromEvent
} = rxjs;

const images = document.querySelectorAll('article img');
const detailModal = document.querySelector('#detail-modal');
const detailBgModal = document.querySelector('.bg');

let canShowModal = true;

detailBgModal.addEventListener("transitionend", () => {
  if (detailBgModal.style.opacity === '0') {
    const showImage = document.querySelector('[fullscreen=true]')
    showImage.style.zIndex = 0;
    detailBgModal.style.bottom = 'auto';
    showImage.removeAttribute('fullscreen')
    canShowModal = true;
  }
});

const checkIsImagePortrait = (src) => {
  return new Promise((resolve) => {
    const img = new Image();
    img.src = src;
    img.onload = () => {
      let isImagePortrait;
      const ratio = img.naturalWidth / img.naturalHeight;
      const pratio = window.innerWidth / window.innerHeight;
      console.log('pratio', pratio)
      if (ratio < pratio) {
        isImagePortrait = true;
      } else {
        isImagePortrait = false
      }
      resolve(isImagePortrait);
    };
  });
};

const showModal = (imageElement) => {
  const src = imageElement.getAttribute('src');
  const modalImage = document.querySelector('#detail-modal img');
  return checkIsImagePortrait(src).then(isPortrait => {
    const src = imageElement.getAttribute('src');
    if (isPortrait) {
      modalImage.style.height = '100%';
      modalImage.style.width = 'auto';
    } else {
      modalImage.style.height = 'auto';
      modalImage.style.width = '100%';
    }

    detailModal.style.top = `${window.scrollY}px`;
    detailModal.style.height = `${window.innerHeight}px`;
    detailModal.style.display = 'flex';

    detailBgModal.style.bottom = '0';
    detailBgModal.style.opacity = 1;
    document.querySelector('#detail-modal img').setAttribute('src', src);
  });
};

const hideModal = () => {
  detailBgModal.style.opacity = 0;
  detailModal.style.display = 'none';
  canShowModal = false;
};

let modalDetailPos;

const handleBodyScroll = () => {
  const {
    scrollY
  } = window;
  if (Math.abs(scrollY - modalDetailPos) > 50) {
    const event = new Event('click');
    detailModal.dispatchEvent(event);
    window.removeEventListener('scroll', handleBodyScroll);
  }
};

images.forEach((image) => {
  fromEvent(image, 'click').subscribe(() => {
    if (!canShowModal) {
      return
    }

    image.setAttribute('fullscreen', true)
    console.log('show image')

    showModal(image).then(() => {
      const modalImage = document.querySelector('#detail-modal img');

      const firstSnap = image.getBoundingClientRect();
      const lastSnap = modalImage.getBoundingClientRect();

      const {
        deltaX,
        deltaY,
        deltaWidth,
        deltaHeight
      } = getDelta(firstSnap, lastSnap);

      modalImage.animate([{
          transformOrigin: 'top left',
          transform: `
          translate(${deltaX}px, ${deltaY}px)
          scale(${deltaWidth}, ${deltaHeight})
        `
        },
        {
          transformOrigin: 'top left',
          transform: 'none'
        }
      ], {
        duration: 300,
        easing: 'ease-in-out',
        fill: 'both'
      }).onfinish = () => {
        modalDetailPos = window.scrollY;
        window.addEventListener('scroll', handleBodyScroll)
      };
    });
  });
})

const moveElementToFullscreen = (element) => {
  element.style.position = 'fixed';
  element.style.left = 0;
  element.style.top = 0;
  element.style.right = 0;
  element.style.bottom = 0;
};

const moveElementToNormalState = (element) => {
  element.style.position = null;
  element.style.left = null;
  element.style.top = null;
  element.style.right = null;
  element.style.bottom = null;
};

const getDelta = (firstSnap, lastSnap) => {
  const deltaX = firstSnap.left - lastSnap.left;
  const deltaY = firstSnap.top - lastSnap.top;
  const deltaWidth = firstSnap.width / lastSnap.width;
  const deltaHeight = firstSnap.height / lastSnap.height;
  return {
    deltaX: deltaX,
    deltaY: deltaY,
    deltaWidth: deltaWidth,
    deltaHeight: deltaHeight
  };
}

fromEvent(detailModal, 'click').subscribe(() => {

  const showImage = document.querySelector('[fullscreen=true]');
  if (!showImage) {
    return;
  }
  const modalImage = document.querySelector('#detail-modal img');

  console.log('showImage', showImage)
  const firstSnap = modalImage.getBoundingClientRect();
  const lastSnap = showImage.getBoundingClientRect();

  hideModal();

  const {
    deltaX,
    deltaY,
    deltaWidth,
    deltaHeight
  } = getDelta(firstSnap, lastSnap);

  showImage.style.zIndex = 100;

  showImage.animate([{
      transformOrigin: 'top left',
      transform: `
        translate(${deltaX}px, ${deltaY}px)
        scale(${deltaWidth}, ${deltaHeight})
      `
    },
    {
      transformOrigin: 'top left',
      transform: 'none'
    }
  ], {
    duration: 400,
    easing: 'ease',
    fill: 'both'
  });
});
article {
  max-width: 700px;
  margin: 0 auto;
  padding: 20px;
  box-sizing: border-box;
}

p {
  font-family: 'Nunito';
  font-size: 18px;
  color: rgba(0, 0, 0, .84);
  line-height: 1.60;
  margin: 30px auto;
}

article img {
  max-width: 100%;
  display: block;
  position: relative;
  cursor: zoom-in;
}

#detail-modal {
  justify-content: center;
  align-items: center;
  display: none;
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}

#detail-modal img {
  display: block;
  position: relative;
  z-index: 100;
  cursor: zoom-out;
}

.bg {
  position: fixed;
  left: 0;
  top: 0;
  right: 0;
  background-color:  rgba(0,0,0,.3);
  opacity: 0;
  display: block;
  transition: opacity .3s;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.4.0/rxjs.umd.min.js"></script>
<article>
  <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut rutrum mauris id nibh ultrices, vitae hendrerit nibh venenatis. Phasellus volutpat mauris in diam lacinia, sit amet blandit ante scelerisque. Mauris porttitor risus sit amet urna vestibulum
    porta. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Integer id diam sem. Nunc commodo, est sed efficitur condimentum, massa purus facilisis tellus, at commodo ex est a tellus. Morbi quis iaculis mi. Nam et
    iaculis sapien, at mattis ipsum.</p>
  <div>
    <img src="https://images.unsplash.com/photo-1507358522600-9f71e620c44e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2850&q=80" />
  </div>
  <p>Nullam non porttitor nibh. Etiam mollis libero turpis, vitae sagittis ipsum gravida nec. Vivamus diam sapien, laoreet vel mi ultrices, efficitur tristique nunc. Nam tempus pharetra felis, nec condimentum leo vehicula a. Duis rutrum orci a tellus tristique
    scelerisque. Suspendisse potenti. Proin mollis turpis feugiat, pulvinar risus ac, scelerisque diam. Aenean sodales venenatis tellus, in lacinia sapien. Nam tempus efficitur ligula id feugiat. Donec pretium, nunc sit amet dignissim rutrum, urna est
    tristique ante, id convallis arcu urna vel dui. Cras a metus id orci aliquet tincidunt eget ac mi. Pellentesque elementum lorem in elementum vehicula. Nunc et dolor orci. Nulla varius lorem metus, vel cursus leo ultricies non.</p>
  <div>
    <img src="https://images.unsplash.com/photo-1548636200-691c76f69390?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=668&q=80" />
  </div>
  <p>
    Aliquam at arcu mauris. Curabitur tincidunt massa ut sem porttitor ornare. Duis dapibus dignissim lectus. Cras sodales urna vitae libero lobortis, in consequat dolor efficitur. Sed eleifend nibh mi, sit amet euismod sem faucibus sed. Aenean ac accumsan
    libero, ut dictum ex. Aenean tincidunt gravida enim, in luctus ante volutpat eu. Curabitur sed orci nec nisi cursus blandit.
  </p>
  <div>
    <img src="https://images.unsplash.com/reserve/fPuLkQNXRUKI6HQ2cMPf_IMG_4761.jpg?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1650&q=80" />
  </div>
  <p>
    Morbi ac quam luctus, aliquam odio in, consectetur orci. Etiam et dui sollicitudin, congue odio sit amet, commodo metus. Nunc ac facilisis dolor, sit amet dignissim dui. Praesent vehicula ut dui hendrerit commodo. Vivamus ac elementum turpis. Proin non
    erat semper, dignissim risus vel, ornare libero. Ut volutpat libero non lacus eleifend ultrices. Morbi augue massa, placerat eget eros vel, consequat tincidunt sapien. Vestibulum placerat diam placerat tincidunt lacinia. Proin lorem justo, viverra
    pretium laoreet eu, condimentum et odio. Proin vitae nibh felis.
  </p>
</article>


<div class="bg"></div>
<div id="detail-modal">
  <img />

</div>

quoted from here

Problem :

I want to animate an image when clicked to fill the whole screen, in such a way that seamlessly transitions from its original position to its full size, and back again, like on Medium.

The problem here is that the CSS position property, with top and left, is not animatable. After trying that, I thought of using transform: scale() properties, but this will lead to a bunch of calculations that I’d like to avoid if possible.

My complicated solution would be to get the element’s original position using getBoundingClientRect(), and from there find the end position the image must be in, and create a custom animation every time the image gets blown to full size using Element.animate. I’m not sure that’s the best way to go about this, as figuring out the final size and position of the image will be some extra math I don’t really want to mess with.

Below is my current markup, and some CSS showing it’s possible to keyframe a positional animation using translateX() and translateY(), but not as I really need to.

document.querySelector('picture').onclick = function () {
  document.querySelector('picture').classList.toggle('modal')
}
<style>
   figure {
      margin: 0 0 0 0;
      display: inline-block; /* Stays same width as image contents */
      background-color: whitesmoke;
   }

   img {
      max-width: 100%; /* Images should fit within their container by default */
      height: auto;
      background-color: lightgrey;
      margin: auto;
   }

   picture.modal {
      position: fixed;
      top: 0;
      left: 0;
      background-color: black;
      height: 100vh;
      width: 100vw;
      margin: 0 0;
      display: flex;
      align-content: center;
      object-fit: contain;
   }
   picture.modal img {
      animation-name: slidein;
      animation-duration: 1s;
   }

   @keyframes slidein {
      0% {
         transform: translateX(30px);
      }
      100% {
         transform: translateX(0);
      }
   }

   figcaption {
      padding: 8px; /* Matches default page margin for Chrome/Edge */
   }
</style>
<figure>
   <picture>
      <img src="https://c.pxhere.com/images/12/30/5e283733ff3cd2bd18d7cc13f40a-1435525.jpg!d" loading="auto" />
   </picture>
   <figcaption>
      <header>Title</header>
      <footer>Description</footer>
   </figcaption>
</figure>

I started stubbing out some code as below, but quickly realized that another solution may be much better.

// Get the position of elements for animation
let x = document.querySelector('img').getBoundingClientRect().x
let y = document.querySelector('img').getBoundingClientRect().y

// Set the animation on the image so that it moves smoothly from its position outwards

Help with a vanilla CSS solution, if it is known, would be greatly appreciated.

Comments

Comment posted by github.com/DenisLabrecque/OrangeTwenty-Default-Shorthand-HTML

The only thing to add to this would be managing different image sizes properly (pretty easy) and adding event listeners for window resize, which means that image scaling will become a separate function. Still working on other stuff

Comment posted by Prakhar

@Denis Thanks for recognizing the effort. I am glad it was helpful. Also, I am working on the two features which you have mentioned. Will update the answer once ready.

Comment posted by Prakhar

I have updated it to add support for different device and image sizes. I was a simple fix, as you had mentioned. Working on resizing now.

Comment posted by Prakhar

Updated to add resizing, although the implementation can be improved.

Comment posted by Denis G. Labrecque

This answer is probably the best because it reacts so well to different screen formats and sizes.

By

Leave a Reply

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