Tiltable cards from scratch in React

Tiltable cards from scratch in React

Introduction

Meme

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).

Step 1: Designing the card

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.

Step 2: Implementing the tilt effect

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:

  • transform-style: preserve-3d: Indicates that the children of the element should be positioned in their own 3D plane, or be flattened to their parent's.
    With 'preserve-3d'

    Johnson

  • transform-origin: center: Sets the fulcrum for the element's transformations.
    Transform origin: "center"

  • perspective: 320px: Determines the distance between the z=0 plane and the viewer in order to give a 3D-positioned element some perspective. In this case I found the card's height in pixels to be the sweet spot.
    Perspective: "50px"

    WTF?!

We're now ready to implement the tilt effect!

In order to do that, we'll need to keep track of the mouse position on the card, plus the card's animation state:

  • rotations: We'll use this to store the current tilt angle(s) of the card.
  • isAnimating: We'll use this to keep track of the animation state of the card.

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:

  • onMouseMove: When the mouse is moving on the card, we want to do some calculations to determine the tilt angle.
  • onMouseLeave: When the mouse leaves the card, we want to reset the tilt angle to 0.

Let's look at their implementations:

Some logic is getting implemented for now on, watch the comments! 👀

onMouseMove event handler

// 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 set the isAnimating state to false.
  • We're gonna set a timeout to reset the tilt angle(s) to 0 after 100ms, to stop the card from abruptly stopping its animation.

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:

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.

Step 3: CSS magic dust

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:

  • Card
    • Card transulcency layer
    • Card children
<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

Of course a static glare brings no value to our recipe, so we need to make it follow the mouse position, and if you remember, we already have all the necessary coordinates-calculating logic inside of our handleMouseMove function!

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.

Glare coordinates state

const [glareCoordinates, setGlareCoordinates] = useState({ x: 0, y: 0, opacity: 0 });

Glare coordinates update

const animate = (event) => {
    /* ... All the code we previously wrote inside of the handler... */

    setGlare({
      x: percent.x,
      y: percent.y,
      opacity: 0.25,
    });
  };
}

Glare coordinates binding to the glare overlay

<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
  }}
/>

Step 4: Nah... no step 4, we're good!

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>
  );
}

Stay tuned for more!


Publication date: 29/10/2022