A while back, I wrote an article on 3d interactive CSS buttons. Using a similar technique, I decided to design some 3d interactive (and flippable) CSS user cards. These also work great for lots of different things - for example, a bank card UI, a playing card UI, or just a teams page. The demo can be seen below!
The full code, as always, is Available on CodePen.
3d flippable cards with CSS and Javascript
Hover over the cards (or tap anywhere on the card on mobile) below to see the effect in full swing.
To achieve this effect, we have to combine a few different things in both Javascript and CSS:
- First, we need to create a function which lets us manipulate the angle of the card based on mouse position.
- Next, we need to use that function to figure out the position to add a 'glare' light effect on top of the card.
- Then, we need to add a lot of CSS to create a backface and a front face for the card.
- Finally, we need to add a few functions in our Javascript to allow us to 'flip' the card.
Creating the HTML
Let's start with the HTML. Here's what it looks like for our first card. Each card has two main parts - inner-card
, and inner-card-backface
. The first contains the front of the card, and the second, the back.
We also have two buttons - flip, and unflip, to change which side of the card is visible.
<div class="card blastoise">
<span class="inner-card-backface"> <!-- back of the card -->
<span class="image">
<span class="unflip">Unflip</span>
</span>
</span>
<span class="inner-card"> <!-- front of the card -->
<span class="flip">Flip</span>
<span class="glare"></span> <!-- to store the glare effect -->
</span>
</div>
Creating the JS
Our JS does one fundamental thing - and that is to figure out the user's position on the card, and translate that into an angle which we pass to our CSS, to change how we view the card. To do that, we need to understand how far from the center of the card the user is. We only really have two axis to worry about - and when the user reaches the top or the bottom of either, we can rotate the card relative to the center, as shown in the image below.
How the Javascript works for 3d flippable CSS cards
Ultimately, to do that we write a function which accepts the 'card' element, and updates its CSS accordingly:
let calculateAngle = function(e, item, parent) {
let dropShadowColor = `rgba(0, 0, 0, 0.3)`
if(parent.getAttribute('data-filter-color') !== null) {
dropShadowColor = parent.getAttribute('data-filter-color');
}
parent.classList.add('animated');
// Get the x position of the users mouse, relative to the button itself
let x = Math.abs(item.getBoundingClientRect().x - e.clientX);
// Get the y position relative to the button
let y = Math.abs(item.getBoundingClientRect().y - e.clientY);
// Calculate half the width and height
let halfWidth = item.getBoundingClientRect().width / 2;
let halfHeight = item.getBoundingClientRect().height / 2;
// Use this to create an angle. I have divided by 6 and 4 respectively so the effect looks good.
// Changing these numbers will change the depth of the effect.
let calcAngleX = (x - halfWidth) / 6;
let calcAngleY = (y - halfHeight) / 14;
let gX = (1 - (x / (halfWidth * 2))) * 100;
let gY = (1 - (y / (halfHeight * 2))) * 100;
// Add the glare at the reflection of where the user's mouse is hovering
item.querySelector('.glare').style.background = `radial-gradient(circle at ${gX}% ${gY}%, rgb(199 198 243), transparent)`;
// And set its container's perspective.
parent.style.perspective = `${halfWidth * 6}px`
item.style.perspective = `${halfWidth * 6}px`
// Set the items transform CSS property
item.style.transform = `rotateY(${calcAngleX}deg) rotateX(${-calcAngleY}deg) scale(1.04)`;
parent.querySelector('.inner-card-backface').style.transform = `rotateY(${calcAngleX}deg) rotateX(${-calcAngleY}deg) scale(1.04) translateZ(-4px)`;
if(parent.getAttribute('data-custom-perspective') !== null) {
parent.style.perspective = `${parent.getAttribute('data-custom-perspective')}`
}
// Reapply this to the shadow, with different dividers
let calcShadowX = (x - halfWidth) / 3;
let calcShadowY = (y - halfHeight) / 6;
// Add a filter shadow - this is more performant to animate than a regular box shadow.
item.style.filter = `drop-shadow(${-calcShadowX}px ${-calcShadowY}px 15px ${dropShadowColor})`;
}
This function does 4 things:
- Calculates the shadow of the element, so that it appears to be moving in 3d space.
- Calculates the angle the card should be at, based on the mouse position.
- Calculates the position of the backface, so it moves in tandem with the card front of the card.
- Calculates the position of the glare, which is at the reflection of where the user's mouse is.
All we have to do now, is add this function to each of our mouse movement events, and then reset everytihng when the user's mouse leaves the element. We'll also add in a few functions for 'flipping' and 'unflipping' the card:
document.querySelectorAll('.card').forEach(function(item) {
// For flipping the card backwards and forwards
if(item.querySelector('.flip') !== null) {
item.querySelector('.flip').addEventListener('click', function() {
item.classList.add('flipped');
});
}
// For 'unflipping' the card.
if(item.querySelector('.unflip') !== null) {
item.querySelector('.unflip').addEventListener('click', function() {
item.classList.remove('flipped');
});
}
// For when the user's mouse 'enters' the card
item.addEventListener('mouseenter', function(e) {
calculateAngle(e, this.querySelector('.inner-card'), this);
});
// For when the users mouse moves on top of the card
item.addEventListener('mousemove', function(e) {
calculateAngle(e, this.querySelector('.inner-card'), this);
});
// For when the user's mouse leaves the card.
item.addEventListener('mouseleave', function(e) {
let dropShadowColor = `rgba(0, 0, 0, 0.3)`
if(item.getAttribute('data-filter-color') !== null) {
dropShadowColor = item.getAttribute('data-filter-color')
}
item.classList.remove('animated');
item.querySelector('.inner-card').style.transform = `rotateY(0deg) rotateX(0deg) scale(1)`;
item.querySelector('.inner-card-backface').style.transform = `rotateY(0deg) rotateX(0deg) scale(1.01) translateZ(-4px)`;
item.querySelector('.inner-card').style.filter = `drop-shadow(0 10px 15px ${dropShadowColor})`;
});
});
You might notice that the mouse events are for the card, but the transformations mainly happen on .inner-card. That's because if the angle of .card changes, the 'hover box' will change. If that happened, a user may be hovering over the card, but the angle would change so much that they wouldn't anymore, making the effect seem broken. By adding the hover effects to the card, we maintain a constant hover box, while still allowing us to transform the .inner-card within this fixed box.
Adding the CSS
Finally, we can add the CSS. The fundamental thing here is that we have a card container .card which contains the card we transform - .inner-card.
Another benefit of doing things this way is that when a user clicks 'flip', we can flip .card
itself, as we maintain a parent and child element. That means we can continue to transform the .inner-card,
and flip the .card at the same time, producing a more seamless effect.
On .inner-card-backface
, we add the line transform: rotateX(0) rotateY(0deg) scale(1) translateZ(-4px);
to move it back by 4 pixels. That creates a cool 3d depth effect, as well as making sure the front and backfaces do not collide as the user hovers. We also add backface-visibility: visible;
to our .card so both our back and front faces are interactable.
Finally, since we flip our entire card using the .flipped class, we need to 'unflip' the content on the back of the card. If we don't do that, the text on the back will appear back to front! So we have a class called .flip-inner-card
which simply lets us flip the backface of the card, so the text is no longer back to front.
.card {
box-shadow: none;
backface-visibility: visible;
background: transparent;
font-family: Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Open Sans,Helvetica Neue,sans-serif;
transform-style: preserve-3d;
padding: 0;
height: auto;
margin: 0 2rem 0 0;
width: 18rem;
height: 25rem;
float: left;
transition: all 0.2s ease-out;
border: none;
letter-spacing: 1px;
}
.flip, .unflip {
background: rgba(0,0,0,0.1);
font-size: 1rem;
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem 0.75rem;
border-radius: 100px;
line-height: 1rem;
cursor: pointer;
transition: all 0.1s ease-out;
}
.unflip {
top: auto;
background: #2d2d62;
bottom: 1rem;
}
.flip:hover {
background: rgba(0,0,0,0.3);
}
.card .flip-inner-card {
transform: rotateY(180deg);
position: absolute;
top: 0;
padding: 2rem 1.5rem;
box-sizing: border-box;
left: 0;
width: 100%;
height: 100%;
}
.inner-card-backface {
transform: rotateX(0) rotateY(0deg) scale(1) translateZ(-4px);
border-radius: 14px;
background: linear-gradient(45deg, #0b0b2a, #0b0b2a);
position: absolute;
top: 0;
color: white;
padding: 2rem;
box-sizing: border-box;
transition: all 0.15s ease-out;
will-change: transform, filter;
left: 0;
width: 100%;
height: 100%;
}
.card.flipped {
transform: rotateY(180deg);
}
.card .flip-inner-card {
transform: rotateY(180deg);
position: absolute;
top: 0;
padding: 2rem 1.5rem;
box-sizing: border-box;
left: 0;
width: 100%;
height: 100%;
}
Conclusion
In this tutorial, we've covered how to make a 3d CSS flippable card. We've talked about the function required to figure out the angle to display as the user hovers over it, as well as the CSS required to make a 3d card like this. I hope you've enjoyed - feel free to use on any of your personal projects, and here are some useful links: