A dark dropdown menu attached to a button. When it opens, the panel scales out from the corner nearest the button and its seven items appear in quick succession. A single rounded highlight sits behind the items and glides on a spring to whichever one is hovered or focused, the way the selection moves in a desktop menu. Each item has an icon and an optional shortcut hint, and the destructive action is set apart by a divider and colour. The demo shows the menu open over a mock file screen and reports the last action chosen.
// Adapted from Animata (https://animata.design/docs/card/staggered-card)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { Archive, ChevronDown, Copy, FolderInput, Link2, PenLine, Trash2, Users } from "lucide-react";
const items = [
{ key: "rename", label: "Rename", hint: "F2", icon: PenLine },
{ key: "duplicate", label: "Duplicate", hint: "Ctrl D", icon: Copy },
{ key: "move", label: "Move to folder", hint: "Ctrl M", icon: FolderInput },
{ key: "share", label: "Share with team", hint: "", icon: Users },
{ key: "link", label: "Copy link", hint: "Ctrl L", icon: Link2 },
{ key: "archive", label: "Archive", hint: "", icon: Archive },
{ key: "delete", label: "Delete", hint: "Del", icon: Trash2, danger: true },
];
const STAGGER = 0.045; // seconds between one item appearing and the next
const OPENING_DELAY = 0.08;
const easeOut: [number, number, number, number] = [0, 0, 0.2, 1];
type Box = { top: number; height: number };
export default function DropdownMenuWithStaggeredItems() {
const reduce = useReducedMotion();
const menuId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const byUser = useRef(false);
const [open, setOpen] = useState(true);
const [box, setBox] = useState<Box | null>(null);
const [idle, setIdle] = useState(true);
const [last, setLast] = useState("Nothing yet");
const highlight = (index: number) => {
const node = itemRefs.current[index];
// offsetTop is measured inside the list, so it stays right even while the menu is scaling in.
if (node) setBox({ top: node.offsetTop, height: node.offsetHeight });
};
// Until someone uses the menu, the highlight glides down the items by itself.
useEffect(() => {
if (!open || !idle || reduce) return;
let i = 0;
const first = setTimeout(() => highlight(0), 700);
const timer = setInterval(() => {
i = (i + 1) % items.length;
highlight(i);
}, 1300);
return () => {
clearTimeout(first);
clearInterval(timer);
};
}, [open, idle, reduce]);
useEffect(() => {
if (!open) {
setBox(null);
return;
}
if (byUser.current) itemRefs.current[0]?.focus();
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const count = items.length;
const current = itemRefs.current.findIndex((node) => node === document.activeElement);
const go = (i: number) => itemRefs.current[(i + count) % count]?.focus();
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.key === "Tab") setOpen(false);
else if (event.key === "ArrowDown") {
event.preventDefault();
go(current + 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
go(current - 1);
} else if (event.key === "Home") {
event.preventDefault();
go(0);
} else if (event.key === "End") {
event.preventDefault();
go(count - 1);
}
};
return (
<section className="bg-neutral-100 px-4 py-12 sm:px-6">
<div className="mx-auto max-w-2xl">
<div className="relative h-[470px] rounded-3xl border border-neutral-200 bg-white p-5 sm:p-6">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-base font-semibold text-neutral-900">Autumn lookbook, draft 3</p>
<p className="truncate text-sm text-neutral-600">Edited 12 minutes ago by Noor</p>
</div>
<div ref={rootRef} className="relative shrink-0">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-haspopup="menu"
aria-controls={menuId}
onClick={() => {
byUser.current = true;
setIdle(false);
setOpen((value) => !value);
}}
className="inline-flex h-11 items-center gap-2 rounded-xl bg-neutral-900 px-4 text-sm font-semibold text-white transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900 focus-visible:ring-offset-2"
>
File actions
<ChevronDown aria-hidden="true" className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : ""}`} />
</button>
<AnimatePresence>
{open && (
<motion.div
id={menuId}
role="menu"
aria-label="File actions"
onKeyDown={onKeyDown}
initial={reduce ? false : { scale: 0.85, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { scale: 0.85, opacity: 0, transition: { duration: 0.12 } }}
transition={{ duration: 0.15, ease: easeOut, delay: reduce ? 0 : OPENING_DELAY }}
className="absolute right-0 top-full z-10 mt-2 w-64 origin-top-right rounded-2xl bg-neutral-900 p-1.5 shadow-2xl shadow-neutral-900/30"
>
<div className="relative" onPointerLeave={() => setBox(null)}>
{/* The highlight is one element that slides to whichever item is hovered or focused. */}
<AnimatePresence>
{box && (
<motion.div
aria-hidden="true"
className="absolute inset-x-0 top-0 rounded-xl bg-white/10"
initial={{ y: box.top + 12, height: box.height, opacity: 0 }}
animate={{ y: box.top, height: box.height, opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 38 }}
/>
)}
</AnimatePresence>
{items.map((item, i) => (
<motion.button
key={item.key}
ref={(node) => {
itemRefs.current[i] = node;
}}
type="button"
role="menuitem"
onPointerEnter={() => {
setIdle(false);
highlight(i);
}}
onFocus={() => {
setIdle(false);
highlight(i);
}}
onClick={() => {
setLast(item.label);
close();
}}
initial={reduce ? false : { opacity: 0, scale: 0.8, y: -6 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.3, ease: easeOut, delay: reduce ? 0 : i * STAGGER + OPENING_DELAY }}
className={`relative z-10 flex h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70 ${
item.danger ? "mt-1.5 text-rose-300 before:absolute before:inset-x-2 before:-top-1 before:h-px before:bg-white/15" : "text-neutral-100"
}`}
>
<item.icon aria-hidden="true" className="h-4 w-4 shrink-0 opacity-80" />
<span className="flex-1">{item.label}</span>
{item.hint && <kbd className="font-sans text-xs font-normal text-neutral-400">{item.hint}</kbd>}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Placeholder page content under the menu. */}
<div aria-hidden="true" className="mt-6 grid grid-cols-3 gap-3">
{["bg-orange-200", "bg-amber-100", "bg-stone-200", "bg-rose-100", "bg-orange-100", "bg-stone-300"].map((tone, i) => (
<div key={i} className={`h-28 rounded-2xl sm:h-36 ${tone}`} />
))}
</div>
<p aria-live="polite" className="absolute bottom-5 left-5 text-sm text-neutral-600 sm:left-6">
Last action: <span className="font-semibold text-neutral-900">{last}</span>
</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/dropdown-menu-with-staggered-items-and-sliding-highlight.json
The highlight is a separate element behind the items. When an item is hovered or focused, the component reads its `offsetTop` and `offsetHeight` and animates the highlight to that position and size, so it travels instead of blinking from one item to the next.
The menu scales while it opens, and bounding rectangles are measured after transforms, so they would be slightly wrong during the animation. `offsetTop` is the layout position inside the list and is not affected by the scale.
Only until someone interacts. The idle glide is there so the preview shows the effect without a mouse, and it stops on the first hover, focus or click. Delete the effect that uses `idle` to remove it.
Not out of the box. For nested menus, typeahead and collision handling, pair these visuals with a headless menu primitive and keep the highlight logic, which only needs the active item's position.
Change `useState(true)` to `useState(false)`. It is open here so the preview shows what the component is.
Adapted from Staggered Card in Animata, used under its open-source licence. Licences and credits
Related: Command Palette with Spring Highlight and Live Filtering, Animated Tabs with Sliding Highlight and Crossfade, Dark Navbar with Dropdown Menu and Search Field.
More tabs and navigation or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.