Command Palette

Search for a command to run...

Collapsible

The Collapsible component allows users to toggle the visibility of content, either collapsed or expanded.

Installation

Install the following dependencies:

pnpm add motion

Create a file at components/motion/collapsible.tsx and paste the given code.

"use client";
import * as React from "react";
import {
    AnimatePresence,
    motion,
    MotionConfig,
    Transition,
    Variant,
    Variants,
} from "motion/react";
import { createContext, useContext, useState, useId, useEffect } from "react";
import { cn } from "@/lib/utils";
 
export type CollapsibleContextType = {
    open: boolean;
    toggle: () => void;
    variants?: { expanded: Variant; collapsed: Variant };
};
 
const CollapsibleContext = createContext<CollapsibleContextType | undefined>(
    undefined,
);
 
export type CollapsibleProviderProps = {
    children: React.ReactNode;
    open: boolean;
    onOpenChange?: (open: boolean) => void;
    variants?: { expanded: Variant; collapsed: Variant };
};
 
function CollapsibleProvider({
    children,
    open: openProp,
    onOpenChange,
    variants,
}: CollapsibleProviderProps) {
    const [internalOpenValue, setInternalOpenValue] =
        useState<boolean>(openProp);
 
    useEffect(() => {
        setInternalOpenValue(openProp);
    }, [openProp]);
 
    const toggle = () => {
        const newOpen = !internalOpenValue;
        setInternalOpenValue(newOpen);
        if (onOpenChange) {
            onOpenChange(newOpen);
        }
    };
 
    return (
        <CollapsibleContext.Provider
            value={{
                open: internalOpenValue,
                toggle,
                variants,
            }}
        >
            {children}
        </CollapsibleContext.Provider>
    );
}
 
function useCollapsible() {
    const context = useContext(CollapsibleContext);
    if (!context) {
        throw new Error(
            "useCollapsible must be used within a CollapsibleProvider",
        );
    }
    return context;
}
 
export type CollapsibleProps = {
    open?: boolean;
    onOpenChange?: (open: boolean) => void;
    children: React.ReactNode;
    className?: string;
    variants?: { expanded: Variant; collapsed: Variant };
    transition?: Transition;
};
 
export function Collapsible({
    open: openProp = false,
    onOpenChange,
    children,
    className,
    transition,
    variants,
}: CollapsibleProps) {
    return (
        <MotionConfig transition={transition}>
            <div className={className}>
                <CollapsibleProvider
                    open={openProp}
                    onOpenChange={onOpenChange}
                    variants={variants}
                >
                    {React.Children.toArray(children)[0]}
                    {React.Children.toArray(children)[1]}
                </CollapsibleProvider>
            </div>
        </MotionConfig>
    );
}
 
export function CollapsibleTrigger({
    children,
    className,
}: {
    children: React.ReactNode;
    className?: string;
}) {
    const { toggle, open } = useCollapsible();
 
    return (
        <>
            {React.Children.map(children, (child) => {
                return React.isValidElement(child)
                    ? React.cloneElement(child, {
                          onClick: toggle,
                          role: "button",
                          "aria-expanded": open,
                          tabIndex: 0,
                          onKeyDown: (e: {
                              key: string;
                              preventDefault: () => void;
                          }) => {
                              if (e.key === "Enter" || e.key === " ") {
                                  e.preventDefault();
                                  toggle();
                              }
                          },
                          className: cn(
                              className,
                              // eslint-disable-next-line @typescript-eslint/no-explicit-any
                              (child as React.ReactElement<any>).props
                                  .className,
                          ),
                          // eslint-disable-next-line @typescript-eslint/no-explicit-any
                          ...(child as React.ReactElement<any>).props,
                      })
                    : child;
            })}
        </>
    );
}
 
export function CollapsibleContent({
    children,
    className,
}: {
    children: React.ReactNode;
    className?: string;
}) {
    const { open, variants } = useCollapsible();
    const uniqueId = useId();
 
    const BASE_VARIANTS: Variants = {
        expanded: {
            height: "auto",
            opacity: 1,
        },
        collapsed: {
            height: 0,
            opacity: 0,
        },
    };
 
    const combinedVariants = {
        expanded: { ...BASE_VARIANTS.expanded, ...variants?.expanded },
        collapsed: { ...BASE_VARIANTS.collapsed, ...variants?.collapsed },
    };
 
    return (
        <div className={cn("overflow-hidden", className)}>
            <AnimatePresence initial={false}>
                {open && (
                    <motion.div
                        id={uniqueId}
                        initial="collapsed"
                        animate="expanded"
                        exit="collapsed"
                        variants={combinedVariants}
                    >
                        {children}
                    </motion.div>
                )}
            </AnimatePresence>
        </div>
    );
}
 
// export default {
//     Collapsible,
//     CollapsibleProvider,
//     CollapsibleTrigger,
//     CollapsibleContent,
// };

Update the import paths to match your project setup.