---
name: image-hover-zoom
description: Use when you want "Clean, familiar, commercial" - Scales an image slightly when hovered.
---

# Image Hover Zoom

> **Category:** Image / Media  -  **Personality:** Clean, familiar, commercial
>
> **Best use:** E-commerce, blogs, galleries
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 1/5 - Visual impact 3/5 - Difficulty 1/5 - Performance cost 1/5

## What it is

The most familiar image interaction on the web: an image sits inside a **fixed-size frame that clips its overflow**, and on hover the *image* (not the frame) scales up a few percent. Because the frame stays put and `overflow:hidden`, the picture appears to push gently into the glass — inviting, tactile, and instantly readable as "this is clickable." It is the backbone of product grids, blog thumbnails and portfolio galleries. Here it drives a boutique storefront where each tile also reveals a price veil, a wishlist button and a "quick add" action as the image grows.

## Dependencies / CDN

None — pure CSS for the effect (a sprinkle of vanilla JS adds cursor-follow zoom origin + a wishlist toggle, both optional). The demo loads two Google Fonts (display serif + UI grotesk), which are purely cosmetic:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,400;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

One product card. The clipping `.frame` holds a stretched `<a>` (the image link) plus the overlay controls as siblings — keeping buttons *out* of the anchor so the markup stays valid:

```html
<article class="card">
  <div class="frame">
    <a class="shot" href="#" aria-label="View Aria Studio Over-Ears">
      <img src="/img/product-headphones.jpg" alt="Matte graphite over-ear headphones"
           loading="lazy" decoding="async">
      <span class="veil"></span>
    </a>
    <span class="tag tag--clay">New</span>
    <button class="fav" type="button" aria-pressed="false" aria-label="Save Aria Studio Over-Ears">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 21s-6.6-4.2-9.1-8.2C1.2 9.9 2.6 6 6.2 6c2 0 3.2 1.2 3.8 2.2C10.6 7.2 11.8 6 13.8 6c3.6 0 5 3.9 3.3 6.8C18.6 16.8 12 21 12 21Z"/></svg>
    </button>
    <div class="quick">
      <button class="add" type="button">+ Add to bag</button>
    </div>
  </div>
  <div class="meta">
    <div><a class="name" href="#">Aria Studio Over-Ears</a><div class="cat">Audio</div></div>
    <div class="price">€189</div>
  </div>
</article>
```

## CSS

```css
/* 1) THE EFFECT — a fixed frame that clips a scaling image. These ~8 lines are the whole trick. */
.frame{
  position:relative; overflow:hidden;          /* the clip — image can never escape */
  border-radius:16px; aspect-ratio:4/5;        /* fixed shape, so the layout never shifts */
}
.shot{position:absolute; inset:0; display:block}
.shot img{
  width:100%; height:100%; object-fit:cover; display:block;
  transform-origin:50% 50%;
  transition:transform .7s cubic-bezier(.19,.64,.13,1);  /* slow, eased = premium */
  will-change:transform;                       /* promote to its own layer (GPU) */
}
.card:hover  .shot img,
.card:focus-within .shot img{ transform:scale(1.09); }   /* hover AND keyboard focus */

/* 2) DRESSING — gradient veil + controls that fade/slide in with the zoom */
.veil{position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity .45s ease;
  background:linear-gradient(to top, rgba(26,21,15,.55), rgba(26,21,15,0) 66%)}
.quick{position:absolute; left:12px; right:12px; bottom:12px; display:flex; gap:8px;
  opacity:0; pointer-events:none; transform:translateY(12px); transition:.45s ease}
.card:hover .veil, .card:focus-within .veil,
.card:hover .quick,.card:focus-within .quick{opacity:1}
.card:hover .quick,.card:focus-within .quick{pointer-events:auto; transform:translateY(0)}

/* 3) RESPECT REDUCED MOTION — no scale, no slide */
@media (prefers-reduced-motion:reduce){
  .shot img{transition:none}
  .card:hover .shot img, .card:focus-within .shot img{transform:none}
}
```

## JavaScript

Not required — the zoom is pure CSS. The demo adds two small, optional flourishes (only when they make sense):

```js
(function(){
  var mm = window.matchMedia;
  var reduce = mm && mm('(prefers-reduced-motion: reduce)').matches;
  var fine   = mm && mm('(hover:hover) and (pointer:fine)').matches;

  // a) Zoom origin follows the cursor → the image grows toward where you point.
  if(fine && !reduce){
    document.querySelectorAll('.card').forEach(function(card){
      var frame = card.querySelector('.frame'), img = card.querySelector('.shot img');
      if(!frame || !img) return;
      frame.addEventListener('pointermove', function(e){
        var r = frame.getBoundingClientRect();
        img.style.transformOrigin =
          ((e.clientX-r.left)/r.width*100).toFixed(1) + '% ' +
          ((e.clientY-r.top )/r.height*100).toFixed(1) + '%';
      });
      card.addEventListener('pointerleave', function(){ img.style.transformOrigin = '50% 50%'; });
    });
  }

  // b) Wishlist toggle — a tiny commercial micro-interaction.
  document.querySelectorAll('.fav').forEach(function(btn){
    btn.addEventListener('click', function(){
      btn.setAttribute('aria-pressed', btn.classList.toggle('is-saved') ? 'true':'false');
    });
  });
})();
```

## How it works

- **`overflow:hidden` on a fixed-ratio frame** is the clip. The `aspect-ratio` keeps the frame's size constant, so growing the image *never* reflows neighbouring cards — only the pixels inside the frame change.
- **`transform: scale()` on the `<img>`** (never on the frame) does the zoom. `transform` is compositor-only — it runs on the GPU and doesn't trigger layout or paint, which is why this is cheap enough for a whole grid.
- **A slow eased `transition` (~0.7s, custom cubic-bezier)** is what separates "premium" from "twitchy." A linear 0.2s scale feels like a glitch; a long ease feels deliberate.
- **`:hover` *and* `:focus-within`** both fire the zoom, so keyboard users tabbing to the card's link get the exact same affordance as mouse users.
- The optional JS only re-points `transform-origin` toward the cursor — the scale value itself stays 100% CSS.

## Customization

- **Zoom amount:** `scale(1.04)` is a whisper (editorial blogs); `scale(1.09)` is confident (this demo); past `~1.15` it reads as aggressive and reveals image softness.
- **Frame shape:** swap `aspect-ratio` — `1/1` for catalogue squares, `4/5` for fashion, `16/10` for lifestyle banners. `object-fit:cover` handles any source ratio.
- **Speed / feel:** shorten to `.45s` for snappy retail, lengthen to `1s` for galleries. Tune the cubic-bezier for more "settle."
- **Direction:** instead of zoom-in, try a subtle Ken-Burns pan (`scale(1.06)` + a translate), or pair with a `filter:brightness()` lift.
- **Reveal:** the veil + quick-actions are independent — drop them for a pure zoom, or add a caption that slides up from the bottom edge.

## Accessibility & performance

- **Keyboard parity:** `:focus-within` mirrors `:hover`, and a visible `:focus-visible` outline is set on the inner link/buttons so the effect is operable without a mouse.
- **`prefers-reduced-motion`** is honoured — the scale and slide are removed entirely (the overlay still appears, just instantly), so motion-sensitive users get the information without the movement.
- **Cheap by design:** animate only `transform`/`opacity` (compositor properties) and flag the image with `will-change:transform`. Never animate `width`/`height`/`margin` — those re-layout the page every frame.
- **Don't ship oversized images:** the zoom magnifies, so a soft or low-res source becomes obvious. Serve a 2x asset and let `object-fit:cover` crop it. Use `loading="lazy"` for grids below the fold.

## Gotchas

- **Scale the `<img>`, never the `.frame`.** Scaling the frame moves its border/shadow and re-lays-out siblings; scaling the inner image is contained by `overflow:hidden` and stays GPU-cheap.
- **Forgetting `overflow:hidden`** lets the enlarged image bleed over neighbours — the single most common mistake with this effect.
- **A 1px "crawl" on the clipped edges** can appear during the scale on some browsers; pre-loading the image at `scale(1.001)` (as here) and adding `backface-visibility:hidden` keeps the edges crisp.
- **Touch devices have no hover.** Tapping the card focuses its link (so `:focus-within` flashes the overlay), but there's no sustained hover — treat the zoom as desktop enhancement, and make sure the card is fully usable tapped.
- **Nested anchors are invalid:** keep the wishlist/add `<button>`s as siblings of the image `<a>`, not inside it, or screen readers and the validator will complain.
