# Morphing Card

The MorphingCard component creates an engaging, interactive card that smoothly transitions between different shapes and content. It features elegant 3D rotations, customizable gradients, and a clean design that's perfect for showcasing key information or features in a visually striking manner.

- **Category:** Card
- **Docs:** https://uibeats.com/docs/card/morphing-card
- **Registry:** https://uibeats.com/r/morphing-card.json
- **License:** MIT
- **npm dependencies:** `lucide-react`, `motion`, `next-themes`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/morphing-card.json
```

Or install the dependencies and copy the source below:

```bash
npm install lucide-react motion next-themes
```

## When to use

For cycling through a small set of related ideas in one slot: three product pillars, three use cases. Because it advances on a timer, keep each piece of copy short enough to read inside one interval, and consider turning autoPlay off where the content matters.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `width` | `string` | `'300px'` | Width of the morphing card |
| `height` | `string` | `'300px'` | Height of the morphing card |
| `contents` | `Array<{ shape: 'rectangle' \| 'circle' \| 'hexagon'; title: string; description: string; }>` | `-` | Array of content objects for each state of the card (required) |
| `colorScheme` | `{ from: string; to: string; }` | `{ from: 'var(--brand)', to: 'var(--accent-pink)' }` | Gradient color scheme for the card background |
| `autoPlay` | `boolean` | `true` | Whether the card should automatically cycle through shapes |
| `interval` | `number` | `3000` | Time interval (in milliseconds) between shape transitions when autoPlay is true |

## Example usage

```tsx
import React from "react";
import MorphingCard from "@/components/demo/card/morphing-card";

const MorphingCardUsage = () => {
  return (
    <MorphingCard
      width="250px"
      height="250px"
      contents={[
        {
          shape: "rectangle",
          title: "Discover",
          description:
            "Explore our innovative features that redefine user experience.",
        },
        {
          shape: "circle",
          title: "Connect",
          description:
            "Join a community of forward-thinking individuals and ideas.",
        },
        {
          shape: "hexagon",
          title: "Transform",
          description:
            "Witness the evolution of design and functionality in real-time.",
        },
      ]}
      colorScheme={{ from: "var(--brand)", to: "var(--accent-pink)" }}
      autoPlay={true}
      interval={4000}
    />
  );
};

export default MorphingCardUsage;
```

## Source

Save as `components/ui/morphing-card.tsx`.

```tsx
"use client";

import React, { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
import { ArrowRight, Pause, Play } from "lucide-react";
import { useTheme } from "next-themes";

type Shape = "rectangle" | "circle" | "hexagon";

interface MorphingCardProps {
  width?: string;
  height?: string;
  contents: Array<{
    shape: Shape;
    title: string;
    description: string;
  }>;
  colorScheme?: {
    from: string;
    to: string;
  };
  autoPlay?: boolean;
  interval?: number;
}

const shapeVariants = {
  rectangle: { borderRadius: "16px", rotate: 0 },
  circle: { borderRadius: "50%", rotate: 120 },
  hexagon: { borderRadius: "24% 76% 24% 76% / 32% 32% 68% 68%", rotate: 240 },
};

/**
 * The same shapes without the third of a turn between them.
 *
 * The silhouette is the point of this component and it survives; the rotation
 * is decoration on top of it, and a 120° sweep of a 300px card is the single
 * most vestibular-provoking thing in the library.
 */
const staticShapeVariants = {
  rectangle: { borderRadius: "16px", rotate: 0 },
  circle: { borderRadius: "50%", rotate: 0 },
  hexagon: { borderRadius: "24% 76% 24% 76% / 32% 32% 68% 68%", rotate: 0 },
};

const MorphingCard: React.FC<MorphingCardProps> = ({
  width = "300px",
  height = "300px",
  contents,
  // Themed by default rather than pinned to two indigo hex values, so an
  // installed card picks up the project's accent instead of ours.
  colorScheme = { from: "var(--brand)", to: "var(--accent-pink)" },
  autoPlay = true,
  interval = 3000,
}) => {
  const [currentIndex, setCurrentIndex] = useState(0);
  /**
   * `null` until the play control is touched.
   *
   * After that it is the user's decision and outranks everything else, which
   * is the distinction reduced motion actually draws: a carousel that starts
   * advancing on its own is the problem, and one the reader deliberately
   * started is not. Derived rather than held in state and corrected by an
   * effect, so the preference resolving does not cost a second render.
   */
  const [playingOverride, setPlayingOverride] = useState<boolean | null>(null);
  const prefersReducedMotion = useReducedMotion();
  const isPlaying = playingOverride ?? (autoPlay && !prefersReducedMotion);

  const nextShape = useCallback(() => {
    setCurrentIndex((prevIndex) => (prevIndex + 1) % contents.length);
  }, [contents.length]);

  useEffect(() => {
    let timer: NodeJS.Timeout;
    if (isPlaying) {
      timer = setInterval(nextShape, interval);
    }
    return () => clearInterval(timer);
  }, [isPlaying, interval, nextShape]);

  const currentContent = contents[currentIndex];
  const { theme, systemTheme } = useTheme();

  // `contents` is caller-supplied; an empty array would otherwise crash here.
  if (!currentContent) return null;

  const currentTheme = theme === "system" ? systemTheme : theme;

  return (
    <div className="relative" style={{ width, height }}>
      <motion.div
        className="absolute inset-0 cursor-pointer overflow-hidden rounded-2xl shadow-lg"
        style={{
          background: `linear-gradient(135deg, ${colorScheme.from}, ${colorScheme.to})`,
        }}
        animate={
          prefersReducedMotion
            ? staticShapeVariants[currentContent.shape]
            : shapeVariants[currentContent.shape]
        }
        transition={
          prefersReducedMotion
            ? { duration: 0 }
            : { duration: 0.8, ease: [0.4, 0, 0.2, 1] }
        }
        whileHover={prefersReducedMotion ? undefined : { scale: 1.05 }}
        whileTap={prefersReducedMotion ? undefined : { scale: 0.95 }}
      >
        {/* This empty div will handle the shape morphing */}
      </motion.div>
      <div className="absolute inset-0 flex items-center justify-center">
        <AnimatePresence mode="wait">
          <motion.div
            key={currentIndex}
            className="flex h-full w-full flex-col items-center justify-center p-6"
            initial={prefersReducedMotion ? false : { opacity: 0, y: 20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={
              prefersReducedMotion ? { opacity: 0 } : { opacity: 0, y: -20 }
            }
            transition={{ duration: prefersReducedMotion ? 0 : 0.5 }}
          >
            <h3 className="mb-4 text-center text-xl font-semibold text-white">
              {currentContent.title}
            </h3>
            <p className="text-center text-sm text-white">
              {currentContent.description}
            </p>
          </motion.div>
        </AnimatePresence>
      </div>
      <div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 space-x-1">
        {contents.map((_, index) => (
          <motion.div
            key={index}
            className="h-1 w-3 rounded-full bg-white"
            initial={{ opacity: 0.3 }}
            animate={{ opacity: index === currentIndex ? 1 : 0.3 }}
            transition={{ duration: 0.3 }}
          />
        ))}
      </div>
      <button
        className={`absolute top-4 right-4 rounded-full p-2 text-white ${currentTheme !== "dark" ? "bg-gray-800 hover:bg-gray-500" : "bg-white/20 hover:bg-white/30"}`}
        onClick={(e) => {
          e.stopPropagation();
          setPlayingOverride(!isPlaying);
        }}
      >
        {isPlaying ? <Pause size={14} /> : <Play size={14} />}
      </button>
      <button
        className={`absolute right-4 bottom-4 rounded-full p-2 text-white ${currentTheme !== "dark" ? "bg-gray-800 hover:bg-gray-500" : "bg-white/20 hover:bg-white/30"}`}
        onClick={(e) => {
          e.stopPropagation();
          nextShape();
        }}
      >
        <ArrowRight size={14} />
      </button>
    </div>
  );
};

export default MorphingCard;
```

## Credits

Written by [Michael](https://github.com/lappemic).
