Create A Card Flip Animation With CSS

11 min read

Animation, when used with restraint, can be a great way to enhance a user experience by making elements on the page feel more tangible and by adding visual interest. In this short tutorial, we'll use HTML, CSS, and just a touch of JavaScript to animate a card flipping over.

We'll be building up to this example. Clicking the "Flip Card" button will show the opposite side of the card and do so with a nice, natural looking flip.

Initial Setup

Our card will consist of a pair of divs, we'll toggle between which div we want to show using a single CSS class, and we'll do the rest using CSS transforms. Let's setup our divs and initial CSS to get started. In order to support live code in this post, I'll be using the vanilla template for Sandpack, which uses Parcel to bundle the code. Our file structure will look like (notice JS and CSS files under src).

  • /index.html
  • /src/index.js
  • /src/styles.css

If you want to follow along using the same structure, you can create a new CodeSandbox using the "Vanilla JS" template.

Markup

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Parcel Sandbox</title>
    <meta charset="UTF-8" />
  </head>
  <body>
    <div class="container">
      <button type="button" id="flip-btn">Flip Card</button>
      <div class="card">
        <div id="back" class="cardBack">Back</div>
        <div id="front" class="cardFront">Front</div>
      </div>
    </div>

    <script src="src/index.js"></script>
  </body>
</html>

Initial Styles

Let's start by giving our elements their basic styling.

Button

We need a button to triger the flip, let's give it a little style. We'll give it a border, some padding, round the corners and bump up the font weight.

/* src/styles.css */
button {
  border: solid 2px;
  padding: 0.5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

Container

This element will wrap the entire example. We'll give it a little padding, make it a flex container, set our flex direction to columns, and center everything.

/* src/styles.css */
.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

Card Wrapper

Looking at the markup, we can see that this div wraps the two divs that represent the front and back of our card. We'll set a width, minimum height, and a position: relative. We'll ultimately end up setting our front and back card divs to use position: absolute. By putting the relative here, we make sure that absolute positioning is applied relative to this div.

.card {
  margin-top: 1rem;
  min-height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

Front and Back Cards

The front and back card divs will share quite a bit of their styling, so let's start with the shared styling. We're setting the box-sizing property to border-box to make sure that however we style the cards, their width and height includes any borders or padding and they fit within the parent element's width. We'll also round the corners and apply a box shadow to provide a bit of depth.

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
}

Initial JavaScript

For our purposes (so I can easily include live examples along the way), we'll have a JavaScript file import the css and allow Parcel to bundle up our code. If you're working on a static HTML file locally, you can inlude the CSS with a link tag and then put the JavaScript for the next section right in some <script> tags.

Using the Parcel-based approach, the initial index.js file will simply import the CSS

import './styles.css'

A Live View of Our Initial Setup

This is what we've built so far. No interactivity yet, but we'll add that in the next step.

<!DOCTYPE html>
<html>

<head>
  <title>Parcel Sandbox</title>
  <meta charset="UTF-8" />
</head>

<body>
  <div class="container">
    <button type="button" id="flip-btn">
      Flip Card
    </button>
    <div class="card">
      <div id="back" class="cardBack">Back</div>
      <div id="front" class="cardFront">Front</div>
    </div>
  </div>

  <script src="src/index.js">
  </script>
</body>

</html>

Toggle Classes with JavaScript

We need a fairly small amount of JavaScript here to toggle classes on our elements. Beyond that, we'll do everything else using CSS. For this demo, I'll be adding this to index.js, but as I mentioned earlier, you could add the following code to some <script> tags to your HTML file.

const front = document.getElementById('front')
const back = document.getElementById('back')
const btn = document.getElementById('flip-btn')
function handleFlip() {
  front.classList.toggle('flipped')
  back.classList.toggle('flipped')
}
btn.addEventListener('click', handleFlip)

We're going to use the flipped class on both front and back divs and we'll control the styling through that. This code uses browser APIs to add and remove the flipped class to the divs when the button is clicked.

There is no visible change at this point, but if you inspect the card elements in the following sample using DevTools, you can click the button and should see the flipped class being added and removed from both card elements.

import "./styles.css";

const front = document.getElementById('front')
const back = document.getElementById('back')
const btn = document.getElementById('flip-btn')

function handleFlip() {
  front.classList.toggle('flipped')
  back.classList.toggle('flipped')
}

btn.addEventListener('click', handleFlip)

Transform Cards Based on The flipped Class

Now that we have the flipped class being toggled on our divs, let's add some styles that are specific to the front and back cards, accounting for the flipped and non-flipped states.

Card Back Styling

We'll give the back card a background color, and then we'll use a transform to set an initial rotation. We're turning the card 180deg on the Y axis, flipping it over by default. Then when the flipped class is applied, we use the same rotateY function and flip the card to face us.

.cardBack {
  transform: rotateY(180deg);
  background-color: #ebf4ff;
}

.cardBack.flipped {
  transform: rotateY(0deg);
}

Front Card Styling

We'll apply the same flipping behavior to the front card, but in reverse. We'll default to the card facing us, and rotate it 180deg when the flipped class is applied.

.cardFront {
  transform: rotateY(0deg);
}

.cardFront.flipped {
  transform: rotateY(180deg);
}

With our logic in place to toggle our classes, and our transforms applied, we can observe the card sections applying the updated styles and "flipping". It's quite abrupt in its current state. We'll address that in the next step.

button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
}

.cardBack {
  background-color: #ebf4ff;
  transform: rotateY(180deg);
}

.cardBack.flipped {
  transform: rotateY(0deg);
}

.cardFront {
  transform: rotateY(0deg);
}

.cardFront.flipped {
  transform: rotateY(180deg);
}

Add the Flip with CSS Transitions

To make this change feel less jarring and more natural, let's add a transition rule to our shared card styles. We'll add transition: transform 0.5s ease; to transition our transforms over half a second, and we'll apply the ease timing function.

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
+ transition: transform 0.5s ease;
}

With that applied, we have a nice animated transition when flipped.

button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
}
.cardBack {
  background-color: #ebf4ff;
  transform: rotateY(180deg);
}

.cardBack.flipped {
  transform: rotateY(0deg);
}

.cardFront {
  transform: rotateY(0deg);
}

.cardFront.flipped {
  transform: rotateY(180deg);
}

Position Cards

Now we should position the cards so they behave like a single card instead of two stacked divs. Our parent container has a position: relative rule applied to it, so all we need to do is set a rule for each card element to position: absolute. Now our cards will be absolutely positioned, relative to the parent container. Our card pieces are sitting on top of one another now.

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
+ position: absolute;
}
button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
}
.cardBack {
  background-color: #ebf4ff;
  transform: rotateY(180deg);
}

.cardBack.flipped {
  transform: rotateY(0deg);
}

.cardFront {
  transform: rotateY(0deg);
}

.cardFront.flipped {
  transform: rotateY(180deg);
}

Hide the Back Side of Cards

When we flip our cards, we can still see both labels. This is because we only have a background color set on the back card, and the stacking order puts the front card on top, so we see both labels in their respective position and rotation based on that state of the flipped class.

Let's update our styling to hide the back side of the cards so we only see the card that is face up. We'll do this by adding a backface-visibility rule to our shared styles. We'll add this with the -webkit- vendor prefix first, followed by the non-prefixed rule:

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
}

Now when we flip the cards, we only see the face up card. Notice that when we can see the front card, we lose the background color that we had applied to the back card because it is hidden. We'll add a background to the front card in a future, cleanup step. For now we'll keep it so everything is clear in the UI as we progress.

button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}
.cardBack {
  background-color: #ebf4ff;
  transform: rotateY(180deg);
}

.cardBack.flipped {
  transform: rotateY(0deg);
}

.cardFront {
  transform: rotateY(0deg);
}

.cardFront.flipped {
  transform: rotateY(180deg);
}

We're almost done. The flip animation probably looks a little strange and unnatural at this stage. That's okay, we'll address that next.

Fix the Unnatural Flip

Perspective

To make this animation feel more natural, we'll apply some perspective to give the card a feeling of depth. I've settled on 1000px here, but feel free to play around with the values and pick something that feels "right" to you.

.cardBack {
-  transform: rotateY(180deg);
+  transform: perspective(1000px) rotateY(180deg);
   background-color: #ebf4ff;
}

.cardBack.flipped {
-  transform: rotateY(0deg);
+  transform: perspective(1000px) rotateY(0deg);
}

.cardFront {
-  transform: rotateY(0deg);
+  transform: perspective(1000px) rotateY(0deg);
}

.cardFront.flipped {
-  transform: rotateY(180deg);
+  transform: perspective(1000px) rotateY(180deg);
}
button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}
.cardBack {
  background-color: #ebf4ff;
  transform: perspective(1000px) rotateY(180deg);
}

.cardBack.flipped {
  transform: perspective(1000px) rotateY(0deg);
}

.cardFront {
  transform: perspective(1000px) rotateY(0deg);
}

.cardFront.flipped {
  transform: perspective(1000px) rotateY(180deg);
}

Rotation Direction

Something is still off, our card flip still doesn't feel right. The remaining problem is the direction of the flip. The two cards are essentially crashing through each other to swap rotations. We need them to rotate as a single unit, so we'll have to make one of the cards go in the opposite direction.

As it turns out, the transition algorithm's math can be manipulated to control the direction of the transition. All we need to do is change the rotateY on our front card from 180deg to -180deg. It doesn't change the end result, but it does change the transition.

.cardFront.flipped {
-  transform: perspective(1000px) rotateY(180deg);
+  transform: perspective(1000px) rotateY(-180deg);
}
button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
}
.cardBack {
  background-color: #ebf4ff;
  transform: perspective(1000px) rotateY(180deg);
}

.cardBack.flipped {
  transform: perspective(1000px) rotateY(0deg);
}

.cardFront {
  transform: perspective(1000px) rotateY(0deg);
}

.cardFront.flipped {
  transform: perspective(1000px) rotateY(-180deg);
}

Final cleanup

All we have left is to apply a background color to the front card so our card isn't transparent from the front. You can do whatever you want in terms of color, background image, etc. Here we'll keep it simple and just apply the same background to both cards by moving our background-color rule from the cardBack-specific rules to our shared card styles.

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
+ background-color: #ebf4ff;
}

.cardBack {
- background-color: #ebf4ff;
  transform: perspective(1000px) rotateY(180deg);
}
button {
  border: solid 2px;
  padding: .5rem;
  border-radius: 0.25rem;
  font-weight: 700;
}

.container {
  padding: 20px;
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: auto;
}

.card {
  margin-top: 1rem;
  height: 300px;
  width: 250px;
  position: relative;
  border-radius: 0.25rem;
}

.cardFront,
.cardBack {
  box-sizing: border-box;
  border-radius: 0.25rem;
  height: 100%;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  width: 100%;
  transition: transform 0.5s ease;
  position: absolute;
  -webkit-backface-visibility: hidden;
  backface-visibility: hidden;
  background-color: #ebf4ff;
}
.cardBack {
  transform: perspective(1000px) rotateY(180deg);
}

.cardBack.flipped {
  transform: perspective(1000px) rotateY(0deg);
}

.cardFront {
  transform: perspective(1000px) rotateY(0deg);
}

.cardFront.flipped {
  transform: perspective(1000px) rotateY(-180deg);
}

Conclusion

This is a pretty common interaction to animate and CSS gives us the tools we need to achieve the animation. No need to complicate things with too much JavaScript. By using CSS for this, all the tricky logic and things we might have otherwise missed are handled for us by the browser. For example, if we toggle the flipped class part way through a transition, it naturally changes direction without any jarring flickers or jumps. It just picks up the values based on where it is and calculates a transition from there. Go ahead, click the flip buttons a whole bunch of times really fast... the card will flip flop and finish as expected without awkwardly jumping to the far end of the animation at any point. This is absolutely something that we could build with JavaScript, but not necessarily something we should build with JavaScript. By using a CSS based approach, we cut down our initial work, reduce the need for maintenance, and gain some performance by letting the browser handle this for us.

If you like to learn with bite-sized videos, I published this very thing as a lesson on egghead.io a while back, check it out!