Expandable Card

PreviousNext

A card component that can expand to show more content.

Docs
roiuiitem

Preview

Loading preview…
registry/brook/ui/expandable-card/expandable-card.tsx
"use client";
import { Dialog } from "@base-ui/react/dialog";
import { Plus, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import styles from "./expandable-card.module.css";

type ExpandableCardItem = {
  id: string | number;
  imageSrc: string;
  alt: string;
  cardHeading: string;
  content?: React.ReactNode;
};

type ExpandableCardProps = {
  item: ExpandableCardItem;
  className?: string;
};

function ExpandableCard({ item, className }: ExpandableCardProps) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className={cn(styles.wrapper, className)}>
      <Dialog.Root onOpenChange={setIsOpen} open={isOpen}>
        <AnimatePresence>
          {isOpen && (
            <Dialog.Backdrop
              hidden={undefined}
              key="overlay"
              render={
                <motion.div
                  animate={{
                    opacity: 1,
                  }}
                  className={styles.overlay}
                  exit={{ opacity: 0 }}
                  initial={{ opacity: 0 }}
                  transition={{
                    duration: 0.25,
                    // biome-ignore lint/style/noMagicNumbers: cubic-bezier easing values
                    ease: [0.455, 0.03, 0.515, 0.955],
                  }}
                />
              }
            />
          )}
        </AnimatePresence>
        <Dialog.Portal keepMounted>
          <AnimatePresence>
            {isOpen && (
              <div className={styles.modalPositioner} key="positioner">
                <Dialog.Popup
                  hidden={undefined}
                  render={
                    <motion.div
                      className={styles.expandedCard}
                      layoutId={`card-${item.id}`}
                      style={{
                        borderRadius: "32px",
                        overflow: "hidden",
                      }}
                    />
                  }
                >
                  <div className={styles.scrollableContent}>
                    <div className={styles.closeButtonContainer}>
                      <Dialog.Close
                        aria-label="Close"
                        render={
                          <motion.button
                            animate={{ opacity: 1 }}
                            className={styles.closeButton}
                            exit={{ opacity: 0, display: "flex" }}
                            initial={{ opacity: 0 }}
                            transition={{
                              type: "spring",
                              duration: 0.3,
                              delay: 0.1,
                            }}
                          />
                        }
                      >
                        <X height={21} strokeWidth={2} width={21} />
                      </Dialog.Close>
                    </div>

                    <motion.img
                      alt={item.alt}
                      className={styles.expandedImage}
                      height={600}
                      layoutId={`image-${item.id}`}
                      src={item.imageSrc}
                      style={{ borderRadius: "24px" }}
                      width={600}
                    />

                    <motion.div className={styles.contentExpanded}>
                      <motion.div layoutId={`heading-${item.id}`}>
                        <h3 className={styles.expandedHeading}>{item.cardHeading}</h3>
                      </motion.div>

                      <motion.div
                        animate={{ opacity: 1, y: 0, scale: 1 }}
                        className={styles.paragraphWrapper}
                        exit={{
                          opacity: 0,
                          display: "block",
                          y: -40,
                          scale: 0.92,
                        }}
                        initial={{ opacity: 0, y: -40, scale: 0.92 }}
                        transition={{
                          delay: 0.1,
                          duration: 0.3,
                          type: "spring",
                          bounce: 0,
                        }}
                      >
                        {item.content}
                      </motion.div>
                    </motion.div>
                  </div>
                </Dialog.Popup>
              </div>
            )}
          </AnimatePresence>
        </Dialog.Portal>
        <Dialog.Trigger
          render={
            <motion.button className={styles.card} layoutId={`card-${item.id}`} style={{ borderRadius: "24px" }} />
          }
        >
          <motion.img
            alt={item.alt}
            className={styles.image}
            height={300}
            layoutId={`image-${item.id}`}
            src={item.imageSrc}
            style={{ borderRadius: "24px" }}
            width={300}
          />

          <div className={styles.contentContainer}>
            <motion.div layoutId={`heading-${item.id}`}>
              <h3 className={styles.heading}>{item.cardHeading}</h3>
            </motion.div>

            <motion.div className={styles.expandIcon}>
              <Plus height={21} strokeWidth={2} width={21} />
            </motion.div>
          </div>
        </Dialog.Trigger>
      </Dialog.Root>
    </div>
  );
}

export { ExpandableCard };
export type { ExpandableCardItem, ExpandableCardProps };

Installation

npx shadcn@latest add @roiui/expandable-card

Usage

import { ExpandableCard } from "@/components/expandable-card"
<ExpandableCard />