-
Notifications
You must be signed in to change notification settings - Fork 225
refactor(flip-card): composable Front/Back API with motion-safe code #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,59 +1,83 @@ | ||
| import { type ComponentProps, createContext, use, useMemo } from "react"; | ||
|
|
||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| interface FlipCardProps extends React.HTMLAttributes<HTMLDivElement> { | ||
| image: string; | ||
| title: string; | ||
| description: string; | ||
| subtitle?: string; | ||
| rotate?: "x" | "y"; | ||
| export type FlipCardRotate = "x" | "y"; | ||
|
|
||
| type FlipCardContextValue = { | ||
| rotate: FlipCardRotate; | ||
| }; | ||
|
|
||
| const FlipCardContext = createContext<FlipCardContextValue | null>(null); | ||
|
|
||
| const ROTATION_CLASS = { | ||
| x: { | ||
| hover: "group-hover/card:rotate-x-180 motion-reduce:group-hover/card:rotate-x-0", | ||
| back: "rotate-x-180", | ||
| }, | ||
| y: { | ||
| hover: "group-hover/card:rotate-y-180 motion-reduce:group-hover/card:rotate-y-0", | ||
| back: "rotate-y-180", | ||
| }, | ||
| } as const; | ||
|
|
||
| function useFlipCard() { | ||
| const context = use(FlipCardContext); | ||
| if (!context) { | ||
| throw new Error("FlipCard.Front and FlipCard.Back must be used within <FlipCard>."); | ||
| } | ||
| return context; | ||
| } | ||
|
|
||
| export default function FlipCard({ | ||
| image, | ||
| title, | ||
| description, | ||
| subtitle, | ||
| rotate = "y", | ||
| className, | ||
| ...props | ||
| }: FlipCardProps) { | ||
| const rotationClass = { | ||
| x: ["group-hover/card:rotate-x-180", "rotate-x-180"], | ||
| y: ["group-hover/card:rotate-y-180", "rotate-y-180"], | ||
| } as const; | ||
| type FlipCardRootProps = ComponentProps<"div"> & { | ||
| rotate?: FlipCardRotate; | ||
| }; | ||
|
|
||
| function FlipCardRoot({ rotate = "y", className, children, ...props }: FlipCardRootProps) { | ||
| const value = useMemo(() => ({ rotate }), [rotate]); | ||
|
|
||
| return ( | ||
| <div className={cn("group/card h-72 w-56 perspective-[1000px]", className)} {...props}> | ||
| <div | ||
| className={cn( | ||
| "relative h-full rounded-2xl transition-transform duration-500 transform-3d", | ||
| rotationClass[rotate][0], | ||
| )} | ||
| > | ||
| {/* Front */} | ||
| <div className="absolute inset-0 backface-hidden"> | ||
| <img | ||
| src={image} | ||
| alt={title} | ||
| className="h-full w-full rounded-2xl object-cover shadow-2xl shadow-black/40" | ||
| /> | ||
| <div className="absolute bottom-4 left-4 text-xl font-bold text-white">{title}</div> | ||
| </div> | ||
| {/* Back */} | ||
| <FlipCardContext.Provider value={value}> | ||
| <div className={cn("group/card h-72 w-56 perspective-[1000px]", className)} {...props}> | ||
| <div | ||
| className={cn( | ||
| "absolute inset-0 rounded-2xl bg-black/80 p-4 text-slate-200 backface-hidden", | ||
| rotationClass[rotate][1], | ||
| "relative h-full rounded-2xl transition-transform duration-500 ease-out transform-3d will-change-transform motion-reduce:transition-none", | ||
| ROTATION_CLASS[rotate].hover, | ||
| )} | ||
| > | ||
| <div className="flex min-h-full flex-col gap-2"> | ||
| <h1 className="text-base font-bold text-white">{subtitle}</h1> | ||
| <p className="mt-1 border-t border-t-gray-200 py-4 text-base font-medium leading-normal text-gray-100"> | ||
| {description} | ||
| </p> | ||
| </div> | ||
| {children} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </FlipCardContext.Provider> | ||
| ); | ||
| } | ||
|
|
||
| type FlipCardFaceProps = ComponentProps<"div">; | ||
|
|
||
| function FlipCardFront({ className, ...props }: FlipCardFaceProps) { | ||
| useFlipCard(); | ||
|
|
||
| return <div className={cn("absolute inset-0 backface-hidden", className)} {...props} />; | ||
| } | ||
|
|
||
| function FlipCardBack({ className, ...props }: FlipCardFaceProps) { | ||
| const { rotate } = useFlipCard(); | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn("absolute inset-0 backface-hidden", ROTATION_CLASS[rotate].back, className)} | ||
| {...props} | ||
| /> | ||
| ); | ||
| } | ||
|
|
||
| const FlipCard = Object.assign(FlipCardRoot, { | ||
| Front: FlipCardFront, | ||
| Back: FlipCardBack, | ||
| }) as typeof FlipCardRoot & { | ||
| Front: typeof FlipCardFront; | ||
| Back: typeof FlipCardBack; | ||
| }; | ||
|
|
||
| export default FlipCard; | ||
| export { FlipCard, FlipCardBack, FlipCardFront, FlipCardRoot }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,58 +1,92 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
| import FlippingCard from "@/animata/list/flipping-cards"; | ||
| import { PlusCircle } from "lucide-react"; | ||
|
|
||
| import Marquee from "@/animata/container/marquee"; | ||
| import FlippingCards, { getFlippingCardsAccent } from "@/animata/list/flipping-cards"; | ||
|
|
||
| const demoItems = [ | ||
| { | ||
| font: "Antonov AN-255", | ||
| title: "Aa", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Boeing 747", | ||
| title: "Bb", | ||
| image: | ||
| "https://plus.unsplash.com/premium_photo-1717916843908-7bbee16bad20?q=80&w=1964&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Cessna 172", | ||
| title: "Cc", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718743256288-e77382a88aaf?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Dassault Falcon 7X", | ||
| title: "Dd", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Embraer EMB 120 ", | ||
| title: "Ee", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718792679559-5cfd607bb564?q=80&w=1956&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Fokker F100", | ||
| title: "Ff", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718397172443-48185c6bb4e1?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| ]; | ||
|
|
||
| const meta = { | ||
| title: "List/Flipping Cards", | ||
| component: FlippingCard, | ||
| component: FlippingCards, | ||
| parameters: { | ||
| layout: "centered", | ||
| }, | ||
| tags: ["autodocs"], | ||
| argTypes: {}, | ||
| } satisfies Meta<typeof FlippingCard>; | ||
| } satisfies Meta<typeof FlippingCards>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| export const Primary: Story = { | ||
| args: { | ||
| list: [ | ||
| { | ||
| font: "Antonov AN-255", | ||
| title: "Aa", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Boeing 747", | ||
| title: "Bb", | ||
| image: | ||
| "https://plus.unsplash.com/premium_photo-1717916843908-7bbee16bad20?q=80&w=1964&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Cessna 172", | ||
| title: "Cc", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718743256288-e77382a88aaf?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Dassault Falcon 7X", | ||
| title: "Dd", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718889874468-3a56b84bb2e7?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Embraer EMB 120 ", | ||
| title: "Ee", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718792679559-5cfd607bb564?q=80&w=1956&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| { | ||
| font: "Fokker F100", | ||
| title: "Ff", | ||
| image: | ||
| "https://images.unsplash.com/photo-1718397172443-48185c6bb4e1?q=80&w=1974&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", | ||
| }, | ||
| ], | ||
| }, | ||
| render: () => ( | ||
| <FlippingCards> | ||
| {demoItems.map((item, index) => ( | ||
| <FlippingCards.Item key={item.title}> | ||
| <FlippingCards.Item.Front className="flex bg-white"> | ||
| <div className="flex w-full flex-col border border-black/15 px-3 py-4 text-sm"> | ||
| <span className="border-t-2 border-black pt-1 text-black">{item.font}</span> | ||
| <span className="mt-4 border-b-2 border-black px-1 font-serif text-8xl text-black"> | ||
| {item.title} | ||
|
Comment on lines
+63
to
+67
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make the story face markup theme-responsive. The face content hardcodes light-only colors ( Suggested token-based class update- <FlippingCards.Item.Front className="flex bg-white">
- <div className="flex w-full flex-col border border-black/15 px-3 py-4 text-sm">
- <span className="border-t-2 border-black pt-1 text-black">{item.font}</span>
- <span className="mt-4 border-b-2 border-black px-1 font-serif text-8xl text-black">
+ <FlippingCards.Item.Front className="flex bg-card text-card-foreground">
+ <div className="flex w-full flex-col border border-border/60 px-3 py-4 text-sm">
+ <span className="border-t-2 border-border pt-1">{item.font}</span>
+ <span className="mt-4 border-b-2 border-border px-1 font-serif text-8xl">
{item.title}
</span>
@@
- <span className="text-black">See more</span>
- <PlusCircle size={18} color="black" />
+ <span className="text-card-foreground">See more</span>
+ <PlusCircle size={18} className="text-card-foreground" />As per coding guidelines, "All new components must be theme-responsive with support for both light and dark themes". Also applies to: 84-85 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| </span> | ||
| <div className="mt-12 flex items-center justify-between"> | ||
| <span>{index + 1}</span> | ||
| <PlusCircle size={18} /> | ||
| </div> | ||
| </div> | ||
| </FlippingCards.Item.Front> | ||
| <FlippingCards.Item.Back | ||
| className="flex flex-col justify-between overflow-hidden py-4 text-sm" | ||
| style={{ backgroundColor: getFlippingCardsAccent(index) }} | ||
| > | ||
| <img alt="" src={item.image} className="size-32 px-2" /> | ||
| <Marquee className="font-serif text-5xl text-white" applyMask={false}> | ||
| {item.font.split(" ")[0]} | ||
| </Marquee> | ||
| <div className="flex items-center justify-between px-3"> | ||
| <span className="text-black">See more</span> | ||
| <PlusCircle size={18} color="black" /> | ||
| </div> | ||
| </FlippingCards.Item.Back> | ||
| </FlippingCards.Item> | ||
| ))} | ||
| </FlippingCards> | ||
| ), | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reduced-motion users can no longer access the back face.
Lines 15 and 19 cancel the hover rotation under
prefers-reduced-motion, so the card never flips whileFlipCardBackstays pre-rotated at 180°. That makes the back content visually unreachable for those users.motion-reduce:transition-noneon Line 44 already removes the animation; the rotation override should be dropped so the flip still happens instantly.Suggested fix
const ROTATION_CLASS = { x: { - hover: "group-hover/card:rotate-x-180 motion-reduce:group-hover/card:rotate-x-0", + hover: "group-hover/card:rotate-x-180", back: "rotate-x-180", }, y: { - hover: "group-hover/card:rotate-y-180 motion-reduce:group-hover/card:rotate-y-0", + hover: "group-hover/card:rotate-y-180", back: "rotate-y-180", }, } as const;📝 Committable suggestion
🤖 Prompt for AI Agents