<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
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.