Layout blueprint
Gallery focus zoom
Hover lifts a photo out of its grid. Click grows it edge to edge across the gallery, with a close control to return to the same spot.
Inspired by Harsh Singh on X ↗
Build it
Choose a target and adapt the source to your interface.
<div class="focus-demo">
<div class="focus-viewport">
<div class="focus-grid" data-focusing="false">
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open white architecture"
>
<img
src="https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&w=720&q=80"
alt="White architecture"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open mountain at dusk"
>
<img
src="https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&w=720&q=80"
alt="Mountain at dusk"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open quiet interior"
>
<img
src="https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&w=720&q=80"
alt="Quiet interior"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open ocean waves"
>
<img
src="https://images.unsplash.com/photo-1518837695005-2083093ee35b?auto=format&fit=crop&w=720&q=80"
alt="Ocean waves"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open sculptural facade"
>
<img
src="https://images.unsplash.com/photo-1511818966892-d7d671e672a2?auto=format&fit=crop&w=720&q=80"
alt="Sculptural facade"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open alpine lake"
>
<img
src="https://images.unsplash.com/photo-1470770841072-f978cf4d019e?auto=format&fit=crop&w=720&q=80"
alt="Alpine lake"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open quiet interior"
>
<img
src="https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&w=720&q=80"
alt="Quiet interior"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open white architecture"
>
<img
src="https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&w=720&q=80"
alt="White architecture"
width="720"
height="480"
draggable="false"
/>
</button>
<button
class="focus-card"
type="button"
aria-pressed="false"
aria-expanded="false"
aria-label="Open ocean waves"
>
<img
src="https://images.unsplash.com/photo-1518837695005-2083093ee35b?auto=format&fit=crop&w=720&q=80"
alt="Ocean waves"
width="720"
height="480"
draggable="false"
/>
</button>
</div>
<section
class="focus-cover"
role="region"
aria-label="Expanded photo"
aria-hidden="true"
inert
hidden
>
<img
class="focus-cover-image"
src="https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&w=720&q=80"
alt="White architecture"
width="720"
height="480"
/><button class="focus-close" type="button" aria-label="Close photo">
×
</button>
</section>
</div>
<p class="focus-hint">
Hover to explore · Click to fill the gallery · Esc to restore
</p>
</div>
<script>
const root = document.querySelector('.focus-demo');
const viewport = root.querySelector('.focus-viewport');
const grid = root.querySelector('.focus-grid');
const cards = [...grid.querySelectorAll('.focus-card')];
const cover = root.querySelector('.focus-cover'),
coverImage = cover.querySelector('img'),
closeButton = cover.querySelector('button');
const reduced = matchMedia('(prefers-reduced-motion:reduce)');
let active = null,
opened = null,
animations = [];
function zoomGeometry(card, viewport) {
const width = card.offsetWidth,
height = card.offsetHeight;
const x = card.offsetLeft - viewport.scrollLeft,
y = card.offsetTop - viewport.scrollTop;
const scale = Math.max(
1,
Math.min(
1.65,
(viewport.clientWidth - 24) / width,
(viewport.clientHeight - 24) / height,
),
);
const marginX = (width * (scale - 1)) / 2,
marginY = (height * (scale - 1)) / 2;
return {
x: Math.max(
12 + marginX - x,
Math.min(0, viewport.clientWidth - 12 - marginX - x - width),
),
y: Math.max(
12 + marginY - y,
Math.min(0, viewport.clientHeight - 12 - marginY - y - height),
),
scale,
};
}
function coverGeometry(card, viewport) {
const photo = card.getBoundingClientRect(),
area = viewport.getBoundingClientRect();
const scaleX = area.width / viewport.offsetWidth,
scaleY = area.height / viewport.offsetHeight;
return {
left: (photo.left - area.left) / scaleX,
top: (photo.top - area.top) / scaleY + viewport.scrollTop,
width: photo.width / scaleX,
height: photo.height / scaleY,
borderRadius: 4,
};
}
function select(card) {
active = card;
grid.dataset.focusing = String(!!card);
const geometry = card ? zoomGeometry(card, viewport) : null;
cards.forEach((item) =>
item.setAttribute('aria-pressed', String(item === card)),
);
if (card) {
card.style.setProperty('--focus-x', geometry.x + 'px');
card.style.setProperty('--focus-y', geometry.y + 'px');
card.style.setProperty('--focus-scale', geometry.scale);
}
}
function openPhoto(card) {
const from = coverGeometry(card, viewport);
opened = card;
select(null);
const image = card.querySelector('img');
coverImage.src = image.src;
coverImage.alt = image.alt;
card.setAttribute('aria-expanded', 'true');
cover.style.top = viewport.scrollTop + 'px';
viewport.dataset.covered = 'true';
cover.hidden = false;
cover.inert = false;
cover.setAttribute('aria-hidden', 'false');
grid.inert = true;
grid.setAttribute('aria-hidden', 'true');
closeButton.focus({ preventScroll: true });
animations.forEach((animation) => animation.cancel());
animations = [];
if (!reduced.matches) {
animations = [
cover.animate(
[
{
left: (from.left / viewport.clientWidth) * 100 + '%',
top: (from.top / viewport.clientHeight) * 100 + '%',
width: (from.width / viewport.clientWidth) * 100 + '%',
height: (from.height / viewport.clientHeight) * 100 + '%',
borderRadius: from.borderRadius + 'px',
},
{
left: '0px',
top: viewport.scrollTop + 'px',
width: '100%',
height: '100%',
borderRadius: '0px',
},
],
{ duration: 500, easing: 'cubic-bezier(.22,1,.36,1)' },
),
closeButton.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: 160,
delay: 280,
easing: 'ease-out',
fill: 'backwards',
}),
];
}
}
function closePhoto() {
const previous = opened;
previous?.setAttribute('aria-expanded', 'false');
opened = null;
animations.forEach((animation) => animation.cancel());
animations = [];
cover.hidden = true;
cover.inert = true;
cover.setAttribute('aria-hidden', 'true');
grid.inert = false;
grid.removeAttribute('aria-hidden');
viewport.dataset.covered = 'false';
select(null);
previous?.focus({ preventScroll: true });
}
cards.forEach((card) => {
card.addEventListener('pointerenter', (event) => {
if (event.pointerType === 'mouse' && !opened) select(card);
});
card.addEventListener('pointerleave', () => {
if (active === card && !opened) select(null);
});
card.addEventListener('click', () => openPhoto(card));
});
closeButton.addEventListener('click', closePhoto);
root.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
if (opened) closePhoto();
else select(null);
}
});
grid.addEventListener('focusout', (event) => {
if (!grid.contains(event.relatedTarget)) select(null);
});
viewport.addEventListener(
'scroll',
() => {
if (!opened) select(null);
},
{ passive: true },
);
const resizeObserver = new ResizeObserver(() => {
select(null);
if (opened) animations.forEach((animation) => animation.finish());
});
resizeObserver.observe(viewport);
reduced.addEventListener('change', () => {
if (reduced.matches) {
animations.forEach((animation) => animation.cancel());
animations = [];
}
});
// Disconnect resizeObserver if the demo is removed by your application.
</script>
<style>
.focus-demo {
position: relative;
width: 540px;
max-width: 100%;
color: var(--stage-ink, #252b34);
font:
12px/1.5 system-ui,
sans-serif;
}
.focus-viewport {
position: relative;
height: 340px;
overflow: auto;
overscroll-behavior: contain;
border-radius: 12px;
background: var(--stage-bg, #fafafa);
isolation: isolate;
}
.focus-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-auto-rows: 96px;
gap: 6px;
padding: 28px;
}
.focus-card {
position: relative;
z-index: 0;
display: block;
grid-column: span 2;
min-width: 0;
min-height: 44px;
padding: 0;
border: 0;
border-radius: 4px;
background: #d7dde0;
cursor: zoom-in;
transform-origin: center;
transition:
transform 500ms cubic-bezier(0.22, 1, 0.36, 1),
filter 240ms ease-out,
opacity 240ms ease-out;
}
.focus-card:nth-child(4),
.focus-card:nth-child(5) {
grid-column: span 1;
}
.focus-card:nth-child(6) {
grid-column: span 4;
}
.focus-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
pointer-events: none;
}
.focus-card[aria-pressed='true'] {
z-index: 2;
cursor: zoom-out;
transform: translate(var(--focus-x, 0px), var(--focus-y, 0px))
scale(var(--focus-scale, 1.65));
transition-timing-function:
cubic-bezier(0.22, 1, 0.36, 1), ease-out, ease-out;
}
.focus-grid[data-focusing='true'] .focus-card[aria-pressed='false'] {
filter: blur(3px);
opacity: 0.55;
}
.focus-card:focus-visible {
outline: 2px solid var(--stage-link, #175bc5);
outline-offset: 3px;
}
.focus-hint {
margin: 12px 0 0;
text-align: center;
color: var(--stage-muted, #596170);
}
.focus-viewport[data-covered='true'] {
overflow: hidden;
}
.focus-cover {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 3;
overflow: hidden;
background: var(--stage-bg, #fafafa);
}
.focus-cover[hidden] {
display: none;
}
.focus-cover-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.focus-close {
position: absolute;
top: 12px;
right: 12px;
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: #111c;
color: white;
font: 28px/1 system-ui;
cursor: pointer;
}
.focus-close:focus-visible {
outline: 2px solid white;
outline-offset: 3px;
}
@media (max-width: 420px) {
.focus-grid {
padding: 22px;
gap: 5px;
grid-auto-rows: 80px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.focus-card:nth-child(4),
.focus-card:nth-child(5) {
grid-column: span 2;
}
.focus-viewport {
height: 300px;
}
.focus-close {
width: 52px;
height: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
.focus-card {
transition: none;
}
.focus-grid[data-focusing='true'] .focus-card[aria-pressed='false'] {
filter: none;
opacity: 0.65;
}
}
</style>Animation breakdown
Frame phases, motion intent, implementation constraints and accessible alternatives.
Rest: all photos remain sharp in their original grid slots.
Hover: the photo enlarges over 500ms; surrounding tiles soften over 240ms.
Click: the selected image grows from its visible tile bounds to fill 100% of the gallery area over 500ms, without fading or blurring.
Leaving: Close photo or Escape restores the grid and keyboard focus to the original photo.
Motion intent
Hover lifts a photo out of its grid. Click grows it edge to edge across the gallery, with a close control to return to the same spot.
Hover uses a 500ms lift and 240ms softening. The clicked photo grows continuously over 500ms; the close control appears after 280ms.
A quick departure and long ease-out let the image lift confidently and arrive without overshoot.
Motion specification
- Trigger
- Hover to preview a photo. Click, tap, Enter, or Space opens it across 100% of the gallery area.
- Timing
- 500ms
- Easing
- cubic-bezier(.22, 1, .36, 1)
Animated properties
Implementation notes
- Measure the tile’s untransformed size and position before setting state. Clamp the enlarged bounds to a 12px viewport inset.
- Reserve the original grid slot and lift the selected tile with z-index; do not reflow neighbouring images.
- Hover previews only for a mouse. Click, tap, Enter, or Space opens a cover view within the gallery viewport.
- Measure the visible tile bounds before clearing hover, accounting for scroll and preview scaling. Animate one isolated box to the gallery bounds; keep the image opaque and use object-fit:cover to crop without distortion.
- Position the cover at the current scroll offset and lock gallery scrolling while open. Preserve the original scroll position when closing.
- Keep the grid inert while covered, focus Close photo on opening, and restore focus to the original tile on closing.
- Demo photography is from Unsplash. Replace the sample image URLs with your own assets when copying an example.
- The native example uses a three-column touch grid and an absolute image that grows from the tapped tile bounds with resizeMode="cover" inside the gallery.
Reduced motion
Apply hover and cover states instantly, without blur, fading, or image scaling.
- Every photo and the close control have descriptive labels and a minimum 44px target.
- Expose the open cover with aria-expanded and hide the inactive grid from keyboard and assistive technology.
- Enter and Space open a photo; Close photo and Escape restore the grid and return keyboard focus to the original tile.
Avoid
- Do not fade or blur the expanding image.
- Do not animate grid dimensions or remove the original slot.
- Keep lifted images within the viewport and preserve visible keyboard focus.