"use client"; import { ArrowUpRight, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useCallback, useEffect, useId, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; // Shared-layout "card expands into a modal" pattern (App Store style): the // collapsed card *is* the trigger button, and shares a layoutId with the // expanded panel portalled to
. Motion FLIPs between the two boxes — // grow on open, shrink back on close — so it reads as one surface morphing // rather than a card that opens a separate dialog. // // The trigger stays mounted (just hidden) the whole time it's open, instead // of unmounting via AnimatePresence like a typical trigger/panel swap. That // keeps its place in the document flow so a surrounding grid of cards never // reflows while the modal is up — only its paint toggles. export interface ExpandingCardProps { /** Shown in both the collapsed card and the expanded modal; morphs continuously between them. */ title: ReactNode; /** Shown in both states, under the title. */ summary?: ReactNode; /** Expanded-only content — fades in once the card has landed. */ children: ReactNode; /** Collapsed-state affordance text. */ expandHint?: string; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Collapsed card. */ className?: string; /** Expanded modal surface. */ modalClassName?: string; } // Both surfaces set radius via inline `style` (never a Tailwind class) — // Motion only interpolates `borderRadius`/`boxShadow` during a layout // animation when they arrive as animatable style values, not static CSS. const CLOSED_RADIUS = 24; const OPEN_RADIUS = 32; // Shared layoutId morph = a FLIP between the card's box and the modal's box. // SPRING_LAYOUT, not SPRING_PANEL: ease.ts scopes SPRING_LAYOUT to exactly // this ("shared-layout glides... panels morphing between positions"), while // SPRING_PANEL is tuned for overlays that materialize in place with no box // to carry across. const MORPH = SPRING_LAYOUT; export function ExpandingCard({ title, summary, children, expandHint = "Expand", open: openProp, defaultOpen = false, onOpenChange, className, modalClassName, }: ExpandingCardProps) { const uid = useId(); const surfaceId = `${uid}-surface`; const titleId = `${uid}-title`; const summaryId = `${uid}-summary`; const reduce = useReducedMotion(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const [mounted, setMounted] = useState(false); const triggerRef = useRef