Cards

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.

Custom Animation

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.

Drag or Tilt

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.

Front Side

Installation

CLI

Installation via the CLI is coming soon. For now, please follow the manual installation instructions below.

Manual

  1. 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
  2. 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>:

PropTypeDefaultDescription
flipDirection'horizontal' | 'vertical''horizontal'The direction the card should flip.
durationnumber700The duration of the CSS flip animation in milliseconds.
easingstring'ease-in-out'The CSS timing function for the CSS flip animation.
parallaxEnabledbooleantrueEnables a 3D parallax tilt effect on mouse move.
parallaxIntensitynumber15Controls the intensity of the parallax effect.
manualFlipbooleanfalseEnables manual, gesture-based flipping instead of CSS interactions.
isFlippedboolean-A controlled state for whether the card is flipped.
onFlip(isFlipped: boolean) => void-Callback function when the flip state changes.