Expandable Card (Tailwind)

PreviousNext

A card component that can expand to show more content.

Docs
roiuiitem

Preview

Loading preview…
registry/brook/tailwind/ui/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";

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("relative h-fit w-fit", className)}>
      <Dialog.Root onOpenChange={setIsOpen} open={isOpen}>
        <AnimatePresence>
          {isOpen && (
            <Dialog.Backdrop
              hidden={undefined}
              key="overlay"
              render={
                <motion.div
                  animate={{
                    opacity: 0.99,
                  }}
                  className="fixed inset-0 z-[100] min-h-dvh bg-background"
                  exit={{ opacity: 0 }}
                  initial={{ opacity: 0 }}
                  transition={{
                    delay: 0.1,
                    duration: 0.25,
                    // biome-ignore lint/style/noMagicNumbers: cubic-bezier easing values
                    ease: [0.19, 1, 0.22, 1],
                  }}
                />
              }
            />
          )}
        </AnimatePresence>
        <Dialog.Portal keepMounted>
          <AnimatePresence>
            {isOpen && (
              <div
                className="pointer-events-none fixed inset-0 z-[9999] flex max-h-full items-center justify-center overflow-y-auto"
                key="positioner"
              >
                <Dialog.Popup
                  hidden={undefined}
                  render={
                    <motion.div
                      className={cn(
                        "fixed top-[5vh] max-h-dvh w-full max-w-[960px] overflow-hidden",
                        "border-[0.5px] border-[oklch(from_var(--border)_l_c_h/0.6)] bg-[var(--mix-card-15-bg)] p-0",
                        "pointer-events-auto flex flex-col items-center gap-[16px]",
                        "transform-none animate-none opacity-100 transition-none",
                        "scrollbar-thin scrollbar-thumb-[var(--border)] scrollbar-track-transparent"
                      )}
                      layoutId={`card-${item.id}`}
                      style={{
                        borderRadius: "32px",
                        overflow: "hidden",
                      }}
                    />
                  }
                >
                  <div
                    className={cn(
                      "relative flex h-full w-full flex-col items-center gap-[16px] overflow-y-auto px-6 pt-0 pb-[12vh]",
                      "scrollbar-thin scrollbar-thumb-[var(--border)] scrollbar-track-transparent"
                    )}
                    style={{
                      maskImage:
                        "linear-gradient(to bottom, var(--background) calc(100% - 10vh), oklch(from var(--background) l c h / 0.33) calc(100% - calc(8vh / 2)), transparent 100%)",
                      WebkitMaskImage:
                        "linear-gradient(to bottom, var(--background) calc(100% - 10vh), oklch(from var(--background) l c h / 0.33) calc(100% - calc(8vh / 2)), transparent 100%)",
                    }}
                  >
                    <div className="sticky top-8 z-20 flex h-11 w-11 cursor-pointer items-center justify-center self-end rounded-full">
                      <Dialog.Close
                        aria-label="Close"
                        className={cn(
                          "z-20 h-8 w-8 rounded-full border-[0.5px] border-[oklch(from_var(--border)_l_c_h_/_0.7)]",
                          "flex cursor-pointer items-center justify-center bg-[var(--background)] text-[var(--muted-foreground)] transition-[150ms_ease-out] md:bg-transparent",
                          "hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
                        )}
                        render={
                          <motion.button
                            animate={{ opacity: 1 }}
                            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="h-auto w-full max-w-[700px] object-contain"
                      height={600}
                      layoutId={`image-${item.id}`}
                      src={item.imageSrc}
                      style={{ borderRadius: "24px" }}
                      width={600}
                    />

                    <motion.div className="mx-auto flex h-auto w-full max-w-[700px] flex-col items-start gap-9 pt-7 pr-0 pb-0 pl-0 text-left leading-[2]">
                      <motion.div layoutId={`heading-${item.id}`}>
                        <h3 className="m-0 w-full self-start font-medium text-[48px] text-[var(--foreground)] leading-[1.5] tracking-[-0.02em]">
                          {item.cardHeading}
                        </h3>
                      </motion.div>

                      <motion.div
                        animate={{ opacity: 1, y: 0, scale: 1 }}
                        className="text-[oklch(from_var(--secondary-foreground)_l_c_h_/_0.8)]"
                        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",
                        }}
                      >
                        {item.content}
                      </motion.div>
                    </motion.div>
                  </div>
                </Dialog.Popup>
              </div>
            )}
          </AnimatePresence>
        </Dialog.Portal>

        <Dialog.Trigger
          render={
            <motion.button
              className="flex w-[320px] cursor-pointer flex-col items-center overflow-hidden border-[0.5px] border-[oklch(from_var(--border)_l_c_h_/_0.7)] bg-transparent p-0 focus-visible:outline-2 focus-visible:outline-[var(--ring)] focus-visible:outline-offset-2"
              layoutId={`card-${item.id}`}
              style={{
                all: "unset",
                cursor: "pointer",
                display: "flex",
                flexDirection: "column",
                alignItems: "center",
                width: "320px",
                border: "0.5px solid oklch(from var(--border) l c h / 0.7)",
                overflow: "hidden",
                borderRadius: "24px",
              }}
            />
          }
        >
          <motion.img
            alt={item.alt}
            className="h-[320px] w-full object-cover"
            height={300}
            layoutId={`image-${item.id}`}
            src={item.imageSrc}
            style={{ borderRadius: "24px" }}
            width={300}
          />

          <div className="flex w-full items-center justify-center p-4">
            <motion.div layoutId={`heading-${item.id}`}>
              <h3 className="m-0 font-medium text-2xl text-[var(--secondary-foreground)] leading-[1.5] tracking-[-0.02em] transition-[150ms_ease-out]">
                {item.cardHeading}
              </h3>
            </motion.div>

            <motion.div className="ml-auto flex h-9 min-h-9 w-9 min-w-9 shrink-0 items-center justify-center rounded-full border-[0.5px] border-[oklch(from_var(--border)_l_c_h_/_0.7)] text-[var(--muted-foreground)] transition-[150ms_ease-out] hover:bg-[var(--card)] hover:text-[var(--foreground)]">
              <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-tailwind

Usage

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