Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Image drag with tilt" for my site using the Enrichr Library element at https://enrichr.dev/elements/image-drag-tilt.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/image-drag-tilt.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- colorA: #005236
- colorB: #e6ff55
- count: 7
- tilt: 1
- friction: 0.93
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/image-drag-tilt.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-idt">
<div class="ef-idt__stage" tabindex="0" role="group" aria-roledescription="draggable gallery" aria-label="Art cards. Drag, or use the left and right arrow keys.">
<div class="ef-idt__track"></div>
<p class="ef-idt__hint" aria-hidden="true">Drag</p>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-idt { width: 100%; }
.ef-idt__stage { position: relative; overflow: hidden; width: 100%; min-height: 19rem; box-sizing: border-box; border-radius: 1rem; display: flex; align-items: center; cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; outline: none; }
.ef-idt__stage.is-drag { cursor: grabbing; }
.ef-idt__stage:focus-visible { box-shadow: inset 0 0 0 2px #e6ff55; }
.ef-idt__track { display: flex; gap: 1.1rem; padding: 2rem 1.6rem; will-change: transform; }
.ef-idt__card { position: relative; flex: 0 0 auto; width: 10.5rem; height: 13.5rem; border-radius: 0.9rem; overflow: hidden; box-shadow: 0 18px 30px -16px rgba(0, 0, 0, 0.55); will-change: transform; transform-origin: 50% 90%; pointer-events: none; }
.ef-idt__cap { position: absolute; left: 0.8rem; right: 0.8rem; bottom: 0.7rem; display: flex; justify-content: space-between; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #fbf8f1; }
.ef-idt__hint { position: absolute; left: 50%; bottom: 0.7rem; margin: 0; transform: translateX(-50%); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; opacity: 0.55; pointer-events: none; transition: opacity 400ms; }
.ef-idt__stage.is-moved .ef-idt__hint { opacity: 0; }
.ef-idt__card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 55%, rgba(0, 0, 0, 0.55)); }
.ef-idt__cap { z-index: 1; }JavaScript
// Enrichr Library · https://enrichr.dev
// Image drag tilt: a row of procedurally drawn art cards you can drag. Cards rotate and skew with the drag velocity, glide on with inertia
// when released, and ease softly back from the ends. Arrow keys nudge the row. Reduced motion keeps plain dragging with no tilt or glide.
// mount(root, { background, colorA, colorB, count (3-10), tilt (0-2), friction (0.8-0.98) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-idt__stage");
var track = root.querySelector(".ef-idt__track");
var opts = {}, cards = [], raf = 0, visible = true, io = null;
var x = 0, v = 0, tiltV = 0, dragging = false, lastX = 0, lastT = 0, minX = 0, moved = false, pid = null;
var TITLES = ["Tide", "Ember", "Moss", "Dusk", "Arch", "Grain", "Haze", "Loop", "Shore", "Bloom"];
function art(i, a, b) {
var c = "#fbf8f1";
var p = i % 6;
if (p === 0) return "radial-gradient(circle at 50% 42%, " + b + " 0 24%, transparent 25%), linear-gradient(160deg, " + a + ", #0c1e18)";
if (p === 1) return "conic-gradient(from 200deg at 30% 70%, " + a + ", " + b + ", " + c + ", " + a + ")";
if (p === 2) return "repeating-linear-gradient(115deg, " + a + " 0 14px, #0c1e18 14px 28px), linear-gradient(" + a + ", " + a + ")";
if (p === 3) return "radial-gradient(circle at 36% 40%, " + b + " 0 22%, transparent 23%), radial-gradient(circle at 64% 60%, " + c + " 0 22%, transparent 23%), linear-gradient(200deg, " + a + ", #0c3a2b)";
if (p === 4) return "radial-gradient(ellipse 34% 28% at 50% 46%, " + c + " 0 99%, transparent 100%), linear-gradient(" + c + ", " + c + ") 50% 100% / 68% 46% no-repeat, linear-gradient(180deg, " + a + ", " + b + ")";
return "radial-gradient(circle, " + b + " 0 2.5px, transparent 3px) 0 0 / 18px 18px, linear-gradient(145deg, " + a + ", #0c1e18)";
}
function build() {
track.textContent = "";
cards = [];
for (var i = 0; i < opts.count; i++) {
var d = document.createElement("div");
d.className = "ef-idt__card";
d.style.background = art(i, opts.colorA, opts.colorB);
d.setAttribute("aria-hidden", "true");
var cap = document.createElement("div");
cap.className = "ef-idt__cap";
var s1 = document.createElement("span"); s1.textContent = TITLES[i % TITLES.length];
var s2 = document.createElement("span"); s2.textContent = String(i + 1).padStart(2, "0");
cap.appendChild(s1); cap.appendChild(s2); d.appendChild(cap);
track.appendChild(d);
cards.push(d);
}
measure();
x = Math.max(minX, Math.min(0, x));
paint();
}
function measure() { minX = Math.min(0, stage.clientWidth - track.scrollWidth); }
function paint() {
track.style.transform = "translate3d(" + x.toFixed(2) + "px,0,0)";
if (reduce) return;
var t = Math.max(-1, Math.min(1, tiltV / 40)) * opts.tilt;
for (var i = 0; i < cards.length; i++) {
var f = 0.75 + (i % 3) * 0.25;
cards[i].style.transform = "rotate(" + (t * 14 * f).toFixed(2) + "deg) skewX(" + (-t * 9 * f).toFixed(2) + "deg) translateY(" + (Math.abs(t) * -6 * f).toFixed(1) + "px)";
}
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
function frame() {
raf = 0;
measure();
if (!dragging) {
if (reduce) { v = 0; } else {
x += v;
v *= opts.friction;
if (x > 0) { v += (0 - x) * 0.07; v *= 0.82; if (Math.abs(x) < 0.3 && Math.abs(v) < 0.3) { x = 0; v = 0; } }
else if (x < minX) { v += (minX - x) * 0.07; v *= 0.82; if (Math.abs(x - minX) < 0.3 && Math.abs(v) < 0.3) { x = minX; v = 0; } }
}
}
tiltV += (v - tiltV) * 0.14;
paint();
var settled = !dragging && Math.abs(v) < 0.05 && Math.abs(tiltV) < 0.05 && x <= 0 && x >= minX;
if (!settled) raf = requestAnimationFrame(frame);
else { tiltV = 0; paint(); }
}
function down(e) {
if (e.button > 0) return;
dragging = true; moved = false; pid = e.pointerId;
lastX = e.clientX; lastT = performance.now(); v = 0;
try { stage.setPointerCapture(e.pointerId); } catch (x2) {}
stage.classList.add("is-drag");
wake();
}
function move(e) {
if (!dragging || e.pointerId !== pid) return;
var now = performance.now();
var dx = e.clientX - lastX, dt = Math.max(1, now - lastT);
var over = x > 0 ? x : x < minX ? minX - x : 0;
x += dx * (over > 0 ? 1 / (1 + over / 60) : 1);
var inst = dx / dt * 16;
v += (inst - v) * 0.4;
lastX = e.clientX; lastT = now;
if (!moved && Math.abs(dx) > 1) { moved = true; stage.classList.add("is-moved"); }
wake();
}
function up(e) {
if (!dragging || (e && e.pointerId !== pid)) return;
dragging = false;
if (performance.now() - lastT > 90) v = 0;
stage.classList.remove("is-drag");
wake();
}
function key(e) {
var d = e.key === "ArrowRight" ? -1 : e.key === "ArrowLeft" ? 1 : 0;
if (!d) return;
e.preventDefault();
stage.classList.add("is-moved");
if (reduce) { x = Math.max(minX, Math.min(0, x + d * 160)); paint(); } else { v += d * 22; }
wake();
}
function resize() { measure(); x = Math.max(minX, Math.min(0, x)); wake(); }
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", colorA: "#005236", colorB: "#e6ff55", count: 7, tilt: 1, friction: 0.93 }, opts, next || {});
opts.count = Math.max(3, Math.min(10, Math.round(opts.count)));
stage.style.background = opts.background;
stage.style.color = "#fbf8f1";
if (prev.count !== opts.count || prev.colorA !== opts.colorA || prev.colorB !== opts.colorB) build();
}
update(o);
stage.addEventListener("pointerdown", down);
stage.addEventListener("pointermove", move);
stage.addEventListener("pointerup", up);
stage.addEventListener("pointercancel", up);
stage.addEventListener("keydown", key);
window.addEventListener("resize", resize);
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (en) { visible = en[0].isIntersecting; if (visible) wake(); else { cancelAnimationFrame(raf); raf = 0; } });
io.observe(stage);
}
if (!reduce) { v = -16; wake(); }
return {
update: update,
destroy: function () {
cancelAnimationFrame(raf); raf = 0;
if (io) io.disconnect();
stage.removeEventListener("pointerdown", down);
stage.removeEventListener("pointermove", move);
stage.removeEventListener("pointerup", up);
stage.removeEventListener("pointercancel", up);
stage.removeEventListener("keydown", key);
window.removeEventListener("resize", resize);
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"colorA":"#005236","colorB":"#e6ff55","count":7,"tilt":1,"friction":0.93});How it works
The row is moved by a single translate value. While dragging, the pointer delta sets the position and a smoothed velocity is tracked. On release, that velocity keeps moving the row and decays by a friction factor each frame. Past either end, a spring pulls the row back and the drag itself is damped, so it feels like rubber rather than a wall. A second smoothed value follows the velocity and drives each card rotate, skewX and lift, with a small per-card multiplier so the cards do not move in lockstep. The loop stops when everything is at rest.
Accessibility and performance
The area is focusable and responds to the left and right arrow keys, which nudge the row with the same inertia. Cards are decorative and hidden from assistive technology, so replace them with real links or buttons for content that needs to be read. Reduced motion keeps plain dragging with no tilt, glide or springs. Touch input keeps vertical page scrolling.
Only transforms change, on the row and on each card, and only while something is moving. The loop sleeps at rest and when the element is off screen. Ten cards with gradient art is light on the GPU.
| Section | Gallery |
|---|---|
| Techniques | Pointer interaction, Carousel |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Image Dragging Effects by Manoela Ilic on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Can I use real photos?
Yes. Replace the gradient backgrounds on the .ef-idt__card elements with images. Keep the cards non-interactive for pointer events, or move the drag handler to a wrapper.
Why do the cards tilt while the row moves?
The tilt follows velocity, not position. A fast flick tilts the cards a lot and a slow drag barely at all, then they settle as the row slows down.
Does it need a library?
No. It is plain pointer events, requestAnimationFrame and CSS transforms.