Learn how to create a 3D card flip animation using Tailwind CSS.
CSS has come a long way in the past 10 years. It is now easier than ever to create 3D animations. Tailwind makes this even easier by including your CSS directly in your class attributes.
It requires minimal JavaScript to create animations. Tailwind 4 will add 3D space functions, but you can achieve this today with arbitrary values in Tailwind 3 or using external Tailwind plugins. Creating a rotating card has never been simpler.
This demo can work in any framework, although I am using SvelteKit for simplicity. After installing Tailwind, we will use some helper classes.
npm i -D tailwind-merge clsx
Together, these two elements have been normalized to create the cn
function in large UI frameworks like shadcn.
// lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
We need a back and a front component to display.
<div
class="flex h-full w-full flex-col items-center justify-center rounded-xl border bg-blue-700 text-white"
>
Front
</div>
<div
class="flex h-full w-full flex-col items-center justify-center rounded-xl bg-emerald-700 text-white"
>
Back
</div>
You can use whatever classes you want to format the card, but you will need the size-full
or both w-full
and h-full
to make sure the card gets covered completely.
Because we’ll flip the card by clicking, the best practice for accessibility is to use a button
element to avoid confusion with screen readers.
<button type="button" onclick={() => {}} class="size-96 outline-none">
<div class="relative size-full">
<div class="absolute inset-0 size-full">
<Front />
</div>
<div class="absolute inset-0 size-full">
<Back />
</div>
</div>
</button>
type="button"
to ensure a click action and not a page refresh.onclick
action later.outline-none
.relative
display position, with two absolute
and inset-0
child containers, for exact sizing when we use our 3D transitions.size-full
on all three div
elements to match our maximum card sizes.<button type="button" onclick={() => {}} class="size-96 outline-none [perspective:100rem]">
<div class={cn('relative size-full transition duration-500 [transform-style:preserve-3d]')}>
<div class="absolute inset-0 size-full [backface-visibility:hidden]">
<Front />
</div>
<div
class="absolute inset-0 size-full [backface-visibility:hidden] [transform:rotateY(180deg)]"
>
<Back />
</div>
</div>
</button>
[perspective:50rem]
for setting the root 3D space perspective. The Tailwind 4 equivalent is perspective-midrange
.[transform-style:preserve-3d]
to confirm the child elements will still be in the 3D space. The Tailwind 4 equivalent is transform-3d
.[backface-visibility:hidden]
to both sides of the card to hide the other side of the element when each side is visible. The Tailwind 4 equivalent is backface-hidden
.[transform:rotateY(180deg)]
to the back side of the card to set the backside as the back. The Tailwind 4 equivalent is rotate-y-180
.If you’re using an older version of Tailwind, you may prefer the tailwind-3dtransform-plugin, among others. However, I prefer to put the CSS directly in the class with []
until Tailwind 4 is stable and widely used.
To flip the card, we need a front and back state. The best way to represent this is with a boolean that can be reactive.
const card = $state({
showBack: false,
flip() {
this.showBack = !this.showBack;
}
});
I’m using a $state()
variable in Svelte, but this could easily be translated to useState
or other types of signal()
functions, depending on your framework.
card.flip()
.[transform:rotateY(180deg)]
to our root element when we want to flip. Remember this is rotate-y-180
in Tailwind 4.transition duration-1000
to have a second animation effect.<button type="button" onclick={() => card.flip()} class="size-96 outline-none [perspective:50rem]">
<div
class={cn(
'relative size-full transition duration-1000 [transform-style:preserve-3d]',
card.showBack && '[transform:rotateY(180deg)]'
)}
>
<div class="absolute inset-0 size-full [backface-visibility:hidden]">
<Front />
</div>
<div
class="absolute inset-0 size-full [backface-visibility:hidden] [transform:rotateY(180deg)]"
>
<Back />
</div>
</div>
</button>
Notice we programmatically add the rotation thanks to our nifty cn()
utility function.
card.showBack && '[transform:rotateY(180deg)]'
If we’re trying to show the back of the card for studying a flashcard, and we want to flip the card with a keystroke, we need to call the flip()
function from that keystroke.
We can manually listen to a keystroke with the JS event listener, but we can also make this easy.
import { onMount } from 'svelte';
export function useKey(
key: string | string[],
callback: (event: KeyboardEvent) => void,
options: {
event?: 'keydown' | 'keyup' | 'keypress',
preventDefault?: boolean,
} = {}
) {
const {
event = 'keydown',
preventDefault = false,
} = options;
function handler(e: KeyboardEvent) {
const keys = Array.isArray(key) ? key : [key];
if (keys.includes(e.key)) {
if (preventDefault) {
e.preventDefault();
}
callback(e);
}
}
onMount(() => {
addEventListener(event, handler);
return () => {
removeEventListener(event, handler);
};
});
}
I modified useKey
to work with Svelte, but there are prewritten packages and templates for most frameworks:
useKey(' ', () => card.flip());
Beautiful, right?
Jonathan Gamble has been an avid web programmer for more than 20 years. He has been building web applications as a hobby since he was 16 years old, and he received a post-bachelor’s in Computer Science from Oregon State. His real passions are language learning and playing rock piano, but he never gets away from coding. Read more from him at https://code.build/.