3D Flippy Card

In this craft, I share my learnings about 3D CSS by creating a 3D Flippy Cards animation and some maths behind it.

User Avatar

Faisal Husain

To create this animation lets first understand the basics of 3D CSS . You can refer to this blog of mine to get a hang of what 3D css is .

Now lets start making this animation . This animation is made using Framer motion or motion , TailwindCSS and VanillaCSS.

Lets break it down . Firstly we will only animate colors on flipping in the end we will add photos on the card and position them accordingly.

Step 1

We will make a 10 × 10 grid . And on hover we will flip the card .

0,0
0,0
1,0
1,0
2,0
2,0
3,0
3,0
4,0
4,0
5,0
5,0
6,0
6,0
7,0
7,0
8,0
8,0
9,0
9,0
0,1
0,1
1,1
1,1
2,1
2,1
3,1
3,1
4,1
4,1
5,1
5,1
6,1
6,1
7,1
7,1
8,1
8,1
9,1
9,1
0,2
0,2
1,2
1,2
2,2
2,2
3,2
3,2
4,2
4,2
5,2
5,2
6,2
6,2
7,2
7,2
8,2
8,2
9,2
9,2
0,3
0,3
1,3
1,3
2,3
2,3
3,3
3,3
4,3
4,3
5,3
5,3
6,3
6,3
7,3
7,3
8,3
8,3
9,3
9,3
0,4
0,4
1,4
1,4
2,4
2,4
3,4
3,4
4,4
4,4
5,4
5,4
6,4
6,4
7,4
7,4
8,4
8,4
9,4
9,4
0,5
0,5
1,5
1,5
2,5
2,5
3,5
3,5
4,5
4,5
5,5
5,5
6,5
6,5
7,5
7,5
8,5
8,5
9,5
9,5
0,6
0,6
1,6
1,6
2,6
2,6
3,6
3,6
4,6
4,6
5,6
5,6
6,6
6,6
7,6
7,6
8,6
8,6
9,6
9,6
0,7
0,7
1,7
1,7
2,7
2,7
3,7
3,7
4,7
4,7
5,7
5,7
6,7
6,7
7,7
7,7
8,7
8,7
9,7
9,7
0,8
0,8
1,8
1,8
2,8
2,8
3,8
3,8
4,8
4,8
5,8
5,8
6,8
6,8
7,8
7,8
8,8
8,8
9,8
9,8
0,9
0,9
1,9
1,9
2,9
2,9
3,9
3,9
4,9
4,9
5,9
5,9
6,9
6,9
7,9
7,9
8,9
8,9
9,9
9,9

The above card you looking at is made from code below .

const GRID_SIZE = 10;
const COUNT = GRID_SIZE * GRID_SIZE;
 
const FlippyCard = () => {
  return (
    <div className="bg-black ring-inset ring-2 ring-zinc-800 rounded-2xl md:h-[700px] h-[500px] flex items-center justify-center w-full">
      <div className={`grid grid-cols-10 gap-2  md:size-[500px] size-[300px]`}>
        {Array.from({ length: COUNT }, (_, index) => {
          const x = index % GRID_SIZE;
          const y = Math.floor(index / GRID_SIZE);
 
          return (
            <div
              key={index}
              className=" relative w-full h-full cursor-pointer  [transform-style:preserve-3d] hover:[transform:rotateY(180deg)] transition-transform duration-500 "
            >
              <div className=" absolute w-full h-full bg-blue-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateY(0deg)]">
                {x},{y}
              </div>
              <div className=" absolute w-full h-full bg-red-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateY(180deg)] ">
                {x},{y}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
};
 
export default FlippyCard;

Step 2

Now on click of card all cards should flip .

"use client";
import { useState } from "react";
const GRID_SIZE = 10;
const COUNT = GRID_SIZE * GRID_SIZE;
 
const FlippyCard = () => {
  const [flipped, setFlipped] = useState(false);
  return (
    <div className="bg-black ring-inset ring-2 ring-zinc-800 rounded-2xl md:h-[700px] h-[500px] flex items-center justify-center w-full">
      <button
        className={`grid grid-cols-10 gap-2  md:size-[500px] size-[300px]`}
        onClick={() => setFlipped(!flipped)}
      >
        {Array.from({ length: COUNT }, (_, index) => {
          const x = index % GRID_SIZE;
          const y = Math.floor(index / GRID_SIZE);
 
          return (
            <div
              key={index}
              className={`relative w-full h-full cursor-pointer   [transform-style:preserve-3d]  transition-transform duration-500  ${flipped ? "[transform:rotateX(180deg)]" : ""} `}
            >
              <div className=" absolute w-full h-full bg-blue-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateY(0deg)]">
                {x},{y}
              </div>
              <div className=" absolute w-full h-full bg-red-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateX(180deg)] ">
                {x},{y}
              </div>
            </div>
          );
        })}
      </button>
    </div>
  );
};
 
export default FlippyCard;

Thing changed here

  • Is we added a button and on click of button we change the state of flip .

Step 3

In this step we fix the flip and when user clicks on any card only that card flips first then other cards flip with a delay . This look like a wave of cards flipping .

For this I am going to use framer motion or motion . Lets see the demo first .

0,0
0,0
1,0
1,0
2,0
2,0
3,0
3,0
4,0
4,0
5,0
5,0
6,0
6,0
7,0
7,0
8,0
8,0
9,0
9,0
0,1
0,1
1,1
1,1
2,1
2,1
3,1
3,1
4,1
4,1
5,1
5,1
6,1
6,1
7,1
7,1
8,1
8,1
9,1
9,1
0,2
0,2
1,2
1,2
2,2
2,2
3,2
3,2
4,2
4,2
5,2
5,2
6,2
6,2
7,2
7,2
8,2
8,2
9,2
9,2
0,3
0,3
1,3
1,3
2,3
2,3
3,3
3,3
4,3
4,3
5,3
5,3
6,3
6,3
7,3
7,3
8,3
8,3
9,3
9,3
0,4
0,4
1,4
1,4
2,4
2,4
3,4
3,4
4,4
4,4
5,4
5,4
6,4
6,4
7,4
7,4
8,4
8,4
9,4
9,4
0,5
0,5
1,5
1,5
2,5
2,5
3,5
3,5
4,5
4,5
5,5
5,5
6,5
6,5
7,5
7,5
8,5
8,5
9,5
9,5
0,6
0,6
1,6
1,6
2,6
2,6
3,6
3,6
4,6
4,6
5,6
5,6
6,6
6,6
7,6
7,6
8,6
8,6
9,6
9,6
0,7
0,7
1,7
1,7
2,7
2,7
3,7
3,7
4,7
4,7
5,7
5,7
6,7
6,7
7,7
7,7
8,7
8,7
9,7
9,7
0,8
0,8
1,8
1,8
2,8
2,8
3,8
3,8
4,8
4,8
5,8
5,8
6,8
6,8
7,8
7,8
8,8
8,8
9,8
9,8
0,9
0,9
1,9
1,9
2,9
2,9
3,9
3,9
4,9
4,9
5,9
5,9
6,9
6,9
7,9
7,9
8,9
8,9
9,9
9,9

In this steps many things changed . Lets see them one by one .

  1. We added a motion.div from framer motion .
  2. When clicked on card we set the row and column of card clicked in state . And that card is flipped with 0 delay. But delay get increases as we move away from the clicked card . Here is suppose you clicked on [5,5] card then [5,5] card will flip with 0 delay then [4,5] and [6,5] will flip with 0.1s delay and [3,5] and [7,5] will flip with 0.2s delay and so on . Imagine it as diamond shape wave of cards flipping . To calculate the delay we use the formula Math.abs(row - rowClicked) + Math.abs(col - colClicked) * 0.1 . This formula gives us the delay in seconds .
const getManhattanDistance = (
  x1: number,
  y1: number,
  x2: number,
  y2: number,
) => {
  return Math.abs(x2 - x1) + Math.abs(y2 - y1);
};
 
// where  x1 = row , y1 = col , x2 = rowClicked , y2 = colClicked
  1. Also things like keeping a track of animation and disabling click events while animation is going on is added .

So code for above look like this

"use client";
import { useState } from "react";
import { motion } from "motion/react";
 
const GRID_SIZE = 10;
const COUNT = GRID_SIZE * GRID_SIZE;
 
const FlippyCard = () => {
  const [flipped, setFlipped] = useState(false);
  const [clickPosition, setClickPosition] = useState({ x: 0, y: 0 });
  const [isAnimating, setIsAnimating] = useState(false);
 
  const getManhattanDistance = (
    x1: number,
    y1: number,
    x2: number,
    y2: number,
  ) => {
    return Math.abs(x2 - x1) + Math.abs(y2 - y1);
  };
  const maxDistance = getManhattanDistance(0, 0, GRID_SIZE - 1, GRID_SIZE - 1);
  const totalAnimationDuration = maxDistance * 0.1 + 0.5;
  const handleCardClick = (x: number, y: number, event: React.MouseEvent) => {
    event.stopPropagation();
    if (isAnimating) return;
    setClickPosition({ x, y });
    setFlipped((prev) => !prev);
    setIsAnimating(true);
    setTimeout(() => {
      setIsAnimating(false);
    }, totalAnimationDuration * 1000);
  };
  return (
    <div className="bg-black ring-inset ring-2 ring-zinc-800 rounded-2xl md:h-[700px] h-[500px] flex items-center justify-center w-full">
      <div
        className={`grid grid-cols-10 gap-2  md:size-[500px] size-[300px] ${
          isAnimating ? "pointer-events-none" : ""
        } `}
      >
        {Array.from({ length: COUNT }, (_, index) => {
          const x = index % GRID_SIZE;
          const y = Math.floor(index / GRID_SIZE);
 
          return (
            <motion.div
              key={index}
              className={`relative w-full h-full cursor-pointer   [transform-style:preserve-3d]  transition-transform duration-500 `}
              initial="initial"
              animate={{
                rotateX: flipped ? 180 : 0,
                transition: {
                  rotateX: {
                    duration: 0.1,
                    delay:
                      getManhattanDistance(
                        clickPosition.x,
                        clickPosition.y,
                        x,
                        y,
                      ) * 0.1,
                  },
                },
              }}
              onClick={(e) => handleCardClick(x, y, e)}
            >
              <div className=" absolute w-full h-full bg-blue-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateY(0deg)]">
                {x},{y}
              </div>
              <div className=" absolute w-full h-full bg-red-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateX(180deg)] ">
                {x},{y}
              </div>
            </motion.div>
          );
        })}
      </div>
    </div>
  );
};
 
export default FlippyCard;

Step 4

This is the most trickiest of all the step we have to add image to all the grids and position them accordingly .Our animation is working fine but we have to add images to the grid .

Lets see the final demo first . (Click on the card to see the animation)

We use css variables to position the images . We have to calculate the position of each image and set it to the grid .

The complete code look like below .

"use client";
import { useState } from "react";
import { motion } from "motion/react";
 
const GRID_SIZE = 10;
const COUNT = GRID_SIZE * GRID_SIZE;
 
const FlippyCard = () => {
  const [flipped, setFlipped] = useState(false);
  const [clickPosition, setClickPosition] = useState({ x: 0, y: 0 });
  const [isAnimating, setIsAnimating] = useState(false);
 
  const getManhattanDistance = (
    x1: number,
    y1: number,
    x2: number,
    y2: number,
  ) => {
    return Math.abs(x2 - x1) + Math.abs(y2 - y1);
  };
  const maxDistance = getManhattanDistance(0, 0, GRID_SIZE - 1, GRID_SIZE - 1);
  const totalAnimationDuration = maxDistance * 0.1 + 0.5;
  const handleCardClick = (x: number, y: number, event: React.MouseEvent) => {
    event.stopPropagation();
    if (isAnimating) return;
    setClickPosition({ x, y });
    setFlipped((prev) => !prev);
    setIsAnimating(true);
    setTimeout(() => {
      setIsAnimating(false);
    }, totalAnimationDuration * 1000);
  };
  return (
    <div
      className="bg-black ring-inset ring-2 ring-zinc-800 rounded-2xl md:h-[700px] h-[500px] flex items-center justify-center w-full"
      style={
        {
          "--current-image": `url("https://plus.unsplash.com/premium_photo-1674675646896-456033a4b629?w=600&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHwxN3x8fGVufDB8fHx8fA%3D%3D")`,
          "--next-image": `url("https://plus.unsplash.com/premium_photo-1712171314346-f2b287e72ce7?w=600&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxmZWF0dXJlZC1waG90b3MtZmVlZHw2MXx8fGVufDB8fHx8fA%3D%3D")`,
        } as React.CSSProperties
      }
    >
      <div
        className={`grid grid-cols-10   md:size-[500px] size-[300px] ${
          isAnimating ? "pointer-events-none" : ""
        } `}
      >
        {Array.from({ length: COUNT }, (_, index) => {
          const x = index % GRID_SIZE;
          const y = Math.floor(index / GRID_SIZE);
 
          return (
            <motion.div
              key={index}
              className={`  relative w-full h-full cursor-pointer   [transform-style:preserve-3d]  transition-transform duration-500 `}
              initial="initial"
              animate={{
                rotateX: flipped ? 180 : 0,
                transition: {
                  rotateX: {
                    duration: 0.1,
                    delay:
                      getManhattanDistance(
                        clickPosition.x,
                        clickPosition.y,
                        x,
                        y,
                      ) * 0.1,
                  },
                },
              }}
              style={
                {
                  "--x": x,
                  "--y": y,
                  "--grid-size": GRID_SIZE,
                } as React.CSSProperties
              }
              onClick={(e) => handleCardClick(x, y, e)}
            >
              <div
                className=" absolute w-full h-full bg-blue-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateY(0deg)] "
                style={{
                  backgroundImage: "var(--current-image)",
                  backgroundPosition: `calc(var(--x, 0) * -100%) calc(var(--y, 0) * -100%)`,
                  backgroundSize: `calc(var(--grid-size, 10) * 100%)`,
                }}
              />
              <div
                className=" absolute w-full h-full bg-red-500 text-center flex items-center justify-center text-xs md:text-base [backface-visibility:hidden]  [transform:rotateX(180deg)]  "
                style={{
                  backgroundImage: "var(--next-image)",
                  backgroundPosition: `calc(var(--x, 0) * -100%) calc(var(--y, 0) * -100%)`,
                  backgroundSize: `calc(var(--grid-size, 10) * 100%)`,
                }}
              />
            </motion.div>
          );
        })}
      </div>
    </div>
  );
};
 
export default FlippyCard;

Note : I have highlighted the changes in the code above .

And that's it . We have created a 3D flippy card animation . You can play around with the code and make it more interactive .\

Hope you enjoyed this craft . If you have any questions or feedback feel free to reach out to me on twitter/X

Happy Crafting 🎨🚀


  1. To under more about grid positioning in css Click here
Want to hire me as a freelancer? Let's discuss.
Drop a message and let's discuss
Drop in your email ID and I will get back to you.
HomeAboutProjectsBlogsHire MeCrafts