Forked from alishahlakhani/madeofzero-nextjs-framer-flip-card.tsx
Created
July 31, 2024 17:42
-
-
Save keif/f0baecb4b342c27ca8c32f0a560b8aec to your computer and use it in GitHub Desktop.
Create a Tarot card deck selection page using Framer Motion and Nextjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import React, { useState } from "react"; | |
import { AnimatePresence, motion } from "framer-motion"; | |
import Image from "next/image"; | |
import clsx from "clsx"; | |
type Props = { | |
onPick?: (card: string | null) => void; | |
onSelect?: (card: string) => void; | |
}; | |
const ShuffledDeck = [ | |
{ | |
title: "The Fool", | |
image: | |
"https://images.unsplash.com/photo-1720725727156-f68df0468436?q=80&w=2535&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Magician", | |
image: | |
"https://images.unsplash.com/photo-1722196174475-6834f9157d51?q=80&w=2574&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The High Priestess", | |
image: | |
"https://images.unsplash.com/photo-1722156772564-8f921329cc12?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Empress", | |
image: | |
"https://images.unsplash.com/photo-1646579352833-cb46fb461f64?q=80&w=2672&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Emperor", | |
image: | |
"https://images.unsplash.com/photo-1701316613369-6ea5c0d96b98?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3DD", | |
}, | |
{ | |
title: "The Hierophant", | |
image: | |
"https://images.unsplash.com/photo-1722104784480-52b6fc3e3a34?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Lovers", | |
image: | |
"https://images.unsplash.com/photo-1722233987129-61dc344db8b6?q=80&w=2574&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Chariot", | |
image: | |
"https://plus.unsplash.com/premium_photo-1719017469871-a1bd6615dabe?q=80&w=2525&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "Strength", | |
image: | |
"https://plus.unsplash.com/premium_photo-1719017469915-b7501a0d6147?q=80&w=2525&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Hermit", | |
image: | |
"https://plus.unsplash.com/premium_photo-1719017472059-8d1d0ab3cba5?q=80&w=2525&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "Wheel of Fortune", | |
image: | |
"https://images.unsplash.com/photo-1612323272007-3e7c28f6eb05?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "Justice", | |
image: | |
"https://images.unsplash.com/photo-1479330173277-6c90ae1bb2c0?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Hanged Man", | |
image: | |
"https://images.unsplash.com/photo-1493690314206-255f1df89427?q=80&w=2574&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "Death", | |
image: | |
"https://images.unsplash.com/photo-1721817269931-eafc6308899e?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHx0b3BpYy1mZWVkfDI1fHFQWXNEenZKT1ljfHxlbnwwfHx8fHw%3D", | |
}, | |
{ | |
title: "Temperance", | |
image: | |
"https://images.unsplash.com/photo-1721566364814-7345a56e7ab0?q=80&w=2574&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Devil", | |
image: | |
"https://images.unsplash.com/photo-1716043657397-92666764b512?q=80&w=2614&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Tower", | |
image: | |
"https://images.unsplash.com/photo-1534312527009-56c7016453e6?q=80&w=2454&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Star", | |
image: | |
"https://images.unsplash.com/flagged/photo-1567400358593-9e6382752ea2?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Moon", | |
image: | |
"https://images.unsplash.com/photo-1563089145-599997674d42?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The Sun", | |
image: | |
"https://images.unsplash.com/photo-1516464278939-6c47180c46eb?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "Judgment", | |
image: | |
"https://images.unsplash.com/photo-1511447333015-45b65e60f6d5?q=80&w=2510&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
{ | |
title: "The World", | |
image: | |
"https://images.unsplash.com/photo-1546146477-15a587cd3fcb?q=80&w=2160&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | |
}, | |
]; | |
export default function CardsDeckViewer(props: Props) { | |
const { onPick, onSelect } = props; | |
const [picked, setPicked] = useState<string | null>(null); | |
const [selected, setSelected] = useState<string | null>(null); | |
const numberOfDivs = ShuffledDeck.length; // Change this value to adjust the number of divs | |
const radius = 120; // Define the radius of the semi-circle | |
const firstCardAngle = 90; | |
// How much open the fan is. | |
const openAngle = 180; | |
function getRandomArbitrary(min: number, max: number) { | |
return Math.random() * (max - min) + min; | |
} | |
function handleRandomPick() { | |
const randomCardIndex = Math.round( | |
getRandomArbitrary(0, ShuffledDeck.length) | |
); | |
const pickedCard = ShuffledDeck[randomCardIndex].title; | |
setPicked(pickedCard); | |
} | |
return ( | |
<div className="relative h-full w-full bg-[#181B1D] px-6 py-8 rounded-2xl"> | |
<div className="mt-[20rem] h-full w-full flex items-end justify-center "> | |
<AnimatePresence> | |
{ShuffledDeck.map((card, index) => { | |
const fixedAngle = | |
(openAngle / (numberOfDivs - 1)) * index - firstCardAngle; | |
const fixedtranslateX = | |
-Math.sin(fixedAngle * (Math.PI / 180)) * radius; | |
const fixedtranslateY = | |
-Math.cos(fixedAngle * (Math.PI / 180)) * radius; | |
const fixedrotate = -( | |
(openAngle / (numberOfDivs - 1)) * index - | |
firstCardAngle | |
); | |
const variants = { | |
selected: { | |
translateX: 0, | |
translateY: -100, | |
scale: 2.5, | |
zIndex: 10, | |
transition: { duration: 0.5 }, | |
}, | |
unselected: { | |
translateX: fixedtranslateX, | |
translateY: fixedtranslateY, | |
rotate: fixedrotate, | |
opacity: 0, | |
transition: { duration: 0.5 }, | |
}, | |
initial: { | |
translateX: fixedtranslateX, | |
translateY: fixedtranslateY, | |
rotate: fixedrotate, | |
transition: { duration: 0.2 }, | |
}, | |
}; | |
function handleCardSelect() { | |
if (card.title === picked) { | |
setPicked(null); | |
setSelected(null); | |
onPick && onPick(null); | |
} else if (picked === null) { | |
setPicked(card.title); | |
onPick && onPick(card.title); | |
} else { | |
setPicked(null); | |
setSelected(null); | |
} | |
} | |
function handleAnimationEnd( | |
defination: "selected" | "preSelected" | "initial" | |
) { | |
switch (defination) { | |
case "selected": | |
if (selected && onSelect) | |
setTimeout(() => { | |
onSelect(selected); | |
}, 1000); | |
return; | |
default: | |
return; | |
} | |
} | |
return ( | |
<motion.div | |
key={card.title} | |
data-test={card.title} | |
animate={ | |
picked === card.title | |
? "selected" | |
: picked !== null | |
? "unselected" | |
: "initial" | |
} | |
variants={variants} | |
onClick={handleCardSelect} | |
className={clsx( | |
"absolute bg-black/30 cursor-pointer rounded-md overflow-hidden h-[6.6rem] w-[4.6rem]", | |
{ | |
"opacity-100": picked === card.title, | |
grayscale: picked !== null && picked !== card.title, | |
} | |
)} | |
> | |
<div | |
className="cursor-pointer w-full h-full relative" | |
style={{ | |
perspective: "1000px", | |
}} | |
> | |
<motion.div | |
exit={{ opacity: 0 }} | |
id="card" | |
key={`card-${card.title}`} | |
className="relative w-full h-full" | |
variants={{ | |
selected: { | |
transition: { duration: 0.4 }, | |
}, | |
initial: { | |
rotateY: 0, | |
transition: { duration: 0.4 }, | |
}, | |
}} | |
animate={picked === card.title ? "selected" : "initial"} | |
style={{ | |
transformStyle: "preserve-3d", | |
}} | |
> | |
<motion.div | |
exit={{ opacity: 0 }} | |
id="front" | |
key={`front-${card.title}`} | |
className={clsx("absolute w-full h-full border-2", { | |
"border-white": selected === card.title, | |
})} | |
variants={{ | |
selected: { | |
transition: { duration: 0.4 }, | |
scaleX: 1, | |
opacity: 1, | |
}, | |
initial: { | |
scaleX: -1, | |
opacity: 0, | |
transition: { duration: 0.4 }, | |
}, | |
}} | |
animate={ | |
card.title === picked && picked === selected | |
? "selected" | |
: "initial" | |
} | |
style={{ | |
transformStyle: "preserve-3d", | |
backfaceVisibility: "hidden", | |
}} | |
> | |
<Image | |
data-card={card.title} | |
style={{}} | |
fill | |
src={card.image} | |
alt={card.title} | |
/> | |
</motion.div> | |
<motion.div | |
id="back" | |
exit={{ opacity: 0 }} | |
key={`back-${card.title}`} | |
className={clsx( | |
"absolute w-full h-full border-2 border-black/20 hover:border-white", | |
{ | |
"border-white": picked === card.title, | |
} | |
)} | |
variants={{ | |
selected: { | |
transition: { duration: 0.4 }, | |
scaleX: -1, | |
opacity: 0, | |
}, | |
preSelected: { | |
transition: { duration: 0.4 }, | |
opacity: 1, | |
}, | |
initial: { | |
transition: { duration: 0.4 }, | |
opacity: 1, | |
}, | |
}} | |
animate={ | |
card.title === picked && picked === selected | |
? "selected" | |
: "initial" | |
} | |
onAnimationComplete={handleAnimationEnd} | |
style={{ | |
backfaceVisibility: "hidden", | |
transformStyle: "preserve-3d", | |
}} | |
> | |
<Image | |
data-card={card.title} | |
fill | |
src={ | |
"https://images.unsplash.com/photo-1578073273382-f847b29d2192?q=80&w=2568&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" | |
} | |
alt={card.title} | |
/> | |
</motion.div> | |
</motion.div> | |
</div> | |
</motion.div> | |
); | |
})} | |
</AnimatePresence> | |
<div className="flex gap-2"> | |
<button | |
className="relative top-6 p-2 rounded-sm bg-white hover:bg-white/80 text-black" | |
onClick={handleRandomPick} | |
> | |
{picked ? "Pick another" : "Choose for me"} | |
</button> | |
{picked && ( | |
<button | |
className="relative top-6 p-2 rounded-sm bg-fuchsia-800 hover:bg-fuchsia-800/80 text-fuchsia-300" | |
onClick={(_e) => { | |
if (selected == picked) { | |
setSelected(null); | |
} else { | |
setSelected(picked); | |
} | |
}} | |
> | |
Reveal Card | |
</button> | |
)} | |
</div> | |
</div> | |
</div> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment