Drawer

A simple drawer component

Default Drawer

Loading...

Drawer origins

Loading...

Drawer controlled

Loading...

Drawer with custom background color

Loading...

Usage Guide

Install the clsx and tailwind-merge packages:

npm install clsx tw-merge

Next, add cn utility:

import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from "clsx";

export function cn(...inputs: ClassValue[]) {
    return twMerge(clsx(inputs));
}

Copy and paste the following source code in Drawer.tsx.

"use client";

import React from "react";
import { cn } from "@/lib/utils";
import { createPortal } from "react-dom";
import { Transition, Variants } from "framer-motion";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";

type DrawerOrigin = "left" | "right" | "top" | "bottom";

// Drawer context
const DrawerContext = React.createContext<{
    isOpen: boolean;
    setIsOpen: (isOpen: boolean) => void;
    origin: DrawerOrigin;
    variants: Variants;
    transition: Transition;
} | null>(null);

// useDrawer hook
const useDrawer = () => {
    const context = React.useContext(DrawerContext);
    if (!context) {
        throw new Error("Drawer components must be used within a Drawer");
    }
    return context;
};

// Drawer component
interface DrawerProps {
    children: React.ReactNode;
    origin?: DrawerOrigin;
    variants?: Variants;
    transition?: Transition;
    defaultOpen?: boolean;
    open?: boolean;
    onOpenChange?: (isOpen: boolean) => void;
}

const defaultVariants: Record<DrawerOrigin, Variants> = {
    left: {
        initial: { x: "-100%" },
        animate: { x: 0 },
        exit: { x: "-100%" }
    },
    right: {
        initial: { x: "100%" },
        animate: { x: 0 },
        exit: { x: "100%" }
    },
    top: {
        initial: { y: "-100%" },
        animate: { y: 0 },
        exit: { y: "-100%" }
    },
    bottom: {
        initial: { y: "100%" },
        animate: { y: 0 },
        exit: { y: "100%" }
    }
};

const defaultTransition: Transition = {
    type: "spring",
    stiffness: 300,
    damping: 30
};

const Drawer: React.FC<DrawerProps> = ({
    children,
    origin = "right",
    variants = defaultVariants[origin],
    transition = defaultTransition,
    defaultOpen = false,
    onOpenChange,
    open
}) => {
    const isControlled = open !== undefined;
    const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
    const isOpen = isControlled ? open : uncontrolledOpen;

    const setIsOpen = React.useCallback(
        (newOpen: boolean) => {
            if (!isControlled) {
                setUncontrolledOpen(newOpen);
            }
            onOpenChange?.(newOpen);
        },
        [isControlled, onOpenChange]
    );

    React.useEffect(() => {
        onOpenChange?.(isOpen);
    }, [isOpen, onOpenChange]);

    return (
        <DrawerContext.Provider value={{ isOpen, setIsOpen, origin, variants, transition }}>
            {children}
        </DrawerContext.Provider>
    );
};

// DrawerTrigger component
interface DrawerTriggerProps {
    children: React.ReactNode;
    asChild?: boolean;
    className?: string;
}

const DrawerTrigger: React.FC<DrawerTriggerProps> = ({ children, asChild = false, className }) => {
    const { setIsOpen } = useDrawer();

    const handleClick = (e: React.MouseEvent) => {
        e.preventDefault();
        setIsOpen(true);
    };

    if (asChild && React.isValidElement(children)) {
        return React.cloneElement(children as React.ReactElement<any>, {
            onClick: (e: React.MouseEvent) => {
                (children as React.ReactElement<any>).props.onClick?.(e);
                handleClick(e);
            }
        });
    }

    return (
        <button
            onClick={handleClick}
            className={cn(
                "px-4 py-2 text-sm font-medium rounded-md border transition-colors focus:outline-none",
                "bg-white text-black border-gray-200 hover:bg-gray-200 focus:ring-2 focus:ring-gray-300",
                "dark:bg-zinc-800 dark:text-white dark:border-zinc-700 dark:hover:bg-gray-800 dark:focus:ring-gray-600",
                className
            )}
        >
            {children}
        </button>
    );
};

// DrawerContent component
interface DrawerContentProps {
    children: React.ReactNode;
    onClose?: () => void;
    className?: string;
    backdropClassName?: string;
}

const DrawerContent: React.FC<DrawerContentProps> = ({
    children,
    onClose,
    className,
    backdropClassName
}) => {
    const { isOpen, setIsOpen, origin, variants, transition } = useDrawer();

    const drawerRef = React.useRef<HTMLDivElement>(null);
    const contentRef = React.useRef<HTMLDivElement>(null);

    React.useEffect(() => {
        const handleEscape = (event: KeyboardEvent) => {
            if (event.key === "Escape") {
                setIsOpen(false);
                onClose?.();
            }
        };

        if (isOpen) {
            document.addEventListener("keydown", handleEscape);
            return () => document.removeEventListener("keydown", handleEscape);
        }
    }, [isOpen, setIsOpen, onClose]);

    React.useEffect(() => {
        if (isOpen && contentRef.current) {
            const focusableElements = Array.from(
                contentRef.current.querySelectorAll(
                    'button, [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
                )
            ) as HTMLElement[];

            if (focusableElements.length > 0) {
                const firstElement = focusableElements[0];
                const lastElement = focusableElements[focusableElements.length - 1];

                const handleTabKey = (event: KeyboardEvent) => {
                    if (event.key === "Tab") {
                        if (event.shiftKey && document.activeElement === firstElement) {
                            event.preventDefault();
                            lastElement.focus();
                        } else if (!event.shiftKey && document.activeElement === lastElement) {
                            event.preventDefault();
                            firstElement.focus();
                        }
                    }
                };

                firstElement.focus();

                document.addEventListener("keydown", handleTabKey);
                return () => document.removeEventListener("keydown", handleTabKey);
            }
        }
    }, [isOpen]);

    const portalContent = (
        <AnimatePresence>
            {isOpen && (
                <motion.div
                    ref={drawerRef}
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    exit={{ opacity: 0 }}
                    transition={{ duration: 0.2 }}
                    className={cn(
                        "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
                        backdropClassName,
                        "dark:bg-black/70"
                    )}
                    onClick={(e) => {
                        if (e.target === drawerRef.current) {
                            setIsOpen(false);
                        }
                    }}
                >
                    <motion.div
                        ref={contentRef}
                        initial="initial"
                        animate="animate"
                        exit="exit"
                        transition={transition}
                        variants={variants}
                        className={cn(
                            "px-4 fixed bg-white shadow-md border",
                            "dark:bg-zinc-900 dark:text-white dark:border-zinc-800",
                            origin === "left" || origin === "right"
                                ? "top-0 bottom-0 w-full md:w-1/4"
                                : "left-0 right-0 h-80",
                            origin === "left"
                                ? "left-0"
                                : origin === "right"
                                ? "right-0"
                                : origin === "top"
                                ? "top-0"
                                : "bottom-0",
                            className
                        )}
                    >
                        {children}
                    </motion.div>
                </motion.div>
            )}
        </AnimatePresence>
    );

    return typeof window !== "undefined" ? createPortal(portalContent, document.body) : null;
};

// DrawerHeader component
const DrawerHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => (
    <div className={cn("py-4 border-b border-gray-200 dark:border-zinc-700", className)}>
        {children}
    </div>
);

// DrawerTitle component
const DrawerTitle: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => <h2 className={cn("text-xl font-semibold dark:text-white", className)}>{children}</h2>;

// DrawerDescription component
const DrawerDescription: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => <p className={cn("text-sm text-gray-400 dark:text-gray-500", className)}>{children}</p>;

// DrawerClose component
interface DrawerCloseProps {
    children?: React.ReactNode;
    asChild?: boolean;
    className?: string;
}

// DrawerClose component
const DrawerClose: React.FC<DrawerCloseProps> = ({ children, asChild = false, className }) => {
    const { setIsOpen } = useDrawer();

    const handleClose = (e: React.MouseEvent) => {
        e.preventDefault();
        setIsOpen(false);
    };

    if (asChild && React.isValidElement(children)) {
        return React.cloneElement(children as React.ReactElement<any>, {
            onClick: (e: React.MouseEvent) => {
                (children as React.ReactElement<any>).props.onClick?.(e);
                handleClose(e);
            }
        });
    }

    return (
        <button
            type="button"
            onClick={handleClose}
            aria-label="Close drawer"
            className={cn(
                "text-sm border border-gray-200 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-100",
                "dark:text-gray-300 dark:border-zinc-700 dark:hover:text-white dark:hover:bg-gray-800 dark:focus:ring-zinc-800",
                className
            )}
        >
            {children || <X className="h-4 w-4" />}
        </button>
    );
};

// DrawerFooter component
const DrawerFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({
    children,
    className
}) => (
    <div className={cn("py-4 border-t border-gray-200 dark:border-zinc-700", className)}>
        {children}
    </div>
);

export {
    Drawer,
    DrawerTrigger,
    DrawerContent,
    DrawerHeader,
    DrawerTitle,
    DrawerDescription,
    DrawerClose,
    DrawerFooter
};

Props

Drawer

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the component_
origin?stringleft, right, top, bottomright
variants?ObjectFramer motion variants._
transition?ObjectFramer motion transition._
defaultOpen?booleanIf true, the drawer is open by default.false
open?booleanControls whether the dawer is open or closed.undefined
onOpenChange?(isOpen: boolean) => voidCallback function triggered whenever the open state of the drawer changes.undefined

DrawerTrigger

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the drawer trigger_
className?stringAdditional class names to style the DrawerTrigger_
asChild?booleanIf false, returns button other wise returns childrenfalse

DrawerContent

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the DrawerContent_
className?stringAdditional class names to style the DrawerContent_
backdropClassName?stringAdditional class names to style the backdrop of drawer_
onClose?onClose?: () => voidFunction to run when drawer closesundefined

DrawerHeader

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the DrawerHeader_
className?stringAdditional class names to style the DrawerHeader_

DrawerTitle

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the DrawerTitle_
className?stringAdditional class names to style the DrawerTitle_

DrawerDescription

PropTypeDescriptionDefault
childrenReactNodeThe content to be rendered inside the DrawerDescription_
className?stringAdditional class names to style the DrawerDescription_

DrawerClose

PropTypeDescriptionDefault
children?ReactNodeThe content to be rendered inside the DrawerClose_
className?stringAdditional class names to style the DrawerClose_
asChild?booleanIf false, returns button other wise returns childrenfalse