Toggle Vault

PreviousNext

Expandable vault component with animated open/close, trigger button, and content panel.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/toggle-vault.tsx
"use client";

import React, { useState, createContext, useContext, ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";

interface VaultContextType {
  open: boolean;
  toggleOpen: () => void;
}
const VaultContext = createContext<VaultContextType | undefined>(undefined);

export interface ToggleVaultProps {
  children: ReactNode;
  className?: string;
}

export const ToggleVault: React.FC<ToggleVaultProps> = ({ children, className = "" }) => {
  const [open, setOpen] = useState(false);
  const toggleOpen = () => setOpen((prev) => !prev);

  return (
    <VaultContext.Provider value={{ open, toggleOpen }}>
      <div className={`relative w-full h-full min-h-[350px] ${className}`}>
        {children}
      </div>
    </VaultContext.Provider>
  );
};

interface TriggerProps {
  children: ReactNode;
  className?: string;
}
export const ToggleVaultTrigger: React.FC<TriggerProps> = ({ children, className = "" }) => {
  const context = useContext(VaultContext);
  if (!context) throw new Error("ToggleVaultTrigger must be inside ToggleVault");
  const { open, toggleOpen } = context;

  if (open) return null;

  return (
    <motion.div
      onClick={toggleOpen}
      aria-expanded={open}
      className={`
        absolute top-4 right-4 w-[100px] h-[40px] rounded-full flex items-center justify-center
        cursor-pointer z-50 transition-all duration-300 hover:scale-105 shadow-lg
        bg-black text-white dark:bg-white dark:text-black
        ${className}
      `}
    >
      <motion.span
        initial={{ y: -10, opacity: 0 }}
        animate={{ y: 0, opacity: 1 }}
        exit={{ y: 10, opacity: 0 }}
        className="font-bold"
      >
        {children}
      </motion.span>
    </motion.div>
  );
};

interface CloseProps {
  children: ReactNode;
  className?: string;
}
export const ToggleVaultClose: React.FC<CloseProps> = ({ children, className = "" }) => {
  const context = useContext(VaultContext);
  if (!context) throw new Error("ToggleVaultClose must be inside ToggleVault");
  const { open, toggleOpen } = context;
  if (!open) return null;

  return (
    <motion.div
      onClick={toggleOpen}
      key="close"
      initial={{ y: 10, opacity: 0 }}
      animate={{ y: 0, opacity: 1 }}
      exit={{ y: -10, opacity: 0 }}
      className={`
        absolute top-4 right-4 w-[100px] h-[40px] rounded-full flex items-center justify-center
        z-50 font-bold cursor-pointer transition-all duration-300 hover:scale-105 shadow-lg
        bg-white text-black dark:bg-black dark:text-white
        ${className}
      `}
    >
      {children}
    </motion.div>
  );
};

interface ContentProps {
  children: ReactNode;
  className?: string;
}
export const ToggleVaultContent: React.FC<ContentProps> = ({ children, className = "" }) => {
  const context = useContext(VaultContext);
  if (!context) throw new Error("ToggleVaultContent must be inside ToggleVault");
  const { open } = context;

  return (
    <AnimatePresence>
      {open && (
        <motion.div
          key="panel"
          initial={{ scaleX: 0.2, scaleY: 0.066, top: "1rem", right: "1rem", opacity: 0 }}
          animate={{
            scaleX: 1,
            scaleY: 1,
            top: "0.6rem",
            right: "0.6rem",
            opacity: 1,
            transition: { duration: 0.7, ease: [0.2, 0.9, 0.3, 1] },
          }}
          exit={{
            scaleX: 0.2,
            scaleY: 0.066,
            top: "1rem",
            right: "1rem",
            opacity: 0,
            transition: { duration: 0.6, ease: [0.2, 0.9, 0.3, 1] },
          }}
          className={`
            absolute rounded-2xl overflow-hidden z-40 shadow-lg 
            bg-black text-white dark:bg-white dark:text-black
            ${className}
          `}
          style={{ transformOrigin: "top right" }}
          aria-hidden={!open}
        >
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1, transition: { delay: 0.4 } }}
            exit={{ opacity: 0 }}
            className="p-1 flex flex-col gap-3 font-bold font-bricolage"
          >
            {children}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
};

Installation

npx shadcn@latest add @scrollxui/toggle-vault

Usage

import { ToggleVault } from "@/components/toggle-vault"
<ToggleVault />