Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion

Pull cord below

TSX
pull-cord-menu
"use client"

import React, { useState, useEffect } from "react"
import { motion, useMotionValue, useTransform, useAnimation } from "framer-motion"
import { cn } from "@/lib/utils"
import { Settings, User, Bell, LogOut, Sparkles } from "lucide-react"

export const componentMeta = {
  name: "Pull Cord Menu",
  description: "A fun, spring-loaded menu triggered by pulling down a physical-feeling bungee cord.",
  props: {
    className: { type: "string", description: "Additional CSS classes", default: '""' }
  }
}

const menuItems = [
  { icon: User, label: "Profile", color: "text-blue-400" },
  { icon: Bell, label: "Notifications", color: "text-orange-400" },
  { icon: Settings, label: "Settings", color: "text-zinc-400" },
  { icon: LogOut, label: "Sign out", color: "text-red-400" },
]

export default function PullCordMenu({ className }: { className?: string }) {
  const [isOpen, setIsOpen] = useState(false)
  const dragY = useMotionValue(0)
  const controls = useAnimation()
  
  // The base cord length is 60px.
  // We add whatever the current dragged Y is to stretch it.
  const ropeHeight = useTransform(dragY, (y) => {
    return Math.max(0, 60 + y)
  })

  const handleDragEnd = (e: any, info: any) => {
    const threshold = isOpen ? 50 : 120
    const velocityThreshold = 500
    
    // Check if we pulled far enough or fast enough
    if (Math.abs(info.offset.y) > threshold || Math.abs(info.velocity.y) > velocityThreshold) {
      setIsOpen(!isOpen)
    } else {
      // Snap back if we didn't pull far enough
      controls.start({ 
        y: isOpen ? 320 : 0, 
        transition: { type: "spring", stiffness: 350, damping: 14, mass: 1 } 
      })
    }
  }

  // React to isOpen changes
  useEffect(() => {
    controls.start({ 
      y: isOpen ? 320 : 0, 
      transition: { type: "spring", stiffness: 350, damping: 14, mass: 1 } 
    })
  }, [isOpen, controls])

  return (
    <div className={cn("relative w-full max-w-[320px] mx-auto h-[480px] bg-zinc-50 dark:bg-zinc-950 border border-zinc-200 dark:border-zinc-800 rounded-3xl overflow-hidden flex flex-col items-center select-none", className)}>
        
        {/* Background Graphic */}
        <div className="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
          <Sparkles className="w-32 h-32 text-zinc-900 dark:text-white" />
        </div>

        {/* The Dropdown Menu */}
        <motion.div 
          initial={false}
          animate={{ y: isOpen ? 0 : "-100%" }}
          transition={{ type: "spring", stiffness: 250, damping: 20 }}
          className="absolute inset-x-0 top-0 pt-20 pb-6 bg-zinc-900 rounded-b-[40px] z-10 flex flex-col px-6 gap-2 shadow-[0_20px_40px_rgba(0,0,0,0.3)] border-b border-zinc-700"
        >
           {menuItems.map((item, idx) => (
             <motion.button 
               key={idx} 
               whileHover={{ x: 6, backgroundColor: "rgba(255,255,255,0.05)" }}
               whileTap={{ scale: 0.98 }}
               className="flex items-center gap-4 text-white text-base font-semibold px-4 py-3 rounded-xl transition-colors w-full"
             >
               <item.icon size={20} className={item.color} />
               <span>{item.label}</span>
             </motion.button>
           ))}
        </motion.div>

        {/* The rope (attached to ceiling) */}
        <div className="absolute top-0 flex flex-col items-center z-20 pointer-events-none w-full">
          <motion.div 
            className="w-1.5 bg-gradient-to-b from-red-800 to-red-600 shadow-[inset_0_0_2px_rgba(0,0,0,0.5)] origin-top rounded-b-full" 
            style={{ height: ropeHeight }}
          />
        </div>

        {/* The handle to pull */}
        <div className="absolute top-0 flex flex-col items-center z-30 w-full pointer-events-none">
          <motion.div
            drag="y"
            dragConstraints={{ top: isOpen ? 320 : 0, bottom: isOpen ? 320 : 0 }}
            dragElastic={0.4}
            onDragEnd={handleDragEnd}
            animate={controls}
            style={{ y: dragY, touchAction: "none", marginTop: 52 }}
            className="flex flex-col items-center cursor-grab active:cursor-grabbing w-16 pointer-events-auto"
          >
            {/* The plastic / wooden handle */}
            <div className="w-10 h-10 rounded-full bg-gradient-to-br from-red-500 to-red-700 border-2 border-red-900 shadow-[inset_0_-4px_6px_rgba(0,0,0,0.4),0_4px_10px_rgba(0,0,0,0.3)] flex items-center justify-center relative">
               <div className="w-5 h-5 rounded-full shadow-[inset_0_4px_6px_rgba(0,0,0,0.3)] bg-red-800" />
               <div className="absolute top-1 left-2 w-3 h-3 bg-white/20 rounded-full blur-[1px]" />
            </div>
          </motion.div>
        </div>

        <div className="mt-auto mb-10 z-0 pointer-events-none">
           <motion.p 
              animate={{ opacity: isOpen ? 0.3 : 0.7 }}
              className="text-zinc-400 text-sm font-semibold uppercase tracking-wider text-center"
           >
              {isOpen ? "Pull to close" : "Pull cord below"}
           </motion.p>
        </div>
    </div>
  )
}
Save to favoritesCopy to Figma
React

Pull Cord Menu

A fun, spring-loaded menu triggered by pulling down a physical-feeling bungee cord.

Installation

$ npx @cosmoo/oblivion add pull-cord-menu
Dependenciesnpm install framer-motion lucide-react

Usage Example

tsx
import PullCordMenu from "@/components/oblivion/pull-cord-menu"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <PullCordMenu />
    </div>
  )
}