---
name: perspective-gallery
description: Use when you want "Editorial, premium, immersive" - Arranges media in angled 3D perspective.
---

# Perspective Gallery

> **Category:** Image / Media  -  **Personality:** Editorial, premium, immersive
>
> **Best use:** Case studies, architecture, portfolios
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 3/5

## What it is

A perspective gallery hangs a row of images on a single plane that is rotated in 3D space, so the near frame is large and the rest recede toward a vanishing point — like walking past a gallery wall at an angle. It is a **static** arrangement (unlike a 3D carousel, nothing spins or auto-advances); the only motion is a subtle parallax tilt toward the pointer. It suits editorial case studies, architecture and portfolios where you want a few hero images to feel composed and physical rather than gridded.

## Dependencies / CDN

None for the effect — pure CSS 3D transforms + ~20 lines of vanilla JS for the pointer tilt. The demo loads two display/text fonts (optional):

```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=Bodoni+Moda:ital,opsz,wght@0,6..96,500;0,6..96,600;1,6..96,500;1,6..96,600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

A perspective viewport, one rotated `preserve-3d` rail, and N plates. Per-plate depth (`--z`), vertical stagger (`--y`) and size (`--w`/`--h`) are set inline:

```html
<div class="pg-view"><!-- holds the perspective -->
  <div class="pg-rail" id="pgRail"><!-- the rotated wall -->
    <figure class="pg-plate" style="--w:226px;--h:304px;--z:64px;--y:-6px;--b:1.05">
      <img src="/img/architecture-01.jpg" alt="Casa Travertino">
      <span class="pg-tag">PL. 01</span>
      <figcaption class="pg-cap"><span><b>Casa Travertino</b><small>Oaxaca, MX</small></span><span class="pg-yr">2024</span></figcaption>
    </figure>
    <figure class="pg-plate" style="--w:172px;--h:238px;--z:16px;--y:36px;--b:1"> … </figure>
    <figure class="pg-plate" style="--w:250px;--h:166px;--z:-22px;--y:-40px;--b:.95"> … </figure>
    <!-- further plates carry more-negative --z and lower --b so they recede + dim -->
  </div>
</div>
```

## CSS

```css
/* 1) THE PERSPECTIVE lives on the viewport; the rail is its only 3D child */
.pg-view{position:absolute;inset:0;display:flex;align-items:center;
  padding-left:clamp(10px,2.5%,40px);
  perspective:1450px;            /* smaller = stronger foreshortening */
  perspective-origin:54% 42%;}   /* where the vanishing point sits */

/* 2) THE WALL — a flat row rotated in Y so it recedes to one side.
      Base angle + a pointer delta are composed with calc(). */
.pg-rail{
  --pg-bx:3deg; --pg-by:-29deg; --pg-dx:0deg; --pg-dy:0deg;
  display:flex; align-items:center; gap:clamp(8px,1vw,16px);
  transform-style:preserve-3d;             /* keep children in 3D */
  transform:rotateX(calc(var(--pg-bx) + var(--pg-dx)))
            rotateY(calc(var(--pg-by) + var(--pg-dy)));
  transition:transform .5s cubic-bezier(.18,.7,.2,1);
}

/* 3) A PLATE — pushed along Z (depth) and Y (stagger), dimmed by --b for
      atmospheric perspective; lifts toward the viewer on hover. */
.pg-plate{
  --z:0px; --y:0px; --b:1; --lift:0px;
  flex:none; width:var(--w); height:var(--h); position:relative;
  border-radius:9px; overflow:hidden; backface-visibility:hidden;
  transform:translate3d(0,var(--y),calc(var(--z) + var(--lift)));
  filter:brightness(var(--b)) saturate(.97);
  box-shadow:0 34px 60px -30px rgba(0,0,0,.92), inset 0 0 0 1px rgba(239,231,216,.09);
  transition:transform .55s cubic-bezier(.18,.7,.2,1), box-shadow .55s, filter .55s;
}
.pg-plate img{display:block;width:100%;height:100%;object-fit:cover}
.pg-plate:hover{--lift:46px;--b:1.14}        /* nudges forward, brightens */
.pg-cap{opacity:0;transition:.4s}            /* caption fades in on hover */
.pg-plate:hover .pg-cap{opacity:1}

/* 4) A right-edge haze so the far plates dissolve into the distance */
.pg-haze{position:absolute;inset:0;pointer-events:none;
  background:linear-gradient(100deg,transparent 42%,rgba(12,8,6,.45) 74%,rgba(12,8,6,.85) 96%)}

/* Touch has no hover — keep captions on */
@media (hover:none){.pg-cap{opacity:1}}

/* Reduced motion: drop 3D, become a flat scrollable filmstrip */
@media (prefers-reduced-motion:reduce){
  .pg-view{perspective:none;overflow-x:auto}
  .pg-rail{transform:none}
  .pg-plate{transform:none;filter:none}
  .pg-cap{opacity:1}
  .pg-haze{display:none}
}
```

## JavaScript

Optional. Only adds the subtle pointer tilt — gated behind `prefers-reduced-motion`, throttled with `requestAnimationFrame`. It writes two custom properties; CSS does the rest via `calc()`:

```js
(function () {
  var stage = document.querySelector('.pg-stage'),
      rail  = document.getElementById('pgRail');
  if (!stage || !rail) return;
  if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;

  var raf = 0, dx = '0deg', dy = '0deg';
  function onMove(e) {
    var r = stage.getBoundingClientRect();
    var nx = (e.clientX - r.left) / r.width  - 0.5;   // -0.5 .. 0.5
    var ny = (e.clientY - r.top)  / r.height - 0.5;
    dy = (nx * 7).toFixed(2)  + 'deg';   // added to base rotateY
    dx = (-ny * 4.5).toFixed(2) + 'deg'; // added to base rotateX
    if (!raf) raf = requestAnimationFrame(apply);
  }
  function apply() { raf = 0; rail.style.setProperty('--pg-dx', dx); rail.style.setProperty('--pg-dy', dy); }
  function reset() { rail.style.setProperty('--pg-dx','0deg'); rail.style.setProperty('--pg-dy','0deg'); }
  stage.addEventListener('pointermove', onMove);
  stage.addEventListener('pointerleave', reset);
})();
```

## How it works

- **`perspective` on the viewport** turns the flat browser plane into a camera with a focal length. The smaller the value, the more dramatic the foreshortening.
- **`transform-style: preserve-3d` on the rail** lets its children keep their own Z positions instead of being flattened — without it, `translateZ` on the plates does nothing.
- **`rotateY` on the rail** is the whole trick: a flat flex row, tilted, automatically reads as a wall receding toward a vanishing point. Plates on the far side sit physically further from the camera, so perspective shrinks and offsets them for free.
- **Per-plate `translateZ`** pops a few frames forward off the wall so the hang feels layered rather than perfectly coplanar.
- **Atmospheric perspective** is faked with two cheap cues: each successive plate gets a lower `--b` (brightness) and a right-edge haze gradient fades the distance into the background colour, so the eye reads depth.
- **The tilt** never re-lays-out anything: JS only updates two CSS variables that are *added* to the base angles with `calc()`, and the `transition` on `transform` damps it into a smooth parallax.

## Customization

- **Camera drama:** lower `perspective` (e.g. `900px`) for a wide-angle, exaggerated recession; raise it (`2200px`) for a flatter, more telephoto look.
- **Which way it recedes:** flip the sign of `--pg-by` (`rotateY`) to recede left instead of right, and move `perspective-origin` to match.
- **Depth rhythm:** the look lives in the inline `--z` / `--y` / `--b` per plate — space the `--z` values further apart for a deeper corridor, alternate `--y` for a more organic hang.
- **Hover feel:** change `--lift` (how far a plate jumps toward you) and the hover `--b`.
- **Palette/voice:** swap the warm espresso + brass tokens and the Bodoni/Space-Mono pairing for your brand; the geometry is independent of the theme.

## Accessibility & performance

- Animate only `transform` (and the cheap `filter`/`box-shadow`) — never layout properties — so the GPU compositor handles the tilt; `requestAnimationFrame` coalesces pointer events to one write per frame.
- `prefers-reduced-motion` collapses the scene to a flat, horizontally **scrollable** filmstrip with captions shown, so the same content stays reachable without any 3D or motion.
- `@media (hover:none)` reveals captions on touch, where there is no hover to trigger them.
- Give every `<img>` a real `alt`; the plate index (`PL. 01`) is decorative. Keep contrast on overlaid titles with a scrim — text over a bright photo otherwise fails WCAG.
- Use right-sized images and `object-fit:cover`; a handful of plates is fine, but dozens of large bitmaps in one 3D context get expensive to composite.

## Gotchas

- **`preserve-3d` must be on the element you rotate**, and `perspective` on its *ancestor* — put both on the same node and the children flatten.
- An ancestor with `overflow:hidden` **clips** the 3D scene (intended here — far plates vanish at the stage edge), but it can also crop plates you wanted visible; size the rail so the hero isn't cut.
- A `filter`, `opacity` or `transform` on an ancestor creates a new stacking/containing context that can silently **flatten** the 3D or break `position` math — keep the perspective viewport and the rail as a clean parent→child pair.
- This is **not** a carousel: there's no rotation loop or active-slide logic. If you find yourself animating `rotateY` continuously, you've built a different effect.
- Steep `rotateY` (> ~35°) foreshortens so hard the far plates become unreadable slivers; pair a strong angle with smaller, tighter plates.
- `getBoundingClientRect()` on `preserve-3d` children returns the flattened 2D box, not the perceived position — don't rely on it for hit-testing inside the scene.
