<!-- //// -->
<!-- Tile -->
<!-- //// -->

<div class="tile">
  <!-- Why the extra first and last class when you could use :first-child and :last-child you ask? -->
  <!-- It's so that I don't have to load a extra JS file to allow Greensock to target pseudo elements. -->
  <!-- This way is a little uglier but more performant. -->
  <img alt="" class="tile__img tile__img--first" />
  <img alt="" class="tile__img tile__img--last" />
  <!-- img tags don't validate without src tag :(, let's pretend like we didn't see that ;-P... -->
  <!-- ...because this will be implemented into a static site generator, right? riggght? :X  -->

  <div class="title">
    &nbsp;<br />&nbsp;
    <div class="title__container">
      <div class="title__text title__text--first"></div>
      <div class="title__text title__text--last"></div>
    </div>
  </div>
</div>

<!-- /////////// -->
<!-- Next button -->
<!-- /////////// -->

<button class="next-tile">
    <span class="next-tile__details">
      <span class="next-tile__heading">Up next</span>
      <span class="next-tile__title">
        &nbsp; <br />&nbsp;
        <span class="next-tile__title__text next-tile__title__text--first"></span>
        <span class="next-tile__title__text next-tile__title__text--last"></span>
      </span>
      <svg class="next-tile__arrow" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 42.6 20.1" style="enable-background:new 0 0 42.6 20.1;" xml:space="preserve">
        <path class="st0" d="M0,8.2h35.5l-5.6-5.6L32.5,0l10.1,10.1L32.5,20.1l-2.6-2.6l5.6-5.6H0V8.2z"/>
      </svg>
      <div class="test-arrow"></div>
    </span>
    <span class="next-tile__preview">
      <img class="next-tile__preview__img next-tile__preview__img--first" alt="" />
      <img class="next-tile__preview__img next-tile__preview__img--last" alt="" />
    </span>
  </button>
html,
body {
  height: 100%;
}

html {
  box-sizing: border-box;
  font-size: 62.5%;
}

*,
*:before,
*:after {
  box-sizing: inherit;
}

body {
  margin: 0;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-family: 'Open Sans', sans-serif;
  background: #000;
}

/* ---- */
/* Tile */
/* ---- */

.tile {
  width: 100%;
  height: 100%;
  background: #000;
  position: relative;
  overflow: hidden;
}

.title {
  position: absolute;
  top: 50%;
  left: 10rem;
  color: #fff;
  font-size: 5.4rem;
  font-weight: 700;
  line-height: 1.3;
  text-shadow: 0 0.1rem 0 rgba(0,0,0, 0.15);
  letter-spacing: -0.02rem;
  overflow: hidden;
}

.title--last {
  opacity: 0;
}

.title__container {
  position: absolute;
  top: 0;
  left: 0;
}

.tile__img {
  position: absolute;
  width: 100%;
  height: auto;
}

/* --------- */
/* Next tile */
/* --------- */

.next-tile {
  position: absolute;
  top: 50%;
  right: 0;
  transform: translateY(-50%);
  display: flex;
  border-top-left-radius: 0.1rem;
  border-bottom-left-radius: 0.1rem;
  overflow: hidden;
  padding: 0;
  background: transparent;
  border: 0;
  cursor: pointer;
  outline: none;
  z-index: 100;
  margin: 0;
  -webkit-tap-highlight-color: rgba(0,0,0,0);
}

.next-tile__details {
  width: 20rem;
  height: 33rem;
  background: #fff;
  text-align: left;
  display: flex;
  justify-content: flex-end;
  flex-direction: column;
  padding: 5rem 3.5rem;
  z-index: 10;
  position: relative;
  box-shadow: 0.3rem 0 1rem 0 rgba(0,0,0,0.26);
}

.next-tile__heading {
  margin-bottom: 4.5rem;
  text-transform: uppercase;
  color: #b3b3b3;
  font-weight: 600;
  display: block;
}

.next-tile__title {
  margin-bottom: 6rem;
  font-size: 2rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.05rem;
  color: #222222;
  display: block;
  position: relative;
}

.next-tile__title__text {
  position: absolute;
  top: 0;
  left: 0;
}

.next-tile__title__text--last {
  opacity: 0;
}

.next-tile__arrow {
  fill: #b3b3b3;
  width: 2.4rem;
  display: block;
}

.next-tile__preview {
  width: 16rem;
  height: 33rem;
  background: #000;
  position: relative;
  overflow: hidden;
  display: block;
}

.next-tile__preview img {
  position: absolute;
  width: 100%;
  height: auto;
}

img.next-tile__preview__img--last {
  opacity: 0;
  transform: translateY(-50%) scale(1.6);
  transform-origin: 50% 50%;
}

/* This was a very quick mockup of the mobile view, if this was a client project I... */
/* ...would put a little more thought into it and try to flush out something more usable on mobile... */
/* ...as it sits the image wastes a lot of space, something better can be done here. */

/* Oh and do it mobile first, this will prevent you from having to overwrite so many properties */

@media (max-height: 400px) {
  .next-tile__preview {
    height: 23rem;
  }

  .next-tile__details {
    height: 23rem;
    padding: 3rem 2.5rem;
  }

  .next-tile__title {
    margin-bottom: 4rem;
  }
}

@media (max-width: 1180px) {
  html { font-size: 52.5%; }
}

@media (max-width: 990px) {
  html { font-size: 42.5%; }
}

@media (max-width: 900px) {
  .title {
    font-size: 3.8rem;
    top: 37%;
    left: 10%;
  }

  .next-tile {
    top: auto;
    bottom: 4rem;
    transform: translateX(0);
  }

  .next-tile__preview {
    height: 100%;
    overflow: visible;
  }

  .next-tile__details {
    height: auto;
    padding: 2rem;
  }

  .next-tile__heading {
    margin-bottom: 1rem;
  }

  .next-tile__title {
    margin-bottom: 1rem;
  }
}
const tiles = [
  {
    image: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/49240/ui8-alex-shutin-kKvQJ6rK6S4-unsplash.jpg',
    thumb: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/49240/ui8-alex-shutin-kKvQJ6rK6S4-unsplash--thumb.jpg',
    title: 'Summer treads on <br />the heels of spring.',
    nextTitle: 'Blue <br />Mountains'
  },
  {
    image: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/49240/ui8-marat-gilyadzinov-MYadhrkenNg-unsplash.jpg',
    thumb: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/49240/ui8-marat-gilyadzinov-MYadhrkenNg-unsplash--thumb.jpg',
    title: 'Jellyfish make <br />everything better.',
    nextTitle: 'Squishy <br />Jellies'
  },
  {
    image: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/49240/ui8-luca-bravo-bTxMLuJOff4-unsplash.jpg',
    thumb: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/49240/ui8-luca-bravo-bTxMLuJOff4-unsplash--thumb.jpg',
    title: 'Design adds value <br />faster than it adds costs.',
    nextTitle: 'Paper <br />Cut'
  },
];

let activeIndex = 0;
const nextButton = document.querySelector('.next-tile');
updateTileRatio();
populateInitialData();
nextButton.addEventListener('click', nextTile);

// ---------------------
// Populate initial data
// ---------------------

function populateInitialData() {
  // It would be better to target the individual elements as you can't be sure that the arrays below...
  // ...will only contain 2 items. But it's my pen and I'm sureee that there's only 2 elements ;-P
  const tileImages = document.querySelectorAll('.tile__img');
  tileImages[0].src = `${tiles[activeIndex].image}`;
  tileImages[1].src = `${tiles[getNextIndex()].image}`;

  const tileTitles = document.querySelectorAll('.title__text');
  tileTitles[0].innerHTML = tiles[activeIndex].title;
  tileTitles[1].innerHTML = tiles[getNextIndex()].title;

  const nextButtonImages = document.querySelectorAll('.next-tile__preview__img');
  nextButtonImages[0].src = `${tiles[getNextIndex()].thumb}`;
  nextButtonImages[1].src = `${tiles[getNextIndex(1)].thumb}`;

  const nextButtonTitles = document.querySelectorAll('.next-tile__title__text');
  nextButtonTitles[0].innerHTML = tiles[getNextIndex()].nextTitle;
  nextButtonTitles[1].innerHTML = tiles[getNextIndex(1)].nextTitle;
}

// ------------------------
// Set the tile image ratio
// ------------------------

// Why are we doing this and not just using object: cover in CSS?
// Large images, cover, Chrome, and Greensock don't play well together. On the first tile transition...
// ...you will see a noticable studder. This disappears on initial transitions but it's enough to prevent me from using it...
// If anybody knows a workaround to prevent the studder, please let me know!

function updateTileRatio() {
  const browserWidth = document.body.clientWidth;
  const browserHeight = document.body.clientHeight;
  const browserRatio = browserWidth/browserHeight;
  const imageWidth = 3000; // Yeah yeah yeah, magic numbers... let's just say this is what my spec is set to - if we have to use a different size we will find another way to get the values
  const imageHeight = 2000;
  const imageRatio = imageWidth/imageHeight;
  const tileImages = document.querySelectorAll('.tile__img');

  // This could be a bit better if we checked to see if we even need to fire the stuff below...
  // ...if the ratio is still the same with a browser resize we should just skip over all of this code. #laziness #itsjustapen

  if (browserRatio < imageRatio) {
    for(let i = 0; i < tileImages.length; i++) {
      tileImages[i].style.width = 'auto';
      tileImages[i].style.height = '100%';
    }
  } else {
    for(let i = 0; i < tileImages.length; i++) {
      tileImages[i].style.width = '100%';
      tileImages[i].style.height = 'auto';
    }
  }
}

// ---------------
// Screen resized!
// ---------------

window.addEventListener('resize', screenResized);

// You might want to use a debouncer or something to prevent this function from firing too many times...
// ...but for this demo we will leave it (https://davidwalsh.name/javascript-debounce-function)
function screenResized() {
  updateTileRatio();
}

// ---------------
// Title animation
// ---------------

const titleAnimation = new TimelineMax({ paused: true })
      .to('.title__container', 0.8, {ease: Power2.easeOut, yPercent: -50}, 'titleAnimation')
      .to('.title__text--first', 0.5, {opacity: 0}, 'titleAnimation')
      .eventCallback('onComplete', () => {
        // Update the titles and reset the animation so that we could...
        // ...just play the same animation on next click
        titleAnimation.progress(0).pause();

        const titles = document.querySelectorAll('.title__text');
        titles[0].innerHTML = tiles[activeIndex].title;
        titles[1].innerHTML = tiles[getNextIndex()].title;
      });

// --------------------------
// Next tile button animation
// --------------------------

// Mixing css set properties with Greensock properties causes rendering issues...
// ...so it's best to set positioning of anything that will change using .set()
// https://greensock.com/forums/topic/20822-animation-co-ordinates-wrong-after-resize/?tab=comments#comment-97600
TweenMax.set('.next-tile__preview img', {top: '50%', right: '0', y: '-50%'});
TweenMax.set('.tile__img', {top: '50%', left: '50%', x: '-50%', y: '-50%'});
TweenMax.set('.tile__img--last', {scale: 1.2, opacity: 0.001}); // Setting opacity 0 here causes lag on initial play, this dissapears later on, will open a ticket and see if this is a known issue
TweenMax.set('.tile__img--first, .title__img--last', {yPercent: -50, xPercent: -50});
TweenMax.set('.title', {y: '-50%', width: '100%'});
TweenMax.set('.title__container', {width: '100%'});

// Text change animation
const nextTextAnimation = new TimelineMax({ paused: true })
      .to('.next-tile__title__text--first', 0.4, {opacity: 0}, 'textChange')
      .to('.next-tile__title__text--last', 0.4, {opacity: 1}, 'textChange');

// Slide next tile to reveal new image
const titles = document.querySelectorAll('.next-tile__title__text');
const tileImages = document.querySelectorAll('.tile__img');
const previewImages = document.querySelectorAll('.next-tile__preview__img');
const nextButtonAnimation = new TimelineMax({ paused: true })
      .to('.next-tile__details', 0.6, {ease: Power1.easeOut, xPercent: 80})
      .to('.tile__img--last', 0.6, {ease: Sine.easeOut, opacity: 1, scale: 1}, 0)
      .to('.next-tile__preview__img--first', 0, {opacity: 0}, 'sliderClosed')
      .to('.next-tile__preview__img--last', 0.6, {ease: Sine.easeOut, opacity: 1, scale: 1}, 'sliderClosed')
      .to('.next-tile__details', 0.5, {ease: Sine.easeOut, xPercent: 0}, 'sliderClosed+=0.15')
      .add(() => nextTextAnimation.play(), '-=0.5')
      .eventCallback('onComplete', () => {
        nextButtonAnimation.progress(0).pause();
        nextTextAnimation.progress(0).pause();

        tileImages[0].src = `${tiles[activeIndex].image}`;
        tileImages[1].src = `${tiles[getNextIndex()].image}`;
        
        previewImages[0].src = `${tiles[getNextIndex()].thumb}`;
        previewImages[1].src = `${tiles[getNextIndex(1)].thumb}`;

        titles[0].innerHTML = tiles[getNextIndex()].nextTitle;
        titles[1].innerHTML = tiles[getNextIndex(1)].nextTitle;
      });

// -------
// Helpers
// -------

function getNextIndex(skipSteps = 0) {
  let newIndex = activeIndex;
  incrementIndex();

  for (let i = 0; i < skipSteps; i++) {
    incrementIndex();
  }

  function incrementIndex() {
    if (newIndex >= tiles.length - 1) {
      newIndex = 0
    } else {
      newIndex = newIndex + 1
    }
  }

  return newIndex;
}

// -----------
// Tile Change
// -----------

function nextTile() {
  // We want to prevent clicking on the next tile button if an animation is active...
  // ...to prevent the animations from being interupted mid animation.
  if (
    !titleAnimation.isActive() &&
    !nextButtonAnimation.isActive() &&
    !nextTextAnimation.isActive()
  ) {
    activeIndex = getNextIndex();
    titleAnimation.play();
    nextButtonAnimation.play();
  }
}

// ------------------------------
// Initialize all timeline values
// ------------------------------

titleAnimation.progress(1).progress(0);
nextButtonAnimation.progress(1).progress(0);
nextTextAnimation.progress(1).progress(0);
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/gsap/2.1.3/TweenMax.min.js