Flip Card
A highly interactive 3D flip card component that supports automatic, manual (gesture-based), and programmatic flipping.
Animation Control
Customize the feel of the flip animation. The default is a standard horizontal flip, but you can change the axis with flipDirection
and fine-tune the timing and physics with the duration
and easing
props for a unique, bouncy, or slow-motion effect.
Advanced Interactivity
Go beyond a simple flip. Enable the parallax
effect to make the card tilt in 3D space as the user's cursor moves over it, creating a sense of depth. For touch devices, enable manualFlip
to give users a native, gesture-based experience where they can physically drag and flick the card over.
Programmatic Control
Integrate the flip card into your application's logic. This demo showcases two powerful methods: imperatively controlling the card from a parent using a ref
and its exposed toggle()
method, and declaratively by managing its state as a fully controlled component with the isFlipped
and onFlip
props.
Installation
CLI
Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.
Manual
-
Install the following dependencies:
npm install react-spring @use-gesture/react
yarn add react-spring @use-gesture/react
pnpm add react-spring @use-gesture/react
-
Copy and paste the component code into your project.
'use client'; import * as React from 'react'; import { useSpring, animated } from '@react-spring/web'; import { useDrag } from '@use-gesture/react'; import { cn } from '@/lib/utils'; /** * A handle for programmatically controlling the FlipCard. */ export interface FlipCardRef { /** * Programmatically flips the card to a specific state or toggles it. * @param flipped The target state. If undefined, the card will toggle. */ flip: (flipped?: boolean) => void; /** * Toggles the current flipped state of the card. */ toggle: () => void; } interface FlipCardContextValue { isFlipped: boolean; toggle: () => void; flipDirection: 'horizontal' | 'vertical'; manualFlip: boolean; duration: number; easing: string; } const FlipCardContext = React.createContext<FlipCardContextValue | null>( null ); const useFlipCard = () => { const context = React.useContext(FlipCardContext); if (!context) { throw new Error( 'useFlipCard must be used within a <FlipCard> component.' ); } return context; }; interface FlipCardProps extends React.HTMLAttributes<HTMLDivElement> { /** The direction the card should flip. */ flipDirection?: 'horizontal' | 'vertical'; /** The duration of the CSS flip animation in milliseconds. */ duration?: number; /** The CSS timing function for the CSS flip animation. */ easing?: string; /** Enables a 3D parallax tilt effect on mouse move. */ parallaxEnabled?: boolean; /** Controls the intensity of the parallax effect. Higher numbers mean more tilt. */ parallaxIntensity?: number; /** Enables manual, gesture-based flipping instead of CSS-based interactions. */ manualFlip?: boolean; /** A controlled state for whether the card is flipped. */ isFlipped?: boolean; /** Callback function when the flip state changes, for use with controlled state. */ onFlip?: (isFlipped: boolean) => void; } /** * A highly interactive 3D flip card component that supports automatic, * manual (gesture-based), and programmatic flipping. */ const FlipCard = React.forwardRef<FlipCardRef, FlipCardProps>( ( { className, flipDirection = 'horizontal', duration = 700, easing = 'ease-in-out', parallaxEnabled = true, parallaxIntensity = 15, manualFlip = false, isFlipped: controlledIsFlipped, onFlip, ...props }, ref ) => { const [internalIsFlipped, setInternalIsFlipped] = React.useState(false); const [rotation, setRotation] = React.useState({ x: 0, y: 0 }); const cardRef = React.useRef<HTMLDivElement>(null); const isControlled = controlledIsFlipped !== undefined; const isFlipped = isControlled ? controlledIsFlipped : internalIsFlipped; const setIsFlipped = React.useCallback( (value: React.SetStateAction<boolean>) => { const newValue = typeof value === 'function' ? value(isFlipped) : value; if (!isControlled) { setInternalIsFlipped(newValue); } onFlip?.(newValue); }, [isControlled, isFlipped, onFlip] ); React.useImperativeHandle(ref, () => ({ flip: (flipped?: boolean) => { setIsFlipped((prev) => (flipped !== undefined ? flipped : !prev)); }, toggle: () => { setIsFlipped((prev) => !prev); }, })); const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { if (!parallaxEnabled || !cardRef.current) return; const { left, top, width, height } = cardRef.current.getBoundingClientRect(); const mouseX = e.clientX - left; const mouseY = e.clientY - top; const x = parallaxIntensity - (mouseX / width) * (parallaxIntensity * 2); const y = (mouseY / height) * (parallaxIntensity * 2) - parallaxIntensity; // Swap x and y to map mouse movement to intuitive rotational axes. setRotation({ x: y, y: x }); }; const handleMouseLeave = () => { if (!parallaxEnabled) return; setRotation({ x: 0, y: 0 }); }; const contextValue = { isFlipped, toggle: () => setIsFlipped((prev) => !prev), flipDirection, manualFlip, duration, easing, }; return ( <FlipCardContext.Provider value={contextValue}> <div ref={cardRef} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} style={{ transform: parallaxEnabled ? `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg)` : undefined, transition: parallaxEnabled ? 'transform 0.2s ease-out' : undefined, }} className={cn( 'group [perspective:1000px] w-full h-full', className )} {...props} /> </FlipCardContext.Provider> ); } ); FlipCard.displayName = 'FlipCard'; const FlipCardTrigger = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, children, ...props }, ref) => { const { isFlipped, toggle, manualFlip, flipDirection, duration, easing } = useFlipCard(); const triggerRef = React.useRef<HTMLDivElement>(null); const [{ rotate }, api] = useSpring(() => ({ rotate: isFlipped ? 180 : 0, config: { mass: 1, tension: 210, friction: 20 }, })); const bind = useDrag( ({ down, movement: [mx], velocity: [vx] }) => { // Determines if a flip should occur based on flick velocity or drag distance. const triggerFlip = vx > 0.4 || Math.abs(mx) > triggerRef.current!.offsetWidth / 2; if (!down && triggerFlip) { toggle(); } else { const newRotation = (isFlipped ? 180 : 0) + (flipDirection === 'horizontal' ? mx : -mx); api.start({ rotate: newRotation, immediate: down }); } }, { enabled: manualFlip, axis: flipDirection === 'horizontal' ? 'x' : 'y', from: () => [rotate.get(), rotate.get()], // Ensures the card snaps back to place if a drag is released without triggering a flip. onEnd: () => api.start({ rotate: isFlipped ? 180 : 0 }), } ); // Syncs the spring animation with external state changes (e.g., from controlled props). React.useEffect(() => { api.start({ rotate: isFlipped ? 180 : 0 }); }, [isFlipped, api]); const AnimatedDiv = animated.div; return ( <AnimatedDiv {...(manualFlip ? bind() : {})} ref={triggerRef} style={ manualFlip ? { transform: rotate.to((r) => flipDirection === 'horizontal' ? `rotateY(${r}deg)` : `rotateX(${r}deg)` ), } : { transitionDuration: `${duration}ms`, transitionTimingFunction: easing, } } className={cn( 'relative w-full h-full cursor-pointer rounded-lg [transform-style:preserve-3d]', !manualFlip && 'transition-transform', !manualFlip && { '[transform:rotateY(180deg)]': isFlipped && flipDirection === 'horizontal', '[transform:rotateX(180deg)]': isFlipped && flipDirection === 'vertical', 'group-hover:[transform:rotateY(180deg)] group-focus-within:[transform:rotateY(180deg)]': flipDirection === 'horizontal', 'group-hover:[transform:rotateX(180deg)] group-focus-within:[transform:rotateX(180deg)]': flipDirection === 'vertical', }, 'motion-reduce:transition-none', className )} onClick={!manualFlip ? toggle : undefined} tabIndex={manualFlip ? -1 : 0} role='button' aria-pressed={isFlipped} {...props} > {children} </AnimatedDiv> ); }); FlipCardTrigger.displayName = 'FlipCardTrigger'; const FlipCardFront = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, children, ...props }, ref) => { const { isFlipped } = useFlipCard(); return ( <div ref={ref} className={cn( 'absolute w-full h-full rounded-lg bg-card text-card-foreground shadow-md border [backface-visibility:hidden]', 'motion-reduce:transition-opacity motion-reduce:duration-500', 'motion-reduce:group-hover:opacity-0 motion-reduce:group-focus-within:opacity-0', isFlipped && 'motion-reduce:opacity-0', className )} aria-hidden={isFlipped} {...props} > {children} </div> ); }); FlipCardFront.displayName = 'FlipCardFront'; const FlipCardBack = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, children, ...props }, ref) => { const { isFlipped, flipDirection } = useFlipCard(); return ( <div ref={ref} className={cn( 'absolute w-full h-full rounded-lg bg-card text-card-foreground shadow-lg border [backface-visibility:hidden]', { '[transform:rotateY(180deg)]': flipDirection === 'horizontal', '[transform:rotateX(180deg)]': flipDirection === 'vertical', }, 'motion-reduce:transition-opacity motion-reduce:duration-500 motion-reduce:opacity-0 motion-reduce:[transform:none]', 'motion-reduce:group-hover:opacity-100 motion-reduce:group-focus-within:opacity-100', isFlipped && 'motion-reduce:opacity-100', className )} aria-hidden={!isFlipped} {...props} > {children} </div> ); }); FlipCardBack.displayName = 'FlipCardBack'; export { FlipCard, FlipCardTrigger, FlipCardFront, FlipCardBack };
Usage
Import the components and compose them to create a flip card.
import {
FlipCard,
FlipCardTrigger,
FlipCardFront,
FlipCardBack,
} from '@/components/ui/flip-card';
export default function FlipCardDemo() {
return (
<div className='grid grid-cols-1 md:grid-cols-2 gap-8'>
{/* Default Usage */}
<div className='w-full h-64'>
<FlipCard>
<FlipCardTrigger>
<FlipCardFront className='flex items-center justify-center'>
<p className='text-2xl font-bold'>Hover or Click Me</p>
</FlipCardFront>
<FlipCardBack className='flex items-center justify-center'>
<p>This is the back!</p>
</FlipCardBack>
</FlipCardTrigger>
</FlipCard>
</div>
{/* Customized vertical flip */}
<div className='w-full h-64'>
<FlipCard
flipDirection='vertical'
duration={1000}
parallaxIntensity={30}
>
<FlipCardTrigger>
<FlipCardFront className='flex flex-col items-center justify-center'>
<p className='text-2xl font-bold'>Vertical Flip</p>
<p className='text-sm'>with high parallax</p>
</FlipCardFront>
<FlipCardBack className='flex items-center justify-center'>
<p>A completely different feel.</p>
</FlipCardBack>
</FlipCardTrigger>
</FlipCard>
</div>
</div>
);
}
Props
The FlipCard
component accepts the following props, in addition to the standard React.HTMLAttributes<HTMLDivElement>
:
Prop | Type | Default | Description |
---|---|---|---|
flipDirection | 'horizontal' | 'vertical' | 'horizontal' | The direction the card should flip. |
duration | number | 700 | The duration of the CSS flip animation in milliseconds. |
easing | string | 'ease-in-out' | The CSS timing function for the CSS flip animation. |
parallaxEnabled | boolean | true | Enables a 3D parallax tilt effect on mouse move. |
parallaxIntensity | number | 15 | Controls the intensity of the parallax effect. |
manualFlip | boolean | false | Enables manual, gesture-based flipping instead of CSS interactions. |
isFlipped | boolean | - | A controlled state for whether the card is flipped. |
onFlip | (isFlipped: boolean) => void | - | Callback function when the flip state changes. |