22 CSS Image Galleries16 / 22

CSS + JSMIT licensed

CSS Tilt Parallax Image Cards

Photo cards that tilt in 3D toward the cursor with a moving specular glare and a parallax caption that floats above the image — the interactive tilt effect portfolios and product heroes search for. A few lines of pointer JS feed CSS variables; CSS does the rendering.

Published Updated

Live Demo
Try it

The code

<section class="gl-14" id="gl-14" aria-label="3D tilt parallax cards">
  <div class="gl-14__row">
    <article class="gl-14__card" data-tilt><div class="gl-14__media"><img src="https://loremflickr.com/500/620/aurora,mountain?lock=1401" alt="Aurora over mountains" loading="lazy" decoding="async"></div><figcaption class="gl-14__cap"><span class="gl-14__k">Iceland</span><h3>Northern Skies</h3></figcaption></article>
    <article class="gl-14__card" data-tilt><div class="gl-14__media"><img src="https://loremflickr.com/500/620/canyon,sunset?lock=1402" alt="Canyon at sunset" loading="lazy" decoding="async"></div><figcaption class="gl-14__cap"><span class="gl-14__k">Utah</span><h3>Red Horizon</h3></figcaption></article>
    <article class="gl-14__card" data-tilt><div class="gl-14__media"><img src="https://loremflickr.com/500/620/forest,fog?lock=1403" alt="Foggy forest" loading="lazy" decoding="async"></div><figcaption class="gl-14__cap"><span class="gl-14__k">Oregon</span><h3>Deep Green</h3></figcaption></article>
  </div>
</section>
.gl-14,
.gl-14 *,
.gl-14 *::before,
.gl-14 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.gl-14 ::selection {
  background: oklch(0.72 0.16 285);
  color: #0a0616;
}

@property --rx {
  syntax: '<angle>';
  inherits: true;
  initial-value: 0deg;
}

@property --ry {
  syntax: '<angle>';
  inherits: true;
  initial-value: 0deg;
}

.gl-14 {
  --accent: oklch(0.74 0.16 285);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  padding: 40px 20px;
  background: radial-gradient(120% 120% at 50% 15%,#141021,#08060f);
  display: flex;
  align-items: center;
  justify-content: center;
  perspective: 1000px;
}

.gl-14__row {
  display: flex;
  gap: 26px;
  flex-wrap: wrap;
  justify-content: center;
  width: min(940px,100%);
}

.gl-14__card {
  --rx: 0deg;
  --ry: 0deg;
  --mx: 50%;
  --my: 50%;
  position: relative;
  width: 260px;
  border-radius: 20px;
  overflow: hidden;
  cursor: pointer;
  transform: rotateX(var(--rx)) rotateY(var(--ry));
  transform-style: preserve-3d;
  transition: transform .4s ease,box-shadow .4s;
  box-shadow: 0 30px 60px -30px rgba(0,0,0,.8);
  background: #12101d;
}

.gl-14__card::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background: radial-gradient(300px 300px at var(--mx) var(--my),rgba(255,255,255,.28),transparent 60%);
  opacity: 0;
  transition: opacity .3s;
  mix-blend-mode: screen;
}

.gl-14__card:hover::after {
  opacity: 1;
}

.gl-14__media {
  overflow: hidden;
}

.gl-14__media img {
  display: block;
  width: 100%;
  aspect-ratio: 5/6;
  object-fit: cover;
  transform: translateZ(0);
}

.gl-14__cap {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 22px 20px;
  z-index: 2;
  transform: translateZ(50px);
  background: linear-gradient(0deg,rgba(8,6,15,.85),transparent);
}

.gl-14__k {
  font: 11px ui-monospace,Menlo,monospace;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--accent);
}

.gl-14__cap h3 {
  margin-top: 4px;
  font-size: 19px;
  font-weight: 700;
  color: #fff;
}

.gl-14__card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (hover: none),(prefers-reduced-motion: reduce) {
  .gl-14__card {
    transform: none;
    transition: none;
  }

  .gl-14__card::after {
    display: none;
  }

  .gl-14__cap {
    transform: none;
  }
}
(function(){
  var root = document.getElementById('gl-14');
  if(!root) return;
  if(matchMedia('(hover:none)').matches || matchMedia('(prefers-reduced-motion:reduce)').matches) return;
  var MAX = 12; // max tilt in degrees
  root.querySelectorAll('[data-tilt]').forEach(function(card){
    card.addEventListener('pointermove', function(e){
      var r = card.getBoundingClientRect();
      var px = (e.clientX - r.left) / r.width;
      var py = (e.clientY - r.top) / r.height;
      card.style.setProperty('--ry', ((px - .5) * 2 * MAX).toFixed(2) + 'deg');
      card.style.setProperty('--rx', ((.5 - py) * 2 * MAX).toFixed(2) + 'deg');
      card.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
      card.style.setProperty('--my', (py * 100).toFixed(1) + '%');
    });
    card.addEventListener('pointerleave', function(){
      card.style.setProperty('--rx','0deg');
      card.style.setProperty('--ry','0deg');
    });
  });
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Image Gallerie from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Tilt Parallax Image Cards
Source: https://codefronts.com/snippets/css-image-gallery/css-tilt-parallax-image-cards/

Photo cards that tilt in 3D toward the cursor with a moving specular glare and a parallax caption that floats above the image — the interactive tilt effect portfolios and product heroes search for. A few lines of pointer JS feed CSS variables; CSS does the rendering.
## HTML
```html
<section class="gl-14" id="gl-14" aria-label="3D tilt parallax cards">
  <div class="gl-14__row">
    <article class="gl-14__card" data-tilt><div class="gl-14__media"><img src="https://loremflickr.com/500/620/aurora,mountain?lock=1401" alt="Aurora over mountains" loading="lazy" decoding="async"></div><figcaption class="gl-14__cap"><span class="gl-14__k">Iceland</span><h3>Northern Skies</h3></figcaption></article>
    <article class="gl-14__card" data-tilt><div class="gl-14__media"><img src="https://loremflickr.com/500/620/canyon,sunset?lock=1402" alt="Canyon at sunset" loading="lazy" decoding="async"></div><figcaption class="gl-14__cap"><span class="gl-14__k">Utah</span><h3>Red Horizon</h3></figcaption></article>
    <article class="gl-14__card" data-tilt><div class="gl-14__media"><img src="https://loremflickr.com/500/620/forest,fog?lock=1403" alt="Foggy forest" loading="lazy" decoding="async"></div><figcaption class="gl-14__cap"><span class="gl-14__k">Oregon</span><h3>Deep Green</h3></figcaption></article>
  </div>
</section>
```
## CSS
```css
.gl-14,
.gl-14 *,
.gl-14 *::before,
.gl-14 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.gl-14 ::selection {
  background: oklch(0.72 0.16 285);
  color: #0a0616;
}

@property --rx {
  syntax: '<angle>';
  inherits: true;
  initial-value: 0deg;
}

@property --ry {
  syntax: '<angle>';
  inherits: true;
  initial-value: 0deg;
}

.gl-14 {
  --accent: oklch(0.74 0.16 285);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  padding: 40px 20px;
  background: radial-gradient(120% 120% at 50% 15%,#141021,#08060f);
  display: flex;
  align-items: center;
  justify-content: center;
  perspective: 1000px;
}

.gl-14__row {
  display: flex;
  gap: 26px;
  flex-wrap: wrap;
  justify-content: center;
  width: min(940px,100%);
}

.gl-14__card {
  --rx: 0deg;
  --ry: 0deg;
  --mx: 50%;
  --my: 50%;
  position: relative;
  width: 260px;
  border-radius: 20px;
  overflow: hidden;
  cursor: pointer;
  transform: rotateX(var(--rx)) rotateY(var(--ry));
  transform-style: preserve-3d;
  transition: transform .4s ease,box-shadow .4s;
  box-shadow: 0 30px 60px -30px rgba(0,0,0,.8);
  background: #12101d;
}

.gl-14__card::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background: radial-gradient(300px 300px at var(--mx) var(--my),rgba(255,255,255,.28),transparent 60%);
  opacity: 0;
  transition: opacity .3s;
  mix-blend-mode: screen;
}

.gl-14__card:hover::after {
  opacity: 1;
}

.gl-14__media {
  overflow: hidden;
}

.gl-14__media img {
  display: block;
  width: 100%;
  aspect-ratio: 5/6;
  object-fit: cover;
  transform: translateZ(0);
}

.gl-14__cap {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 22px 20px;
  z-index: 2;
  transform: translateZ(50px);
  background: linear-gradient(0deg,rgba(8,6,15,.85),transparent);
}

.gl-14__k {
  font: 11px ui-monospace,Menlo,monospace;
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--accent);
}

.gl-14__cap h3 {
  margin-top: 4px;
  font-size: 19px;
  font-weight: 700;
  color: #fff;
}

.gl-14__card:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (hover: none),(prefers-reduced-motion: reduce) {
  .gl-14__card {
    transform: none;
    transition: none;
  }

  .gl-14__card::after {
    display: none;
  }

  .gl-14__cap {
    transform: none;
  }
}
```

## JavaScript
```js
(function(){
  var root = document.getElementById('gl-14');
  if(!root) return;
  if(matchMedia('(hover:none)').matches || matchMedia('(prefers-reduced-motion:reduce)').matches) return;
  var MAX = 12; // max tilt in degrees
  root.querySelectorAll('[data-tilt]').forEach(function(card){
    card.addEventListener('pointermove', function(e){
      var r = card.getBoundingClientRect();
      var px = (e.clientX - r.left) / r.width;
      var py = (e.clientY - r.top) / r.height;
      card.style.setProperty('--ry', ((px - .5) * 2 * MAX).toFixed(2) + 'deg');
      card.style.setProperty('--rx', ((.5 - py) * 2 * MAX).toFixed(2) + 'deg');
      card.style.setProperty('--mx', (px * 100).toFixed(1) + '%');
      card.style.setProperty('--my', (py * 100).toFixed(1) + '%');
    });
    card.addEventListener('pointerleave', function(){
      card.style.setProperty('--rx','0deg');
      card.style.setProperty('--ry','0deg');
    });
  });
})();
```

How this works

A tiny pointer handler measures the cursor over each card and writes two custom properties — --rx/--ry (rotation) and --mx/--my (glare position). The card renders them through transform: perspective() rotateX() rotateY(), a radial ::after glare follows the pointer, and the caption sits on a higher translateZ() so it parallaxes above the photo.

JS only supplies numbers; the transform, glare and the eased settle-back on pointer-leave are all CSS, keeping the effect on the compositor. The whole thing no-ops on touch and under reduced-motion.

Make it yours

  • Cap tilt intensity by clamping the max angle in the JS (±8° subtle, ±18° dramatic).
  • Push the caption's translateZ() for more or less parallax float.
  • Restyle or remove the glare ::after for a matte tilt.
  • Add more cards — the handler binds to each independently.

Gotchas — read before shipping

  • Guard for hover:none / reduced-motion so touch and motion-sensitive users get a flat, static card.
  • Write to CSS variables rather than inline transform strings so CSS owns the easing.
  • Keep the real content flat and legible — the tilt is decoration, not a layout mechanism.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

Floor set by oklch(), @property as this demo is written. The core technique itself goes back further — Chrome 85+.

@property enables the eased variable settle (Chrome 85+/Safari 16.4+/Firefox 128+); without it the tilt still tracks, just without the smooth return.

Techniques used in this demo

Search CodeFronts

Loading…