These days it looks like tiltable cards are all the rage, and I wanted to see how easy it would be to implement them from scratch in React.
I'm going to be using the bare minimum of external dependencies to implement this (namely just framer-motion to interpolate animation values smoothly, but using alternatives such as react-spring should be pretty straightforward).
We're gonna lay down a very rough card design, just to get things started.
Let's start by creating a card component that will contain the content of the card. Inside of it, there will be a single, centered paragraph of text:
<div
style={{
width: "240px",
height: "320px",
backgroundColor: "#C2B8F0",
borderRadius: "0.5rem",
boxShadow:
"0 0 0 1px rgba(0, 0, 0, 0.105), 0 9px 20px 0 rgba(0, 0, 0, 0.02), 0 1px 2px 0 rgba(0, 0, 0, 0.106)",
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<p>LIGMA</p>
</div>
We should end up with something like this:
LIGMA
This sets the basics for our card. Now, let's add some tilt effects to it.
We'll start by adding some dedicated CSS properties to the card:
transformStyle: 'preserve-3d'
transformOrigin: 'center'
perspective: '320px'
Let's break down their meanings:
Johnson
WTF?!
In order to do that, we'll need to keep track of the mouse position on the card, plus the card's animation state:
React's useState hook will be perfect for this:
const [rotations, setRotations] = useState({ x: 0, y: 0, z: 2 });
const [isAnimating, setAnimating] = useState(false);
Now we're gonna have to handle two separate interaction events:
Let's look at their implementations:
Some logic is getting implemented for now on, watch the comments! 👀
// Put these anywhere you wish, I personally put them into a dedicated `utils` file.
export function round(num, fix = 2) {
return parseFloat(num.toFixed(fix));
}
export function distance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
const handleMouseMove = (event) => {
setAnimating(true); // We're animating the card.
// We're getting the bounding box of the card.
const rect = event.currentTarget.getBoundingClientRect();
// We're getting the mouse position relative to the card.
const absolute = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
// We're getting the mouse position relative to the center of the card,
// in percentage, starting from x, y 0% 0% top-left, ending in 100% 100% bottom-right.
const percent = {
x: round((100 / rect.width) * absolute.x),
y: round((100 / rect.height) * absolute.y)
};
// We're getting the tilt angle of the card, calculated on the
// percentage of distance from the center, going from
// -50% to 0% to 50% left to right, top to bottom.
const center = {
x: percent.x - 50,
y: percent.y - 50
};
// We can now set the tilt angle(s) of the card. Note that the
// divisions here (/ 12, / 16, / 20) are used to stabilize the
// rotations using smaller values. Play with them and experiment
// you perfect fine-tuning!
setRotations({
x: round(((center.x > 50 ? 1 : -1) * center.x) / 12),
y: round(center.y / 16),
z: round(distance(percent.x, percent.y, 50, 50) / 20)
});
};
Before we go any further, let's break down what's about to happen for the onMouseLeave event:
We're gonna have to check for the isAnimating state in the onMouseMove event handler, but having to do that inside of a setTimeout callback is not ideal. In order to access the current isAnimating state in this case, we'll need to create a closure around the object. The reference to the object will always be the same in the closure function but the actual value stored by the object will be changed each time the callback function runs.
A solution to this issue is to use the useRef hook:const isAnimatingReference = useRef(isAnimating);
We can now proceed to implement the onMouseLeave event handler:
const stopAnimating = () => {
setAnimating(false);
setTimeout(() => {
if (!isAnimatingReference.current) return;
setRotations({ x: 0, y: 0, z: 2 });
}, 100);
};
Now we have all the pieces we need to tilt the card. Let's bind our states and event handlers to the card component:
(if you haven't already, install the framer-motion, so we can use the motion component to smoothly transition between different values)
<motion.div
// Mouse interactions events handlers.
onMouseMove={animate}
onMouseLeave={stopAnimating}
animate={{
// Rotation values used to tilt the card.
rotateY: rotations.x,
rotateX: rotations.y,
transformPerspective: rotations.z * 100
}}
style={{
width: '240px',
height: '320px',
backgroundColor: '#C2B8F0',
borderRadius: '0.5rem',
boxShadow:
'0 0 0 1px rgba(0, 0, 0, 0.105), 0 9px 20px 0 rgba(0, 0, 0, 0.02), 0 1px 2px 0 rgba(0, 0, 0, 0.106)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transformStyle: 'preserve-3d',
transformOrigin: 'center',
perspective: '320px'
}}>
<p>LIGMA</p>
</motion.div>;
We should end up with something like this:
LIGMA
Fantastic, the card tilts smoothly on mouse hover! But we're not done yet. As you can see, the card's tilt angle creates an optical illusion due to the lack of perceived depth, caused by the absence of shadows/highlights. We can fix that by applying some CSS magic to the card's children to make them appear as if they're on the card's surface.
As we previously said, we're gonna add visual depth to the card by adding a translucent layer to it, but -trust me- this is really all that's left to do in order to get the card to look like it's floating in thin air!
The card's structure is gonna be like this:
<motion.div
style={{
// Place above the rest.
zIndex: 2,
// Use an overlay blending mode to make the effect more realistic.
mixBlendMode: 'overlay',
// Use absolute positioning.
position: 'absolute',
// Always keep in front of the card in the 3d plane.
transform: 'translateZ(1px)',
// Cover the whole card's area.
width: '100%',
height: '100%',
// Same border radius as its parent.
borderRadius: '0.5rem',
// We already know about this ;).
transformStyle: 'preserve-3d'
}}
animate={{
// This is gonna draw a pseudo glare on the card. The `50% 50%`
// means it's gonna stay centered, for now...
background: `radial-gradient(
farthest-corner circle at 50% 50%,
rgba(255, 255, 255, 0.7) 10%,
rgba(255, 255, 255, 0.5) 24%,
rgba(0, 0, 0, 0.8) 82%
)`
}}
/>
We should end up with a static glare sitting on top of the card like the following:
LIGMA
Let's add a glareCoordinates state to our component, update it into said handler, and finally bind it to the card overlay glare's position.
const [glareCoordinates, setGlareCoordinates] = useState({ x: 0, y: 0, opacity: 0 });
const animate = (event) => {
/* ... All the code we previously wrote inside of the handler... */
setGlare({
x: percent.x,
y: percent.y,
opacity: 0.25,
});
};
}
<motion.div
// ...
animate={{
background: `radial-gradient(
farthest-corner circle at ${glareCoordinates.x}% ${glareCoordinates.y}%,
rgba(255, 255, 255, 0.7) 10%,
rgba(255, 255, 255, 0.5) 24%,
rgba(0, 0, 0, 0.8) 82%
)`,
opacity: glareCoordinates.opacity
}}
/>
As promised, we're done already. We should end up with something like this:
LIGMA
The code to recreate this whole effect should look like the this:
const Card = () => {
const [rotations, setRotations] = useState({ x: 0, y: 0, z: 0 });
const [isAnimating, setAnimating] = useState(false);
const isAnimatingReference = useRef(isAnimating);
const [glare, setGlare] = useState({ x: 0, y: 0, opacity: 0 });
const animate = (event) => {
setAnimating(true);
const rect = event.currentTarget.getBoundingClientRect();
const absolute = {
x: event.clientX - rect.left,
y: event.clientY - rect.top
};
const percent = {
x: round((100 / rect.width) * absolute.x),
y: round((100 / rect.height) * absolute.y)
};
const center = {
x: percent.x - 50,
y: percent.y - 50
};
setRotations({
x: round(((center.x > 50 ? 1 : -1) * center.x) / 12),
y: round(center.y / 16),
z: round(distance(percent.x, percent.y, 50, 50) / 20)
});
setGlare({
x: percent.x,
y: percent.y,
opacity: 0.25
});
};
const stopAnimating = () => {
setAnimating(false);
setTimeout(() => {
if (isAnimatingReference.current) return;
setRotations({ x: 0, y: 0, z: 2 });
setGlare({ x: 50, y: 50, opacity: 0 });
}, 100);
};
return (
<motion.div
onMouseMove={animate}
onMouseLeave={stopAnimating}
animate={{
rotateY: rotations.x,
rotateX: rotations.y,
transformPerspective: rotations.z * 100
}}
style={{
width: '240px',
height: '320px',
backgroundColor: '#C2B8F0',
borderRadius: '0.5rem',
boxShadow:
'0 0 0 1px rgba(0, 0, 0, 0.105), 0 9px 20px 0 rgba(0, 0, 0, 0.02), 0 1px 2px 0 rgba(0, 0, 0, 0.106)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transformStyle: 'preserve-3d',
transformOrigin: 'center',
perspective: '320px'
}}>
<motion.div
style={{
zIndex: 2,
mixBlendMode: 'overlay',
position: 'absolute',
transform: 'translateZ(1px)',
width: '100%',
height: '100%',
borderRadius: '0.5rem',
transformStyle: 'preserve-3d'
}}
animate={{
background: `radial-gradient(
farthest-corner circle at ${glare.x}% ${glare.y}%,
rgba(255, 255, 255, 0.7) 10%,
rgba(255, 255, 255, 0.5) 24%,
rgba(0, 0, 0, 0.8) 82%
)`,
opacity: glare.opacity
}}
/>
<p>LIGMA</p>
</motion.div>
);
}