Bước 1: Tạo cấu trúc HTML của card
Chúng ta bọc thẻ card trong một container có perspective. Bên trong là một phần tử “stage” để xoay 3D và lớp “glare” mô phỏng ánh sáng phản chiếu.
<div class="im-tilt uk-card uk-card-default uk-card-body uk-border-rounded uk-box-shadow-medium"
data-tilt data-tilt-max="14" data-tilt-scale="1.02">
<div class="im-tilt-stage">
<img class="uk-border-rounded uk-margin-small-bottom" src="thumb.jpg" alt="Ảnh minh họa" width="960" height="540">
<h3 class="uk-margin-remove">Card 3D Hover</h3>
<p class="uk-text-meta">CSS perspective + transform + glare</p>
<div class="im-tilt-glare" aria-hidden="true"></div>
</div>
</div>
Bước 2: Viết CSS perspective, các lớp 3D và glare
Container chỉ giữ nhiệm vụ cung cấp “điểm nhìn” (perspective). Phần tử stage bật transform-style: preserve-3d để nội dung xoay mượt. Lớp glare dùng gradient bán trong suốt di chuyển theo chuột.
/* ===== Card 3D Hover (UIkit-friendly) ===== */
.im-tilt {
/* container đóng vai trò "máy ảnh" */
perspective: 900px;
border-radius: 16px;
overflow: hidden; /* bo góc ảnh + glare */
}
.im-tilt-stage {
position: relative;
transform-style: preserve-3d;
transition: transform 180ms ease, box-shadow 180ms ease;
will-change: transform;
}
.im-tilt .im-tilt-glare {
pointer-events: none;
position: absolute;
inset: -20%; /* phủ rộng để khi xoay không lộ mép */
background: radial-gradient(ellipse at center,
rgba(255,255,255,0.35) 0%,
rgba(255,255,255,0.0) 60%);
transform: translateZ(60px); /* nổi lên một chút để thật hơn */
opacity: 0; /* sẽ điều khiển bằng JS */
}
/* Hiệu ứng “nổi” nhẹ khi hover (fallback nếu tắt JS) */
.im-tilt:hover .im-tilt-stage {
transform: translateZ(10px);
}
/* Tối ưu hiển thị ảnh & nội dung */
.im-tilt img { width: 100%; height: auto; display: block; }
.im-tilt h3 { font-weight: 600; }
.im-tilt p.uk-text-meta { margin-top: 4px; }
/* Dark mode: nếu site dùng .uk-dark hoặc .dark thì dùng nền card tối hơn */
.uk-dark .im-tilt.uk-card-default,
.dark .im-tilt.uk-card-default {
background: #1f1f1f;
color: #eaeaea;
}
/* Tôn trọng người dùng giảm chuyển động */
@media (prefers-reduced-motion: reduce) {
.im-tilt-stage { transition: none !important; }
}
Bước 3: Thêm JavaScript điều khiển góc nghiêng theo chuột
Ta đọc vị trí con trỏ so với bounding box của card, nội suy thành góc xoay X/Y và áp dụng bằng requestAnimationFrame để mượt. Khi rời chuột, card trở lại trạng thái ban đầu.
// 3D Tilt minimal driver (multi-card, UIkit-friendly)
(function () {
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function initTilt(card) {
const stage = card.querySelector('.im-tilt-stage');
const glare = card.querySelector('.im-tilt-glare');
if (!stage) return;
// Tùy chỉnh qua data-attribute
const max = Number(card.getAttribute('data-tilt-max') || 15); // độ nghiêng tối đa (deg)
const scale = Number(card.getAttribute('data-tilt-scale') || 1); // scale khi hover
let frame = null;
let current = { rx: 0, ry: 0, s: 1 };
function apply(rx, ry, s, gOpacity, gx, gy) {
stage.style.transform =
`rotateX(${rx}deg) rotateY(${ry}deg) translateZ(0) scale(${s})`;
if (glare) {
glare.style.opacity = gOpacity.toFixed(2);
// di chuyển center của radial glare theo chuột
glare.style.background = `radial-gradient(650px 350px at ${gx}% ${gy}%,
rgba(255,255,255,0.35) 0%,
rgba(255,255,255,0.0) 60%)`;
}
}
function compute(e) {
const rect = card.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const px = (e.clientX - cx) / (rect.width / 2); // [-1, 1]
const py = (e.clientY - cy) / (rect.height / 2); // [-1, 1]
const ry = clamp(px * max, -max, max);
const rx = clamp(-py * max, -max, max);
const s = scale > 1 ? scale : 1;
const gOpacity = 0.35 * (Math.abs(px) + Math.abs(py)) / 2;
const gx = clamp((px + 1) * 50, 0, 100);
const gy = clamp((py + 1) * 50, 0, 100);
return { rx, ry, s, gOpacity, gx, gy };
}
function onMove(e) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const next = compute(e);
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
current = next;
apply(next.rx, next.ry, next.s, next.gOpacity ?? 0, next.gx ?? 50, next.gy ?? 50);
});
}
function onLeave() {
if (frame) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
current = { rx: 0, ry: 0, s: 1 };
apply(0, 0, 1, 0, 50, 50);
});
}
card.addEventListener('mousemove', onMove);
card.addEventListener('mouseleave', onLeave);
// Touch (giữ hành vi nhẹ nhàng)
card.addEventListener('touchmove', (ev) => {
if (!ev.touches || !ev.touches[0]) return;
onMove({ clientX: ev.touches[0].clientX, clientY: ev.touches[0].clientY });
}, { passive: true });
card.addEventListener('touchend', onLeave);
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-tilt]').forEach(initTilt);
});
})();
Mẹo tối ưu và truy cập
- Tắt hiệu ứng với
prefers-reduced-motion: reduce. - Giới hạn góc xoay bằng
data-tilt-max, thêm hiệu ứng scale bằngdata-tilt-scale. - Tránh tạo quá nhiều shadow/blur nặng; dùng
will-change: transformđúng chỗ.
Bình luận