<main>
  <p>Click on the image to enlarge it. Use ESC to close larger picture.</p>
  <ul class="js-favs" role=”list”>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/D39F51/fff?text=D39F51" alt="A placeholder image with #D39F51 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/D66181/fff?text=D66181" alt="A placeholder image with #D66181 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/453882/fff?text=453882" alt="A placeholder image with #453882 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/C48148/fff?text=C48148" alt="A placeholder image with #C48148 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/D89682/fff?text=D89682" alt="A placeholder image with #D89682 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/C52929/fff?text=C52929" alt="A placeholder image with #C52929 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/252322/fff?text=252322" alt="A placeholder image with #252322 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/CB9F5F/fff?text=CB9F5F" alt="A placeholder image with #CB9F5F background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/A4A6B4/fff?text=A4A6B4" alt="A placeholder image with #A4A6B4 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/9A5C66/fff?text=9A5C66" alt="A placeholder image with #9A5C66 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/424355/fff?text=424355" alt="A placeholder image with #424355 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/B4BA91/fff?text=B4BA91" alt="A placeholder image with #B4BA91 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/79694A/fff?text=79694A" alt="A placeholder image with #79694A background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/99A1AB/fff?text=99A1AB" alt="A placeholder image with #99A1AB background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/AC6C7A/fff?text=AC6C7A" alt="A placeholder image with #AC6C7A background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/CB9659/fff?text=CB9659" alt="A placeholder image with #CB9659 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/AF9AAE/fff?text=AF9AAE" alt="A placeholder image with #AF9AAE background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/927D96/fff?text=927D96" alt="A placeholder image with #927D96 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/B44459/fff?text=B44459" alt="A placeholder image with #B44459 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/BF9579/fff?text=BF9579" alt="A placeholder image with #BF9579 background." />
      </button>
    </li>
    <li>
      <button aria-expanded="false">
        <img src="https://via.placeholder.com/400x200/5D4E5A/fff?text=5D4E5A" alt="A placeholder image with #5D4E5A background." />
      </button>
    </li>
  </ul>
</main>
View Compiled
:root {
  --gap: 4px;
  --duration-shrink: .5s;
  --duration-expand: .25s;
  --no-duration: 0s;
}

body {
  margin: 0;
  background-color: #000;
  color: #fff;
}

p {
  font-family: sans-serif;
  margin: 1rem;
}

ul {
  display: grid;
  grid-template-columns: repeat(1, 1fr);
  grid-gap: var(--gap);
  list-style: none;
  padding: 0;
  margin: 1rem;
}

@media screen and (min-width: 640px) {
  ul {
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  }
}

li {
  transition-property: transform, opacity;
  transition-timing-function: ease-in-out;
  transition-duration: var(--duration-expand);
}

li.is-zoomed {
  transition-duration: var(--duration-shrink);
}

.is-zoomed li:not(.is-zoomed) {
  opacity: 0.3;
}

button {
  all: initial;
  display: block;
  aspect-ratio: 2/1;
  overflow: hidden;
  cursor: pointer;
  width: 100%;
}

button:focus {
  outline: none;
}

li.is-zoomed button,
ul:not(.is-zoomed) button:focus {
  outline: 2px solid red;
  outline-offset: 1px;
}

img {
  height: 100%;
  width: 100%;
  object-fit: cover;
}

.visually-hidden {
  display: block;
  height: 1px;
  width: 1px;
  overflow: hidden;
  clip: rect(1px 1px 1px 1px);
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(1px);
  white-space: nowrap;
  position: absolute;
}

@media (prefers-reduced-motion) {
  li,
  li.is-zoomed {
    transition-duration: var(--no-duration);
  }
}
View Compiled

// Set defaults
let $activeElem = false
let timeout = 0

// Get favorites element
const $fav = document.querySelector('.js-favs')

// Get the transition timeout from CSS
const getTimeouts = () => {
  const durationOn = parseFloat(getComputedStyle(document.documentElement)
    .getPropertyValue('--duration-expand'));

  timeout = parseFloat(durationOn) * 1000
}

// Get the top offset
const getTop = ($elem) => {
  const elemRect = $elem.getBoundingClientRect()

  return elemRect.top
}

// Set data attributes for calculations
const setDataAttrs = ($elems, $parent) => {
  // Get the top offset of the first element
  let top = getTop($elems[0])

  // Set grid gap from CSS
  const gridColumnGap = parseFloat(getComputedStyle(document.documentElement)
    .getPropertyValue('--gap'))

  $parent.setAttribute('data-gap', gridColumnGap)

  // Set grid item width from CSS
  const eStyle = getComputedStyle($elems[0])
  $parent.setAttribute('data-width', eStyle.width)

  // Iterate through grid items
  for (let i = 0; i < $elems.length; i++) {
    const t = getTop($elems[i])

    // Check when top offset changes
    if (t != top) {
      // Set the number of columns and break stop the loop
      $parent.setAttribute('data-cols', i)
      break;
    }
  }
}

// Deactivate grid items
const deactiveElems = ($elems, $parent, $currentElem, $button) => {
  // Unset parent class
  $parent.classList.remove('is-zoomed')

  for (let i = 0; i < $elems.length; i++) {
    // Unset item class
    $elems[i].classList.remove('is-zoomed')
    // Unset item CSS transform
    $elems[i].style.transform = 'none'

    // Skip the rest if the item is the current item
    if ($elems[i] === $currentElem) {
      continue
    }

    // Unset item aria expanded if element exists
    if($button) {
      $button.setAttribute('aria-expanded', false)
    }

    // After a half of the timeout, reset CSS z-index to avoid overlay issues
    setTimeout(() => {
      $elems[i].style.zIndex = 0
    }, timeout)
  }
}

// Set active item
const activateElem = ($elems, $parent, $elem, $button, lengthOfElems, i) => {
  // Get data attributes from parent
  const cols = parseInt($parent.getAttribute('data-cols'))
  const width = parseFloat($parent.getAttribute('data-width'))
  const gap = parseFloat($parent.getAttribute('data-gap'))

  // If there is only a single column, prevent from executing
  if (cols === 1) {
    return
  }

  // Calculate the number of rows
  const rows = Math.ceil(lengthOfElems / cols) - 1

  // If there is only a single row, prevent from executing
  if (rows === 0) {
    return
  }

  // Reset all elements
  deactiveElems($elems, $parent, $elem, $button)

  // If there is active element, set focus to it, unset global active element, and prevent from further executing
  if ($activeElem) {
    $activeElem.focus()
    $activeElem = false
    return
  }

  // Calculate if the item is in the last row
  const isLastRow = i + 1 > rows * cols
  // Set default transform direction to top (expand down)
  let transformOrigin = 'top'

  if (isLastRow) {
    // If the item is in the last row, set transform direction to bottom (expand up)
    transformOrigin = 'bottom'
  }

  // Calculate if the item is the most right
  const isRight = (i + 1) % cols !== 0

  if (isRight) {
    // If the item is the most right, set transform direction to left (expand right)
    transformOrigin += ' left'
  } else {
    // If the item is the most right, set transform direction to right (expand left)
    transformOrigin += ' right'
  }

  $elem.style.transformOrigin = transformOrigin

  // Calculate the scale coefficient
  const scale = (width * 2 + gap) / width

  // After a whole timeout, set CSS high z-index to avoid overlay issues
  setTimeout(() => {
    // Set high CSS z-index to avoid overlay issues
    $elem.style.zIndex = 10
    // Set parent class
    $parent.classList.add('is-zoomed')
    // Set item class
    $elem.classList.add('is-zoomed')
    // Set item CSS transform
    $elem.style.transform = `scale(${scale})`
    // Set item aria expanded
    $button.setAttribute('aria-expanded', true)
    // Set global active item
    $activeElem = $button
  }, timeout)
}

// Set sibling as an active item
const activateSibling = ($sibling) => {
  // Find anchor
  const $siblingButton = $sibling.querySelector('button')

  // Unset global active element
  $activeElem = false

  // Focus and click on current
  $siblingButton.focus()
  $siblingButton.click()
}

// Set click events on anchors
const setClicks = ($elems, $parent) => {
  $elems.forEach(($elem, i) => {
    // Find anchor
    const $button = $elem.querySelector('button')

    $button.addEventListener('click', (e) => {
      // Set active item on click
      activateElem($elems, $parent, $elem,  $button, $elems.length, i)
    })
  })
}

// Set keyboard events
const setKeyboardEvents = () => {
  document.addEventListener('keydown', (e) => {
    // Take action only if global active element exists
    if ($activeElem) {
      // If key is “escape”, emulate the click on the global active element
      if (e.code === 'Escape') {
        $activeElem.click()
      }

      // If key is “left arrow”, activate the previous sibling
      if (e.code === 'ArrowLeft') {
        const $previousSibling = $activeElem.parentNode.previousElementSibling

        if($previousSibling) {
          activateSibling($previousSibling)
        }
      }

      // If key is “right arrow”, activate the next sibling
      if (e.code === 'ArrowRight') {
        const $nextSibling = $activeElem.parentNode.nextElementSibling

        if($nextSibling) {
          activateSibling($nextSibling)
        }
      }
    }
  })
}

// Set resize events
const setResizeEvents = ($elems, $parent) => {
  window.addEventListener('resize', () => {
    // Set data attributes for calculations
    setDataAttrs($elems, $parent)
    // Deactivate grid items
    deactiveElems($elems, $parent)
  })
}

// If the favorites element exists, start the functionality
if ($fav) {
  // Find all list items
  const $favs = $fav.querySelectorAll('li')

  // Check if there are list items
  if ($favs.length) {
    // Get the transition timeout from CSS
    getTimeouts($favs)
    // Set data attributes for calculations
    setDataAttrs($favs, $fav)
    // Set click events on anchors
    setClicks($favs, $fav)
    // Set keyboard events
    setKeyboardEvents()
    // Set resize events
    setResizeEvents($favs, $fav)
  }
}
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.