Dropdown
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Open the menu and glide over the items; the highlight follows you.
Code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Archive, Check, ChevronDown, Copy, Pencil, Share2, Trash2 } from "lucide-react";
import { useId, useState } from "react";
import { Surface } from "../ui";
const ITEMS = [
{ label: "Rename", Icon: Pencil, key: "R" },
{ label: "Duplicate", Icon: Copy, key: "D" },
{ label: "Share", Icon: Share2, key: "S" },
{ label: "Archive", Icon: Archive, key: "A" },
{ label: "Delete", Icon: Trash2, key: "⌫", danger: true },
];
export default function Dropdown() {
const [open, setOpen] = useState(false);
const [hot, setHot] = useState<number | null>(null);
const [done, setDone] = useState<string | null>(null);
const id = useId();
return (
<Surface className="place-items-start justify-items-center pt-[7%]">
<div className="relative">
<motion.button
type="button"
whileTap={{ scale: 0.96 }}
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-[0.6em] rounded-[0.8em] bg-white px-[1em] py-[0.6em] text-[0.9em] font-medium shadow-[0_6px_20px_-12px_rgba(0,0,0,0.3),0_0_0_1px_rgba(0,0,0,0.05)]"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={done ?? "o"} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} className="flex items-center gap-[0.4em]">
{done ? (
<>
<Check className="h-[1em] w-[1em] text-[#2B46B8]" /> {done}
</>
) : (
"Options"
)}
</motion.span>
</AnimatePresence>
<motion.span animate={{ rotate: open ? 180 : 0 }}>
<ChevronDown className="h-[1em] w-[1em] text-[#8a8984]" />
</motion.span>
</motion.button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, scale: 0.92, y: -6, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.95, y: -4, filter: "blur(4px)" }}
transition={{ type: "spring", stiffness: 500, damping: 34 }}
style={{ transformOrigin: "50% 0%" }}
className="absolute left-1/2 top-full z-10 mt-[0.5em] w-[13em] -translate-x-1/2 rounded-[0.9em] bg-white p-[0.35em] shadow-[0_16px_40px_-18px_rgba(0,0,0,0.4),0_0_0_1px_rgba(0,0,0,0.05)]"
onPointerLeave={() => setHot(null)}
>
{ITEMS.map((it, i) => (
<motion.button
key={it.label}
type="button"
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0, transition: { delay: 0.03 * i } }}
onPointerEnter={() => setHot(i)}
onClick={() => {
setDone(it.label);
setOpen(false);
}}
className={`relative flex w-full items-center gap-[0.6em] rounded-[0.6em] px-[0.7em] py-[0.42em] text-left text-[0.85em] ${it.danger ? "text-[#d93b3b]" : "text-[#151515]"} ${i === ITEMS.length - 1 ? "mt-[0.3em]" : ""}`}
>
{hot === i && (
<motion.span
layoutId={`${id}-hl`}
className={`absolute inset-0 rounded-[0.6em] ${it.danger ? "bg-[#fdecec]" : "bg-[#f1f0ec]"}`}
transition={{ type: "spring", stiffness: 600, damping: 40 }}
/>
)}
<it.Icon className="relative h-[1.05em] w-[1.05em]" />
<span className="relative flex-1">{it.label}</span>
<span className="relative font-mono text-[0.8em] text-[#a9a8a2]">{it.key}</span>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</Surface>
);
}
Prompt
Build "Dropdown", a navigation animation. What it does: Open the menu and glide over the items; the highlight follows you. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using lucide-react, motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).