How to Build a 3D Tilt Profile Card with HTML, CSS & JavaScript
Modern profile cards don't have to feel flat.
In this tutorial, we'll build an interactive 3D Tilt Profile Card that responds to mouse movement with smooth rotation, layered parallax, cursor-follow lighting, and a dynamic glare effect.
The component is built with pure HTML, CSS, and JavaScript, without any external animation library.
What We're Building
The card includes:
- Smooth cursor-based 3D tilt
- Layered parallax movement
- Cursor-follow glow
- Dynamic glare effect
- Animated availability indicator
- Rotating avatar ring
- Profile skills and statistics
- Responsive layout
- Touch-device fallback
- Reduced-motion support
The final component works well for developer portfolios, personal websites, team pages, creator profiles, and modern landing pages.
HTML Structure
Let's start with the markup.
The data-depth attributes will later allow JavaScript to move different sections of the card at different speeds, creating the layered 3D effect.
<div class="cr-tilt-profile" id="crTiltProfile">
<div class="cr-tilt-profile__stage">
<article class="cr-tilt-profile__card">
<!-- CURSOR FOLLOW GLOW -->
<div class="cr-tilt-profile__glow" aria-hidden="true"></div>
<!-- GLARE EFFECT -->
<div class="cr-tilt-profile__glare" aria-hidden="true"></div>
<!-- DECORATIVE BACKGROUND -->
<div class="cr-tilt-profile__decor" aria-hidden="true">
<span
class="cr-tilt-profile__orb cr-tilt-profile__orb--one"
></span>
<span
class="cr-tilt-profile__orb cr-tilt-profile__orb--two"
></span>
<span class="cr-tilt-profile__grid"></span>
</div>
<!-- PROFILE CONTENT -->
<div class="cr-tilt-profile__content">
<!-- TOP STATUS -->
<div
class="cr-tilt-profile__top"
data-depth="18"
>
<span class="cr-tilt-profile__status">
<span
class="cr-tilt-profile__status-dot"
></span>
Available for work
</span>
<span class="cr-tilt-profile__badge">
PRO
</span>
</div>
<!-- AVATAR -->
<div
class="cr-tilt-profile__avatar-wrap"
data-depth="34"
>
<div class="cr-tilt-profile__avatar">
<span>AR</span>
</div>
<span
class="cr-tilt-profile__avatar-ring"
></span>
</div>
<!-- PROFILE INFO -->
<div
class="cr-tilt-profile__info"
data-depth="26"
>
<span class="cr-tilt-profile__eyebrow">
FRONTEND DEVELOPER
</span>
<h2>Alex Rivera</h2>
<p>
Building clean interfaces, smooth interactions
and memorable digital experiences.
</p>
</div>
<!-- SKILLS -->
<div
class="cr-tilt-profile__skills"
data-depth="22"
>
<span>HTML</span>
<span>CSS</span>
<span>JavaScript</span>
</div>
<!-- STATS -->
<div
class="cr-tilt-profile__stats"
data-depth="20"
>
<div class="cr-tilt-profile__stat">
<strong>42</strong>
<span>Projects</span>
</div>
<div class="cr-tilt-profile__divider"></div>
<div class="cr-tilt-profile__stat">
<strong>18K</strong>
<span>Followers</span>
</div>
<div class="cr-tilt-profile__divider"></div>
<div class="cr-tilt-profile__stat">
<strong>4.9</strong>
<span>Rating</span>
</div>
</div>
<!-- ACTIONS -->
<div
class="cr-tilt-profile__actions"
data-depth="30"
>
<button
class="cr-tilt-profile__button cr-tilt-profile__button--primary"
type="button"
>
<span>View Profile</span>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M5 12h14M13 6l6 6-6 6"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button
class="cr-tilt-profile__button cr-tilt-profile__button--icon"
type="button"
aria-label="Send message"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M21 3L9.5 14.5M21 3l-7 18-4.5-6.5L3 10l18-7z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
</div>
<!-- BOTTOM ACCENT -->
<div
class="cr-tilt-profile__accent"
aria-hidden="true"
></div>
</article>
<!-- INTERACTION HINT -->
<div class="cr-tilt-profile__hint">
<span class="cr-tilt-profile__hint-icon">
↗
</span>
Move your cursor over the card
</div>
</div>
</div>
CSS Styling
Now let's create the dark glass-style interface.
The perspective and transform-style: preserve-3d properties are especially important because they allow the card and its internal layers to appear at different depths.
.cr-tilt-profile {
--cr-bg: #070914;
--cr-card: rgba(13, 17, 35, 0.86);
--cr-card-border: rgba(255, 255, 255, 0.1);
--cr-cyan: #22d3ee;
--cr-purple: #a855f7;
--cr-text: #f8fafc;
--cr-muted: #94a3b8;
--cr-soft: #cbd5e1;
width: 100%;
min-height: 650px;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 20px;
background:
radial-gradient(
circle at 25% 20%,
rgba(34, 211, 238, 0.08),
transparent 32%
),
radial-gradient(
circle at 78% 78%,
rgba(168, 85, 247, 0.1),
transparent 35%
),
var(--cr-bg);
font-family:
Inter,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
box-sizing: border-box;
overflow: hidden;
}
.cr-tilt-profile *,
.cr-tilt-profile *::before,
.cr-tilt-profile *::after {
box-sizing: border-box;
}
.cr-tilt-profile__stage {
width: min(100%, 420px);
display: flex;
flex-direction: column;
align-items: center;
perspective: 1100px;
}
.cr-tilt-profile__card {
position: relative;
width: 100%;
min-height: 535px;
padding: 28px;
border: 1px solid var(--cr-card-border);
border-radius: 28px;
background:
linear-gradient(
145deg,
rgba(255, 255, 255, 0.075),
rgba(255, 255, 255, 0.018)
),
var(--cr-card);
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.02) inset,
0 0 55px rgba(34, 211, 238, 0.035);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
transform-style: preserve-3d;
transform: rotateX(0deg) rotateY(0deg);
transition:
transform 180ms ease,
box-shadow 250ms ease,
border-color 250ms ease;
overflow: hidden;
will-change: transform;
}
.cr-tilt-profile__card:hover {
border-color: rgba(34, 211, 238, 0.22);
box-shadow:
0 38px 95px rgba(0, 0, 0, 0.58),
0 0 65px rgba(34, 211, 238, 0.07),
0 0 80px rgba(168, 85, 247, 0.045);
}
.cr-tilt-profile__glow {
position: absolute;
left: 50%;
top: 50%;
width: 320px;
height: 320px;
border-radius: 50%;
background:
radial-gradient(
circle,
rgba(34, 211, 238, 0.16) 0%,
rgba(168, 85, 247, 0.08) 34%,
transparent 70%
);
transform: translate(-50%, -50%);
filter: blur(18px);
opacity: 0.5;
pointer-events: none;
z-index: 0;
}
.cr-tilt-profile__glare {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
mix-blend-mode: screen;
z-index: 6;
transition: opacity 250ms ease;
}
.cr-tilt-profile__decor {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 0;
}
.cr-tilt-profile__orb {
position: absolute;
width: 190px;
height: 190px;
border-radius: 50%;
filter: blur(12px);
opacity: 0.18;
}
.cr-tilt-profile__orb--one {
top: -90px;
right: -65px;
background:
radial-gradient(
circle,
var(--cr-cyan),
transparent 68%
);
}
.cr-tilt-profile__orb--two {
bottom: -100px;
left: -75px;
background:
radial-gradient(
circle,
var(--cr-purple),
transparent 68%
);
}
.cr-tilt-profile__grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(
rgba(255, 255, 255, 0.018) 1px,
transparent 1px
),
linear-gradient(
90deg,
rgba(255, 255, 255, 0.018) 1px,
transparent 1px
);
background-size: 28px 28px;
}
.cr-tilt-profile__content {
position: relative;
z-index: 3;
display: flex;
flex-direction: column;
min-height: 475px;
transform-style: preserve-3d;
}
.cr-tilt-profile [data-depth] {
position: relative;
transform-style: preserve-3d;
will-change: transform;
transition: transform 180ms ease;
}
.cr-tilt-profile__top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.cr-tilt-profile__status {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--cr-soft);
font-size: 11px;
font-weight: 600;
}
.cr-tilt-profile__status-dot {
position: relative;
width: 8px;
height: 8px;
border-radius: 50%;
background: #34d399;
box-shadow:
0 0 0 4px rgba(52, 211, 153, 0.08),
0 0 14px rgba(52, 211, 153, 0.55);
}
.cr-tilt-profile__status-dot::after {
content: "";
position: absolute;
inset: -4px;
border: 1px solid rgba(52, 211, 153, 0.25);
border-radius: inherit;
animation: crTiltStatusPulse 2.2s ease-out infinite;
}
@keyframes crTiltStatusPulse {
0% {
transform: scale(0.7);
opacity: 0.8;
}
70%,
100% {
transform: scale(1.8);
opacity: 0;
}
}
.cr-tilt-profile__badge {
padding: 6px 10px;
border: 1px solid rgba(168, 85, 247, 0.25);
border-radius: 999px;
background: rgba(168, 85, 247, 0.08);
color: #d8b4fe;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.14em;
}
.cr-tilt-profile__avatar-wrap {
position: relative;
width: 104px;
height: 104px;
margin: 2px auto 22px;
display: flex;
align-items: center;
justify-content: center;
}
.cr-tilt-profile__avatar {
position: relative;
width: 88px;
height: 88px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 26px;
background:
linear-gradient(
135deg,
rgba(34, 211, 238, 0.22),
rgba(168, 85, 247, 0.22)
),
#11162b;
box-shadow:
0 15px 35px rgba(0, 0, 0, 0.3),
0 0 30px rgba(34, 211, 238, 0.1);
transform: rotate(-4deg);
z-index: 2;
}
.cr-tilt-profile__avatar span {
background:
linear-gradient(
135deg,
#67e8f9,
#c084fc
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: 27px;
font-weight: 800;
}
.cr-tilt-profile__avatar-ring {
position: absolute;
inset: 0;
border: 1px dashed rgba(34, 211, 238, 0.2);
border-radius: 32px;
animation: crTiltAvatarRing 14s linear infinite;
}
@keyframes crTiltAvatarRing {
to {
transform: rotate(360deg);
}
}
.cr-tilt-profile__info {
text-align: center;
}
.cr-tilt-profile__eyebrow {
display: block;
margin-bottom: 7px;
color: var(--cr-cyan);
font-size: 9px;
font-weight: 800;
letter-spacing: 0.16em;
}
.cr-tilt-profile__info h2 {
margin: 0;
color: var(--cr-text);
font-size: 31px;
line-height: 1.1;
}
.cr-tilt-profile__info p {
max-width: 310px;
margin: 11px auto 0;
color: var(--cr-muted);
font-size: 12px;
line-height: 1.65;
}
.cr-tilt-profile__skills {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 7px;
margin-top: 18px;
}
.cr-tilt-profile__skills span {
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: #b6c2d3;
font-size: 9px;
font-weight: 650;
}
.cr-tilt-profile__stats {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
align-items: center;
margin-top: 24px;
padding: 15px 10px;
border-top: 1px solid rgba(255, 255, 255, 0.065);
border-bottom: 1px solid rgba(255, 255, 255, 0.065);
}
.cr-tilt-profile__stat {
text-align: center;
}
.cr-tilt-profile__stat strong {
display: block;
color: var(--cr-text);
font-size: 16px;
}
.cr-tilt-profile__stat span {
display: block;
margin-top: 4px;
color: #748198;
font-size: 8px;
text-transform: uppercase;
}
.cr-tilt-profile__divider {
width: 1px;
height: 25px;
background:
linear-gradient(
to bottom,
transparent,
rgba(255, 255, 255, 0.12),
transparent
);
}
.cr-tilt-profile__actions {
display: grid;
grid-template-columns: 1fr 46px;
gap: 10px;
margin-top: auto;
padding-top: 24px;
}
.cr-tilt-profile__button {
appearance: none;
border: 0;
outline: none;
font: inherit;
cursor: pointer;
}
.cr-tilt-profile__button--primary {
min-height: 46px;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 18px;
border: 1px solid rgba(34, 211, 238, 0.3);
border-radius: 13px;
background:
linear-gradient(
135deg,
rgba(34, 211, 238, 0.15),
rgba(168, 85, 247, 0.13)
);
color: #e6fbff;
font-size: 11px;
font-weight: 700;
}
.cr-tilt-profile__button--primary svg {
width: 16px;
height: 16px;
}
.cr-tilt-profile__button--icon {
width: 46px;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 13px;
background: rgba(255, 255, 255, 0.035);
color: #b9c5d6;
}
.cr-tilt-profile__button--icon svg {
width: 17px;
height: 17px;
}
.cr-tilt-profile__accent {
position: absolute;
left: 18%;
right: 18%;
bottom: -1px;
height: 2px;
background:
linear-gradient(
90deg,
transparent,
var(--cr-cyan),
var(--cr-purple),
transparent
);
}
.cr-tilt-profile__hint {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin-top: 18px;
color: #5f6c82;
font-size: 10px;
font-weight: 600;
}
.cr-tilt-profile__hint-icon {
color: var(--cr-cyan);
font-size: 13px;
animation: crTiltHintMove 1.8s ease-in-out infinite;
}
@keyframes crTiltHintMove {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(3px, -3px);
}
}
@media (max-width: 520px) {
.cr-tilt-profile {
min-height: auto;
padding: 32px 16px;
}
.cr-tilt-profile__stage {
max-width: 390px;
}
.cr-tilt-profile__card {
min-height: 510px;
padding: 23px 20px;
}
}
@media (hover: none), (pointer: coarse) {
.cr-tilt-profile__hint {
display: none;
}
.cr-tilt-profile__card,
.cr-tilt-profile [data-depth] {
transform: none !important;
}
.cr-tilt-profile__glare {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cr-tilt-profile__status-dot::after,
.cr-tilt-profile__avatar-ring,
.cr-tilt-profile__hint-icon {
animation: none !important;
}
}
JavaScript for the 3D Tilt Effect
The JavaScript calculates the cursor position relative to the card and converts it into X and Y rotation values.
We also use the data-depth values to move individual sections independently.
(() => {
const component = document.querySelector("#crTiltProfile");
if (!component) return;
const card = component.querySelector(".cr-tilt-profile__card");
const glow = component.querySelector(".cr-tilt-profile__glow");
const glare = component.querySelector(".cr-tilt-profile__glare");
const depthItems = component.querySelectorAll("[data-depth]");
if (!card || !glow || !glare) return;
const MAX_TILT = 10;
const PARALLAX_STRENGTH = 0.32;
const canHover = window.matchMedia(
"(hover: hover) and (pointer: fine)"
);
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
);
let targetRotateX = 0;
let targetRotateY = 0;
let currentRotateX = 0;
let currentRotateY = 0;
let pointerX = 0;
let pointerY = 0;
let isHovering = false;
let animationFrame = null;
const clamp = (value, min, max) => {
return Math.min(Math.max(value, min), max);
};
const updateDepthItems = () => {
depthItems.forEach((item) => {
const depth =
Number.parseFloat(item.dataset.depth) || 0;
const moveX =
pointerX * depth * PARALLAX_STRENGTH;
const moveY =
pointerY * depth * PARALLAX_STRENGTH;
item.style.transform =
`translate3d(${moveX}px, ${moveY}px, ${depth}px)`;
});
};
const resetDepthItems = () => {
depthItems.forEach((item) => {
item.style.transform =
"translate3d(0px, 0px, 0px)";
});
};
const animate = () => {
currentRotateX +=
(targetRotateX - currentRotateX) * 0.12;
currentRotateY +=
(targetRotateY - currentRotateY) * 0.12;
card.style.transform =
`rotateX(${currentRotateX}deg)
rotateY(${currentRotateY}deg)`;
if (
isHovering ||
Math.abs(currentRotateX) > 0.01 ||
Math.abs(currentRotateY) > 0.01
) {
animationFrame =
requestAnimationFrame(animate);
} else {
currentRotateX = 0;
currentRotateY = 0;
card.style.transform =
"rotateX(0deg) rotateY(0deg)";
animationFrame = null;
}
};
const handlePointerMove = (event) => {
if (!canHover.matches || reduceMotion.matches) {
return;
}
const rect = card.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const normalizedX =
clamp((x / rect.width) * 2 - 1, -1, 1);
const normalizedY =
clamp((y / rect.height) * 2 - 1, -1, 1);
pointerX = normalizedX;
pointerY = normalizedY;
targetRotateX =
normalizedY * -MAX_TILT;
targetRotateY =
normalizedX * MAX_TILT;
glow.style.left = `${x}px`;
glow.style.top = `${y}px`;
const glareX =
50 + normalizedX * 35;
const glareY =
50 + normalizedY * 35;
glare.style.background =
`radial-gradient(
circle at ${glareX}% ${glareY}%,
rgba(255, 255, 255, 0.20) 0%,
rgba(103, 232, 249, 0.08) 18%,
rgba(168, 85, 247, 0.04) 34%,
transparent 62%
)`;
updateDepthItems();
};
const handlePointerEnter = () => {
if (!canHover.matches || reduceMotion.matches) {
return;
}
isHovering = true;
glow.style.opacity = "0.9";
glare.style.opacity = "0.55";
if (!animationFrame) {
animationFrame =
requestAnimationFrame(animate);
}
};
const handlePointerLeave = () => {
isHovering = false;
targetRotateX = 0;
targetRotateY = 0;
pointerX = 0;
pointerY = 0;
glow.style.left = "50%";
glow.style.top = "50%";
glow.style.opacity = "0.5";
glare.style.opacity = "0";
resetDepthItems();
if (!animationFrame) {
animationFrame =
requestAnimationFrame(animate);
}
};
card.addEventListener(
"pointerenter",
handlePointerEnter
);
card.addEventListener(
"pointermove",
handlePointerMove
);
card.addEventListener(
"pointerleave",
handlePointerLeave
);
window.addEventListener("blur", () => {
if (isHovering) {
handlePointerLeave();
}
});
document.addEventListener(
"visibilitychange",
() => {
if (document.hidden && isHovering) {
handlePointerLeave();
}
}
);
})();
How the Tilt Effect Works
The interaction starts by reading the pointer position inside the card:
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
Those values are converted to a range between -1 and 1.
That normalized position determines how far the card should rotate.
targetRotateX = normalizedY * -MAX_TILT;
targetRotateY = normalizedX * MAX_TILT;
The maximum rotation is limited to:
const MAX_TILT = 10;
Keeping the rotation relatively small makes the interaction feel smooth instead of making the card difficult to read.
Creating Layered Parallax
The HTML contains attributes such as:
data-depth="18"
data-depth="34"
data-depth="26"
JavaScript reads these values and applies different translate3d() positions to each content group.
For example, the avatar uses a larger depth value than the status row, so it appears closer to the viewer while the card is moving.
This creates the layered 3D illusion without requiring WebGL or a JavaScript animation library.
Cursor-Follow Glow
The glow element follows the pointer:
glow.style.left = `${x}px`;
glow.style.top = `${y}px`;
Because the glow itself is a blurred radial gradient, moving it around the card creates the appearance of an interactive light source.
The cyan and purple colors also match the rest of the card's visual system.
Dynamic Glare
The glare position is calculated from the normalized pointer coordinates:
const glareX = 50 + normalizedX * 35;
const glareY = 50 + normalizedY * 35;
Those values are then used as the center of a radial gradient.
As the cursor moves, the highlight moves across the surface of the card, helping reinforce the 3D effect.
Touch Device Support
A mouse-based tilt effect doesn't translate naturally to every touch device.
The component checks whether the browser supports a traditional hover-capable pointer:
const canHover = window.matchMedia(
"(hover: hover) and (pointer: fine)"
);
The CSS also disables the tilt transforms and glare on coarse-pointer devices.
@media (hover: none), (pointer: coarse) {
.cr-tilt-profile__card,
.cr-tilt-profile [data-depth] {
transform: none !important;
}
.cr-tilt-profile__glare {
display: none;
}
}
The profile card therefore remains usable on phones and tablets instead of depending on mouse interaction.
Reduced Motion Support
Interactive effects should also respect users who prefer reduced motion.
The component checks:
@media (prefers-reduced-motion: reduce)
and disables decorative animations such as the pulsing availability indicator, rotating avatar ring, and interaction hint.
JavaScript also checks the same preference before activating pointer-based motion.
Try the Live Demo
You can experiment with the complete component on CodePen:
👉 3D Tilt Profile Card - Live CodePen Demo
Move your cursor across different parts of the card to see how the rotation, parallax layers, glow, and glare respond.
Where Can You Use This Component?
A 3D profile card can work especially well in:
- Developer portfolios
- Designer portfolios
- Team member sections
- Personal landing pages
- Creator profiles
- Agency websites
- SaaS team pages
- Interactive showcase sections
Because the profile information is regular HTML, you can easily replace the name, role, skills, statistics, buttons, and avatar with your own content.
Customization Ideas
Once you understand the interaction logic, there are plenty of ways to customize the component.
You could:
- Replace the initials with a real profile image
- Change the cyan and purple theme
- Add social media buttons
- Link the View Profile button to a portfolio
- Add GitHub or LinkedIn information
- Adjust
MAX_TILTfor stronger or softer rotation - Change individual
data-depthvalues - Add more profile statistics
- Convert it into a team member card
- Use the same tilt engine for product or pricing cards
For a stronger 3D effect, increase the depth values slightly.
For a more subtle professional interface, reduce both the depth values and MAX_TILT.
Final Thoughts
This component combines several small effects to create one polished interaction.
The card itself uses CSS for the visual design, while JavaScript handles pointer tracking, rotation, parallax depth, cursor-follow lighting, and dynamic glare.
The important part is that the effect remains progressive: desktop users get the full interactive experience, while touch devices still receive a clean and functional profile card.
You can view the complete tutorial and copy the source code here:
👉 3D Tilt Profile Card - Full Tutorial & Source Code
If you're experimenting with modern CSS and JavaScript interactions, try changing the tilt strength and data-depth values to create your own version.
Happy coding! 🚀
Top comments (0)