Docs
Canvas Drawer

Canvas Drawer

A slide-out panel with canvas texture, brush stroke animations, and an artistic handle.

Installation

Install the following dependencies:

npm install framer-motion lucide-react

Copy and paste the following code into your project.

components/ui/canvas-drawer.tsx

"use client";
 
import * as React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
 
export interface CanvasDrawerProps {
  children: React.ReactNode;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  side?: "left" | "right";
  className?: string;
  showHandle?: boolean;
}
 
export function CanvasDrawer({
  children,
  open = false,
  onOpenChange,
  side = "right",
  className,
  showHandle = true,
}: CanvasDrawerProps) {
  const [isOpen, setIsOpen] = React.useState(open);
 
  React.useEffect(() => {
    setIsOpen(open);
  }, [open]);
 
  const handleToggle = () => {
    const newState = !isOpen;
    setIsOpen(newState);
    onOpenChange?.(newState);
  };
 
  return (
    <>
      {showHandle && (
        <motion.button
          onClick={handleToggle}
          className={cn(
            "group fixed top-1/2 z-50 -translate-y-1/2",
            side === "left" ? "left-0" : "right-0",
          )}
          whileHover={{ scale: 1.1 }}
          whileTap={{ scale: 0.95 }}
        >
          <div
            className={cn(
              "relative flex h-24 w-8 items-center justify-center",
              "rounded-full bg-linear-to-b from-primary/20 to-primary/10",
              "border border-primary/20 backdrop-blur-xs",
              "dark:from-primary/30 dark:to-primary/20",
              "transition-colors duration-200",
              "group-hover:from-primary/30 group-hover:to-primary/20",
              "dark:group-hover:from-primary/40 dark:group-hover:to-primary/30",
            )}
          >
            <motion.div
              className="absolute inset-0 rounded-full bg-primary/5"
              initial={false}
              animate={{
                scale: [1, 1.2, 1],
                opacity: [0.3, 0.2, 0.3],
              }}
              transition={{
                duration: 3,
                repeat: Infinity,
                repeatType: "reverse",
              }}
            />
            <div
              className={cn(
                "h-1 w-4 rounded-full bg-primary/50",
                "transition-transform duration-200",
                isOpen ? "rotate-180" : "rotate-0",
                side === "left" ? "-rotate-90" : "rotate-90",
              )}
            />
          </div>
        </motion.button>
      )}
 
      <AnimatePresence>
        {isOpen && (
          <>
            <motion.div
              className="fixed inset-0 z-40 bg-black/20 backdrop-blur-xs"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={handleToggle}
            />
            <motion.div
              className={cn(
                "fixed inset-y-0 z-50 w-80",
                "bg-linear-to-b from-background/95 to-background/80",
                "border-primary/10 backdrop-blur-md",
                side === "left" ? "left-0 border-r" : "right-0 border-l",
                "overflow-y-auto",
                className,
              )}
              initial={{
                x: side === "left" ? "-100%" : "100%",
                opacity: 0,
              }}
              animate={{
                x: 0,
                opacity: 1,
              }}
              exit={{
                x: side === "left" ? "-100%" : "100%",
                opacity: 0,
              }}
              transition={{
                type: "spring",
                damping: 25,
                stiffness: 200,
              }}
            >
              <motion.button
                onClick={handleToggle}
                className={cn(
                  "group absolute right-4 top-4 z-20",
                  "rounded-full p-2",
                  "bg-primary/5 hover:bg-primary/10",
                  "transition-colors duration-200",
                )}
                whileHover={{ scale: 1.1 }}
                whileTap={{ scale: 0.95 }}
              >
                <motion.div
                  className="absolute inset-0 rounded-full bg-primary/5"
                  initial={false}
                  animate={{
                    scale: [1, 1.2, 1],
                    opacity: [0.3, 0.2, 0.3],
                  }}
                  transition={{
                    duration: 3,
                    repeat: Infinity,
                    repeatType: "reverse",
                  }}
                />
                <X className="h-4 w-4 text-primary/70" />
              </motion.button>
              <div
                className={cn(
                  "absolute inset-0",
                  "bg-[url('/canvas-texture.png')] opacity-30",
                  "pointer-events-none",
                )}
              />
              <div className="relative h-full p-6">{children}</div>
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </>
  );
}

Update the import paths to match your project setup.

import { CanvasDrawer } from "@/components/ui/canvas-drawer";

Usage

Basic Example

import { CanvasDrawer } from "@/components/ui/canvas-drawer";
 
export default function Example() {
  const [open, setOpen] = React.useState(false);
 
  return (
    <CanvasDrawer side="right" open={open} onOpenChange={setOpen}>
      <div className="p-4">
        <h2>Drawer Content</h2>
        <p>Click the artistic handle or close icon to toggle</p>
      </div>
    </CanvasDrawer>
  );
}

Without Handle

Use showHandle={false} to create a cleaner look without the artistic handle. The drawer can still be toggled using the close icon, clicking outside, or through external controls.

import { CanvasDrawer } from "@/components/ui/canvas-drawer";
import { Button } from "@/components/ui/button";
 
export default function Example() {
  const [open, setOpen] = React.useState(false);
 
  return (
    <>
      <Button onClick={() => setOpen(true)}>Open Drawer</Button>
 
      <CanvasDrawer
        side="right"
        open={open}
        onOpenChange={setOpen}
        showHandle={false}
      >
        <div className="p-4">
          <h2>Drawer Content</h2>
          <p>Use the close icon or click outside to dismiss</p>
        </div>
      </CanvasDrawer>
    </>
  );
}

Props

PropTypeDefaultDescription
childrenReact.ReactNode-The content to display inside the drawer
openbooleanfalseControls the open state of the drawer
onOpenChange(open: boolean) => void-Callback when drawer open state changes
side"left" | "right""right"Which side the drawer appears from
showHandlebooleantrueShow the artistic handle/toggle button
classNamestring-Additional CSS classes for styling