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 ↗

Playground

Playhead 0ms / 500ms

Build it

Choose a target and adapt the source to your interface.

483 lines
index.html
<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.

500ms
01

Rest: all photos remain sharp in their original grid slots.

02

Hover: the photo enlarges over 500ms; surrounding tiles soften over 240ms.

03

Click: the selected image grows from its visible tile bounds to fill 100% of the gallery area over 500ms, without fading or blurring.

04

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

transform: translate + scale (hover)filter: blur (surrounding tiles)left + top + width + height + border-radius (expansion)opacity (surrounding tiles and close control)

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.

Related blueprints

Browse all blueprints