{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "expandable", "description": "Expandable component system with smooth animations and customizable presets", "dependencies": [ "motion" ], "files": [ { "path": "registry/default/ui/expandable.tsx", "content": "\"use client\"\n\nimport React, {\n createContext,\n ReactNode,\n useContext,\n useEffect,\n useState,\n} from \"react\"\nimport {\n AnimatePresence,\n HTMLMotionProps,\n motion,\n TargetAndTransition,\n useMotionValue,\n useSpring,\n} from \"motion/react\"\nimport useMeasure from \"react-use-measure\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst springConfig = { stiffness: 200, damping: 20, bounce: 0.2 }\n\ninterface ExpandableContextType {\n isExpanded: boolean // Indicates whether the component is expanded\n toggleExpand: () => void // Function to toggle the expanded state\n expandDirection: \"vertical\" | \"horizontal\" | \"both\" // Direction of expansion\n expandBehavior: \"replace\" | \"push\" // How the expansion affects surrounding content\n transitionDuration: number // Duration of the expansion/collapse animation\n easeType:\n | \"easeInOut\"\n | \"easeIn\"\n | \"easeOut\"\n | \"linear\"\n | [number, number, number, number] // Easing function for the animation\n initialDelay: number // Delay before the animation starts\n onExpandEnd?: () => void // Callback function when expansion ends\n onCollapseEnd?: () => void // Callback function when collapse ends\n}\n\n// Create a context with default values\nconst ExpandableContext = createContext({\n isExpanded: false,\n toggleExpand: () => {},\n expandDirection: \"vertical\", // 'vertical' | 'horizontal' | 'both' // Direction of expansion\n expandBehavior: \"replace\", // How the expansion affects surrounding content\n transitionDuration: 0.3, // Duration of the expansion/collapse animation\n easeType: \"easeInOut\" as const, // Easing function for the animation\n initialDelay: 0,\n})\n\n// Custom hook to use the ExpandableContext\nconst useExpandable = () => useContext(ExpandableContext)\n\ntype ExpandablePropsBase = Omit, \"children\">\n\ninterface ExpandableProps extends ExpandablePropsBase {\n children: ReactNode | ((props: { isExpanded: boolean }) => ReactNode)\n expanded?: boolean\n onToggle?: () => void\n transitionDuration?: number\n easeType?:\n | \"easeInOut\"\n | \"easeIn\"\n | \"easeOut\"\n | \"linear\"\n | [number, number, number, number]\n expandDirection?: \"vertical\" | \"horizontal\" | \"both\"\n expandBehavior?: \"replace\" | \"push\"\n initialDelay?: number\n onExpandStart?: () => void\n onExpandEnd?: () => void\n onCollapseStart?: () => void\n onCollapseEnd?: () => void\n}\n// ROOT Expand component\nconst Expandable = React.forwardRef(\n (\n {\n children,\n expanded,\n onToggle,\n transitionDuration = 0.3,\n easeType = \"easeInOut\" as const,\n expandDirection = \"vertical\",\n expandBehavior = \"replace\",\n initialDelay = 0,\n onExpandStart,\n onExpandEnd,\n onCollapseStart,\n onCollapseEnd,\n ...props\n },\n ref\n ) => {\n // Internal state for expansion when the component is uncontrolled\n const [isExpandedInternal, setIsExpandedInternal] = useState(false)\n\n // Use the provided expanded prop if available, otherwise use internal state\n const isExpanded = expanded !== undefined ? expanded : isExpandedInternal\n\n // Use the provided onToggle function if available, otherwise use internal toggle function\n const toggleExpand =\n onToggle || (() => setIsExpandedInternal((prev) => !prev))\n\n // Effect to call onExpandStart or onCollapseStart when isExpanded changes\n useEffect(() => {\n if (isExpanded) {\n onExpandStart?.()\n } else {\n onCollapseStart?.()\n }\n }, [isExpanded, onExpandStart, onCollapseStart])\n\n // Create the context value to be provided to child components\n const contextValue: ExpandableContextType = {\n isExpanded,\n toggleExpand,\n expandDirection,\n expandBehavior,\n transitionDuration,\n easeType,\n initialDelay,\n onExpandEnd,\n onCollapseEnd,\n }\n\n return (\n \n \n {/* Render children as a function if provided, otherwise render as is */}\n {typeof children === \"function\" ? children({ isExpanded }) : children}\n \n \n )\n }\n)\n\n// Simplify animation types\ntype AnimationPreset = {\n initial: { [key: string]: any }\n animate: { [key: string]: any }\n exit: { [key: string]: any }\n}\n\n// Update ANIMATION_PRESETS type\nconst ANIMATION_PRESETS: Record = {\n fade: {\n initial: { opacity: 0 },\n animate: { opacity: 1 },\n exit: { opacity: 0 },\n },\n \"slide-up\": {\n initial: { opacity: 0, y: 20 },\n animate: { opacity: 1, y: 0 },\n exit: { opacity: 0, y: 20 },\n },\n \"slide-down\": {\n initial: { opacity: 0, y: -20 },\n animate: { opacity: 1, y: 0 },\n exit: { opacity: 0, y: -20 },\n },\n \"slide-left\": {\n initial: { opacity: 0, x: 20 },\n animate: { opacity: 1, x: 0 },\n exit: { opacity: 0, x: 20 },\n },\n \"slide-right\": {\n initial: { opacity: 0, x: -20 },\n animate: { opacity: 1, x: 0 },\n exit: { opacity: 0, x: -20 },\n },\n scale: {\n initial: { opacity: 0, scale: 0.8 },\n animate: { opacity: 1, scale: 1 },\n exit: { opacity: 0, scale: 0.8 },\n },\n rotate: {\n initial: { opacity: 0, rotate: -10 },\n animate: { opacity: 1, rotate: 0 },\n exit: { opacity: 0, rotate: -10 },\n },\n \"blur-sm\": {\n initial: { opacity: 0, filter: \"blur(4px)\" },\n animate: { opacity: 1, filter: \"blur(0px)\" },\n exit: { opacity: 0, filter: \"blur(4px)\" },\n },\n \"blur-md\": {\n initial: { opacity: 0, filter: \"blur(8px)\" },\n animate: { opacity: 1, filter: \"blur(0px)\" },\n exit: { opacity: 0, filter: \"blur(8px)\" },\n },\n \"blur-lg\": {\n initial: { opacity: 0, filter: \"blur(16px)\" },\n animate: { opacity: 1, filter: \"blur(0px)\" },\n exit: { opacity: 0, filter: \"blur(16px)\" },\n },\n}\n\n// Update type definitions\ntype AnimationConfig = {\n initial: { [key: string]: number | string }\n animate: { [key: string]: number | string }\n exit: { [key: string]: number | string }\n}\n\n// Props for defining custom animations\ninterface AnimationProps {\n initial?: TargetAndTransition\n animate?: TargetAndTransition\n exit?: TargetAndTransition\n transition?: any\n}\n\n// Inside ExpandableContent component\nconst getAnimationProps = (\n preset: keyof typeof ANIMATION_PRESETS | undefined,\n animateIn?: AnimationProps,\n animateOut?: AnimationProps\n) => {\n const defaultAnimation = {\n initial: {},\n animate: {},\n exit: {},\n }\n\n const presetAnimation = preset ? ANIMATION_PRESETS[preset] : defaultAnimation\n\n return {\n initial: presetAnimation.initial,\n animate: presetAnimation.animate,\n exit: animateOut?.exit || presetAnimation.exit,\n }\n}\n\n// Wrap this around items in the card that you want to be hidden then animated in on expansion\nconst ExpandableContent = React.forwardRef<\n HTMLDivElement,\n Omit, \"ref\"> & {\n preset?: keyof typeof ANIMATION_PRESETS\n animateIn?: AnimationProps\n animateOut?: AnimationProps\n stagger?: boolean\n staggerChildren?: number\n keepMounted?: boolean\n }\n>(\n (\n {\n children,\n preset,\n animateIn,\n animateOut,\n stagger = false,\n staggerChildren = 0.1,\n keepMounted = false,\n ...props\n },\n ref\n ) => {\n const { isExpanded, transitionDuration, easeType } = useExpandable()\n // useMeasure is used to measure the height of the content\n const [measureRef, { height: measuredHeight }] = useMeasure()\n // useMotionValue creates a value that can be animated smoothly\n const animatedHeight = useMotionValue(0)\n // useSpring applies a spring animation to the height value\n const smoothHeight = useSpring(animatedHeight, springConfig)\n\n useEffect(() => {\n // Animate the height based on whether the content is expanded or collapsed\n if (isExpanded) {\n animatedHeight.set(measuredHeight)\n } else {\n animatedHeight.set(0)\n }\n }, [isExpanded, measuredHeight, animatedHeight])\n\n const animationProps = getAnimationProps(preset, animateIn, animateOut)\n\n return (\n // This motion.div animates the height of the content\n \n {/* AnimatePresence handles the entering and exiting of components */}\n \n {(isExpanded || keepMounted) && (\n // This motion.div handles the animation of the content itself\n \n {stagger ? (\n // If stagger is true, we apply a staggered animation to the children\n \n {React.Children.map(\n children as React.ReactNode,\n (child, index) => (\n \n {child}\n \n )\n )}\n \n ) : (\n children\n )}\n \n )}\n \n \n )\n }\n)\n\ninterface ExpandableCardProps {\n children: ReactNode\n className?: string\n collapsedSize?: { width?: number; height?: number } // Size when collapsed\n expandedSize?: { width?: number; height?: number } // Size when expanded\n hoverToExpand?: boolean // Whether to expand on hover\n expandDelay?: number // Delay before expanding\n collapseDelay?: number // Delay before collapsing\n}\n\nconst ExpandableCard = React.forwardRef(\n (\n {\n children,\n className = \"\",\n collapsedSize = { width: 320, height: 211 },\n expandedSize = { width: 480, height: undefined },\n hoverToExpand = false,\n expandDelay = 0,\n collapseDelay = 0,\n ...props\n },\n ref\n ) => {\n // Get the expansion state and toggle function from the ExpandableContext\n const { isExpanded, toggleExpand, expandDirection } = useExpandable()\n\n // Use useMeasure hook to get the dimensions of the content\n const [measureRef, { width, height }] = useMeasure()\n\n // Create motion values for width and height\n const animatedWidth = useMotionValue(collapsedSize.width || 0)\n const animatedHeight = useMotionValue(collapsedSize.height || 0)\n\n // Apply spring animation to the motion values\n const smoothWidth = useSpring(animatedWidth, springConfig)\n const smoothHeight = useSpring(animatedHeight, springConfig)\n\n // Effect to update the animated dimensions when expansion state changes\n useEffect(() => {\n if (isExpanded) {\n animatedWidth.set(expandedSize.width || width)\n animatedHeight.set(expandedSize.height || height)\n } else {\n animatedWidth.set(collapsedSize.width || width)\n animatedHeight.set(collapsedSize.height || height)\n }\n }, [\n isExpanded,\n collapsedSize,\n expandedSize,\n width,\n height,\n animatedWidth,\n animatedHeight,\n ])\n\n // Handler for hover start event\n const handleHover = () => {\n if (hoverToExpand && !isExpanded) {\n setTimeout(toggleExpand, expandDelay)\n }\n }\n\n // Handler for hover end event\n const handleHoverEnd = () => {\n if (hoverToExpand && isExpanded) {\n setTimeout(toggleExpand, collapseDelay)\n }\n }\n\n return (\n \n \n {/* Nested divs purely for styling and layout (the shadow ring around the card) */}\n
\n
\n
\n {/* Ref for measuring content dimensions (so we can let framer know to animate into the dimensions) */}\n
\n {children}\n
\n
\n
\n
\n \n \n )\n }\n)\n\nExpandableCard.displayName = \"ExpandableCard\"\n\n// I'm telling you we just have to expand 🤌💵\nconst ExpandableTrigger = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes\n>(({ children, className, ...props }, ref) => {\n const { toggleExpand } = useExpandable()\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault()\n toggleExpand()\n }\n }\n\n return (\n \n {children}\n \n )\n})\n\nExpandableTrigger.displayName = \"ExpandableTrigger\"\n\nconst ExpandableCardHeader = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes\n>(({ className, children, ...props }, ref) => (\n \n \n {children}\n \n \n))\n\nExpandableCardHeader.displayName = \"ExpandableCardHeader\"\n\nconst ExpandableCardContent = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes\n>(({ className, children, ...props }, ref) => (\n \n {children}\n \n))\nExpandableCardContent.displayName = \"ExpandableCardContent\"\n\nconst ExpandableCardFooter = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes\n>(({ className, ...props }, ref) => (\n \n))\nExpandableCardFooter.displayName = \"ExpandableCardFooter\"\n\nexport {\n Expandable,\n useExpandable,\n ExpandableCard,\n ExpandableContent,\n ExpandableContext,\n ExpandableTrigger,\n ExpandableCardHeader,\n ExpandableCardContent,\n ExpandableCardFooter,\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }