Tailwind + JSMIT licensed

Tailwind 3D Tilt Parallax Image Cards with Pointer-Driven Variables

Photo cards that tilt in 3D toward the cursor with a moving specular glare and a caption floating above the image. JavaScript writes four numbers; every visual consequence is a Tailwind utility reading them — the cleanest possible division between behaviour and appearance.

Live Demo
Plain CSSTailwind v4
Try it

Untitled UI Pro: 5,000+ React + Tailwind CSS v4 components · from $349 one-time

Untitled UI React · Affiliate link

Get Untitled UI Pro

The code

<section class="tgl-16 w-full min-h-svh grid place-items-center px-5 py-10 font-tgl16-sans [perspective:1000px] bg-[radial-gradient(120%_120%_at_50%_15%,oklch(0.2_0.04_290),oklch(0.09_0.02_280))]" id="tgl-16" aria-label="3D tilt parallax cards">
  <div class="flex flex-wrap justify-center gap-7 w-[min(940px,100%)]">
    <article data-tilt tabindex="0" class="group relative w-[260px] rounded-[20px] overflow-hidden cursor-pointer bg-tgl16-well [--rx:0deg] [--ry:0deg] [--mx:50%] [--my:50%] [transform:rotateX(var(--rx))_rotateY(var(--ry))] transform-3d transition-[transform,box-shadow] duration-[400ms] shadow-[0_30px_60px_-30px_rgba(0,0,0,0.8)] after:content-[''] after:absolute after:inset-0 after:z-[3] after:pointer-events-none after:opacity-0 after:transition-opacity after:duration-300 after:mix-blend-screen after:bg-[radial-gradient(300px_300px_at_var(--mx)_var(--my),rgba(255,255,255,0.28),transparent_60%)] hover:after:opacity-100 focus-visible:outline-3 focus-visible:outline-tgl16-acc focus-visible:outline-offset-[3px] motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:after:hidden">
      <div class="overflow-hidden"><img src="https://images.unsplash.com/photo-1516031190212-da133013de50?q=80&w=500&h=620&auto=format&fit=crop" alt="Aurora over mountains" loading="lazy" decoding="async" class="block w-full [aspect-ratio:5/6] object-cover"></div>
      <figcaption class="absolute inset-x-0 bottom-0 z-[2] px-5 py-5 [transform:translateZ(50px)] bg-[linear-gradient(0deg,rgba(8,6,15,0.9),transparent)] motion-reduce:[transform:none]">
        <span class="font-tgl16-mono text-[11px] tracking-[0.2em] uppercase text-tgl16-acc">Iceland</span>
        <h3 class="mt-1 text-[19px] font-bold text-white">Northern Skies</h3>
      </figcaption>
    </article>
    <article data-tilt tabindex="0" class="group relative w-[260px] rounded-[20px] overflow-hidden cursor-pointer bg-tgl16-well [--rx:0deg] [--ry:0deg] [--mx:50%] [--my:50%] [transform:rotateX(var(--rx))_rotateY(var(--ry))] transform-3d transition-[transform,box-shadow] duration-[400ms] shadow-[0_30px_60px_-30px_rgba(0,0,0,0.8)] after:content-[''] after:absolute after:inset-0 after:z-[3] after:pointer-events-none after:opacity-0 after:transition-opacity after:duration-300 after:mix-blend-screen after:bg-[radial-gradient(300px_300px_at_var(--mx)_var(--my),rgba(255,255,255,0.28),transparent_60%)] hover:after:opacity-100 focus-visible:outline-3 focus-visible:outline-tgl16-acc focus-visible:outline-offset-[3px] motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:after:hidden">
      <div class="overflow-hidden"><img src="https://images.unsplash.com/photo-1483985988355-763728e1935b?q=80&w=500&h=620&auto=format&fit=crop" alt="Canyon at sunset" loading="lazy" decoding="async" class="block w-full [aspect-ratio:5/6] object-cover"></div>
      <figcaption class="absolute inset-x-0 bottom-0 z-[2] px-5 py-5 [transform:translateZ(50px)] bg-[linear-gradient(0deg,rgba(8,6,15,0.9),transparent)] motion-reduce:[transform:none]">
        <span class="font-tgl16-mono text-[11px] tracking-[0.2em] uppercase text-tgl16-acc">Utah</span>
        <h3 class="mt-1 text-[19px] font-bold text-white">Red Horizon</h3>
      </figcaption>
    </article>
    <article data-tilt tabindex="0" class="group relative w-[260px] rounded-[20px] overflow-hidden cursor-pointer bg-tgl16-well [--rx:0deg] [--ry:0deg] [--mx:50%] [--my:50%] [transform:rotateX(var(--rx))_rotateY(var(--ry))] transform-3d transition-[transform,box-shadow] duration-[400ms] shadow-[0_30px_60px_-30px_rgba(0,0,0,0.8)] after:content-[''] after:absolute after:inset-0 after:z-[3] after:pointer-events-none after:opacity-0 after:transition-opacity after:duration-300 after:mix-blend-screen after:bg-[radial-gradient(300px_300px_at_var(--mx)_var(--my),rgba(255,255,255,0.28),transparent_60%)] hover:after:opacity-100 focus-visible:outline-3 focus-visible:outline-tgl16-acc focus-visible:outline-offset-[3px] motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:after:hidden">
      <div class="overflow-hidden"><img src="https://images.unsplash.com/photo-1620799140408-edc6dcb6d633?q=80&w=500&h=620&auto=format&fit=crop" alt="Foggy forest at dawn" loading="lazy" decoding="async" class="block w-full [aspect-ratio:5/6] object-cover"></div>
      <figcaption class="absolute inset-x-0 bottom-0 z-[2] px-5 py-5 [transform:translateZ(50px)] bg-[linear-gradient(0deg,rgba(8,6,15,0.9),transparent)] motion-reduce:[transform:none]">
        <span class="font-tgl16-mono text-[11px] tracking-[0.2em] uppercase text-tgl16-acc">Oregon</span>
        <h3 class="mt-1 text-[19px] font-bold text-white">Deep Green</h3>
      </figcaption>
    </article>
  </div>
</section>
@import "tailwindcss";
/* @property registration has no utility form — typed variables are what make the
   settle-back on pointer-leave interpolate instead of snapping. */

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

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

@theme {
  --color-tgl16-well: #12101d;
  --color-tgl16-well: oklch(0.19 0.02 285);
  --color-tgl16-acc: #b9a7f7;
  --color-tgl16-acc: oklch(0.78 0.13 290);
  --font-tgl16-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-tgl16-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

.tgl-16 {
  width: 100%;
  min-height: 100svh;
}

.tgl-16 ::selection {
  background: var(--color-tgl16-acc);
  color: #0a0616;
}
(function () {
  var root = document.getElementById('tgl-16');
  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 - 0.5) * 2 * MAX).toFixed(2) + 'deg');
      card.style.setProperty('--rx', ((0.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');
    });
  });
})();

Setup — do this once per project

Skip this if your project already runs Tailwind v4.

1Load Tailwind
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

Compiles in the browser — perfect for a CodePen or a quick test, not for production.

2Add the stylesheetRequiredthe @theme block is what makes the custom utilities exist
@import "tailwindcss";
/* @property registration has no utility form — typed variables are what make the
   settle-back on pointer-leave interpolate instead of snapping. */

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

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

@theme {
  --color-tgl16-well: #12101d;
  --color-tgl16-well: oklch(0.19 0.02 285);
  --color-tgl16-acc: #b9a7f7;
  --color-tgl16-acc: oklch(0.78 0.13 290);
  --font-tgl16-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-tgl16-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

.tgl-16 {
  width: 100%;
  min-height: 100svh;
}

.tgl-16 ::selection {
  background: var(--color-tgl16-acc);
  color: #0a0616;
}
3Fonts used by this demooptional — skip if you have your own type stack
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">

What changed in the port

Which parts became utilities, which stayed CSS, and why.

  • The four variables are declared as arbitrary properties in the class list ([--rx:0deg] etc.), so a reader can see the card's full interface without opening the script.
  • transform-3d is a real v4 utility for transform-style: preserve-3d; the tilt and the translateZ caption both depend on it and it is easy to forget.
  • UI change from the CSS original: the cards are now tabindex="0" with a visible focus ring. The original styled :focus-visible on an element that could never receive focus, so the rule was dead.
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 demo 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: Tailwind 3D Tilt Parallax Image Cards with Pointer-Driven Variables
Source: https://codefronts.com/snippets/tailwind-css-image-gallery/tailwind-3d-tilt-parallax-image-cards/

Photo cards that tilt in 3D toward the cursor with a moving specular glare and a caption floating above the image. JavaScript writes four numbers; every visual consequence is a Tailwind utility reading them — the cleanest possible division between behaviour and appearance.
## HTML
```html
<section class="tgl-16 w-full min-h-svh grid place-items-center px-5 py-10 font-tgl16-sans [perspective:1000px] bg-[radial-gradient(120%_120%_at_50%_15%,oklch(0.2_0.04_290),oklch(0.09_0.02_280))]" id="tgl-16" aria-label="3D tilt parallax cards">
  <div class="flex flex-wrap justify-center gap-7 w-[min(940px,100%)]">
    <article data-tilt tabindex="0" class="group relative w-[260px] rounded-[20px] overflow-hidden cursor-pointer bg-tgl16-well [--rx:0deg] [--ry:0deg] [--mx:50%] [--my:50%] [transform:rotateX(var(--rx))_rotateY(var(--ry))] transform-3d transition-[transform,box-shadow] duration-[400ms] shadow-[0_30px_60px_-30px_rgba(0,0,0,0.8)] after:content-[''] after:absolute after:inset-0 after:z-[3] after:pointer-events-none after:opacity-0 after:transition-opacity after:duration-300 after:mix-blend-screen after:bg-[radial-gradient(300px_300px_at_var(--mx)_var(--my),rgba(255,255,255,0.28),transparent_60%)] hover:after:opacity-100 focus-visible:outline-3 focus-visible:outline-tgl16-acc focus-visible:outline-offset-[3px] motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:after:hidden">
      <div class="overflow-hidden"><img src="https://images.unsplash.com/photo-1516031190212-da133013de50?q=80&w=500&h=620&auto=format&fit=crop" alt="Aurora over mountains" loading="lazy" decoding="async" class="block w-full [aspect-ratio:5/6] object-cover"></div>
      <figcaption class="absolute inset-x-0 bottom-0 z-[2] px-5 py-5 [transform:translateZ(50px)] bg-[linear-gradient(0deg,rgba(8,6,15,0.9),transparent)] motion-reduce:[transform:none]">
        <span class="font-tgl16-mono text-[11px] tracking-[0.2em] uppercase text-tgl16-acc">Iceland</span>
        <h3 class="mt-1 text-[19px] font-bold text-white">Northern Skies</h3>
      </figcaption>
    </article>
    <article data-tilt tabindex="0" class="group relative w-[260px] rounded-[20px] overflow-hidden cursor-pointer bg-tgl16-well [--rx:0deg] [--ry:0deg] [--mx:50%] [--my:50%] [transform:rotateX(var(--rx))_rotateY(var(--ry))] transform-3d transition-[transform,box-shadow] duration-[400ms] shadow-[0_30px_60px_-30px_rgba(0,0,0,0.8)] after:content-[''] after:absolute after:inset-0 after:z-[3] after:pointer-events-none after:opacity-0 after:transition-opacity after:duration-300 after:mix-blend-screen after:bg-[radial-gradient(300px_300px_at_var(--mx)_var(--my),rgba(255,255,255,0.28),transparent_60%)] hover:after:opacity-100 focus-visible:outline-3 focus-visible:outline-tgl16-acc focus-visible:outline-offset-[3px] motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:after:hidden">
      <div class="overflow-hidden"><img src="https://images.unsplash.com/photo-1483985988355-763728e1935b?q=80&w=500&h=620&auto=format&fit=crop" alt="Canyon at sunset" loading="lazy" decoding="async" class="block w-full [aspect-ratio:5/6] object-cover"></div>
      <figcaption class="absolute inset-x-0 bottom-0 z-[2] px-5 py-5 [transform:translateZ(50px)] bg-[linear-gradient(0deg,rgba(8,6,15,0.9),transparent)] motion-reduce:[transform:none]">
        <span class="font-tgl16-mono text-[11px] tracking-[0.2em] uppercase text-tgl16-acc">Utah</span>
        <h3 class="mt-1 text-[19px] font-bold text-white">Red Horizon</h3>
      </figcaption>
    </article>
    <article data-tilt tabindex="0" class="group relative w-[260px] rounded-[20px] overflow-hidden cursor-pointer bg-tgl16-well [--rx:0deg] [--ry:0deg] [--mx:50%] [--my:50%] [transform:rotateX(var(--rx))_rotateY(var(--ry))] transform-3d transition-[transform,box-shadow] duration-[400ms] shadow-[0_30px_60px_-30px_rgba(0,0,0,0.8)] after:content-[''] after:absolute after:inset-0 after:z-[3] after:pointer-events-none after:opacity-0 after:transition-opacity after:duration-300 after:mix-blend-screen after:bg-[radial-gradient(300px_300px_at_var(--mx)_var(--my),rgba(255,255,255,0.28),transparent_60%)] hover:after:opacity-100 focus-visible:outline-3 focus-visible:outline-tgl16-acc focus-visible:outline-offset-[3px] motion-reduce:[transform:none] motion-reduce:transition-none motion-reduce:after:hidden">
      <div class="overflow-hidden"><img src="https://images.unsplash.com/photo-1620799140408-edc6dcb6d633?q=80&w=500&h=620&auto=format&fit=crop" alt="Foggy forest at dawn" loading="lazy" decoding="async" class="block w-full [aspect-ratio:5/6] object-cover"></div>
      <figcaption class="absolute inset-x-0 bottom-0 z-[2] px-5 py-5 [transform:translateZ(50px)] bg-[linear-gradient(0deg,rgba(8,6,15,0.9),transparent)] motion-reduce:[transform:none]">
        <span class="font-tgl16-mono text-[11px] tracking-[0.2em] uppercase text-tgl16-acc">Oregon</span>
        <h3 class="mt-1 text-[19px] font-bold text-white">Deep Green</h3>
      </figcaption>
    </article>
  </div>
</section>
```
## CSS
```css
@import "tailwindcss";
/* @property registration has no utility form — typed variables are what make the
   settle-back on pointer-leave interpolate instead of snapping. */

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

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

@theme {
  --color-tgl16-well: #12101d;
  --color-tgl16-well: oklch(0.19 0.02 285);
  --color-tgl16-acc: #b9a7f7;
  --color-tgl16-acc: oklch(0.78 0.13 290);
  --font-tgl16-sans: 'Space Grotesk', system-ui, sans-serif;
  --font-tgl16-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
}

.tgl-16 {
  width: 100%;
  min-height: 100svh;
}

.tgl-16 ::selection {
  background: var(--color-tgl16-acc);
  color: #0a0616;
}
```

## JavaScript
```js
(function () {
  var root = document.getElementById('tgl-16');
  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 - 0.5) * 2 * MAX).toFixed(2) + 'deg');
      card.style.setProperty('--rx', ((0.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

JS supplies numbers, CSS supplies everything else. A pointer handler writes --rx, --ry, --mx and --my; nothing else. The card's transform, the glare position and the eased return are all declared in the class list, so the effect can be retuned without touching the script.

@property is the detail that makes it feel good. Registering the two rotations as typed <angle> values lets the browser interpolate them, so releasing the card settles rather than snaps. Registration has no utility form and lives in the stylesheet.

It refuses to run where it would be wrong. (hover:none) and (prefers-reduced-motion:reduce) both return early, and the matching motion-reduce: utilities flatten the card so nothing is left half-transformed.

Make it yours

  • Tilt intensity: the MAX constant in the script — &plusmn;8° is subtle, &plusmn;18° dramatic.
  • Parallax depth: the caption's translateZ(50px).
  • Glare: restyle or delete the after: layer for a matte tilt.

Gotchas — read before shipping

  • Write variables, not transform strings. Setting style.transform from JS takes ownership of the property and CSS can no longer ease it.
  • The card needs transform-3d (transform-style: preserve-3d) or the caption's translateZ flattens to nothing.
  • Guard for touch. A tilt that only responds to a cursor must be inert, not broken, where there is no cursor.

Browser support

ChromeSafariFirefoxEdge
111+16.4+128+111+

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

Techniques used in this demo

Theme tokensset in @theme, used by a utility

Plain CSSno Tailwind surface

HTML & JSno Tailwind involved

Search CodeFronts

Loading…