Docs
Go back
Component by
OV
OblivionPull 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>
)
}"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-menuDependencies
npm install framer-motion lucide-reactUsage Example
tsx
import PullCordMenu from "@/components/oblivion/pull-cord-menu"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<PullCordMenu />
</div>
)
}import PullCordMenu from "@/components/oblivion/pull-cord-menu"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<PullCordMenu />
</div>
)
}