Web InnoventixFreeCode

Dropdown Menu

Original · free

basic dropdown menu with items

byWeb InnoventixReact + Tailwind
menudropdownmenus
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/menu-dropdown.json
menu-dropdown.tsx
"use client";

import {
  Fragment,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type MenuAction = {
  id: string;
  label: string;
  shortcut?: string;
  icon: ReactNode;
  tone?: "default" | "danger";
};

const iconClass = "h-4 w-4 shrink-0";

const ShareIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <circle cx="18" cy="5" r="3" />
    <circle cx="6" cy="12" r="3" />
    <circle cx="18" cy="19" r="3" />
    <path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4" />
  </svg>
);

const DuplicateIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <rect x="9" y="9" width="11" height="11" rx="2" />
    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
  </svg>
);

const RenameIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <path d="M12 20h9" />
    <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
  </svg>
);

const MoveIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z" />
    <path d="M12 15V9M9.5 11.5 12 9l2.5 2.5" />
  </svg>
);

const ExportIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <path d="M12 3v12" />
    <path d="m7 12 5 5 5-5" />
    <path d="M5 21h14" />
  </svg>
);

const HistoryIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
    <path d="M3 3v5h5" />
    <path d="M12 8v4l3 2" />
  </svg>
);

const TrashIcon = (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
    <path d="M3 6h18" />
    <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
    <path d="M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6" />
    <path d="M10 11v6M14 11v6" />
  </svg>
);

const groups: MenuAction[][] = [
  [
    { id: "share", label: "Share", shortcut: "⇧⌘S", icon: ShareIcon },
    { id: "duplicate", label: "Duplicate", shortcut: "⌘D", icon: DuplicateIcon },
    { id: "rename", label: "Rename", shortcut: "F2", icon: RenameIcon },
  ],
  [
    { id: "move", label: "Move to folder", icon: MoveIcon },
    { id: "export", label: "Export as PDF", shortcut: "⌘E", icon: ExportIcon },
    { id: "history", label: "Version history", icon: HistoryIcon },
  ],
  [
    { id: "delete", label: "Delete project", shortcut: "⌫", icon: TrashIcon, tone: "danger" },
  ],
];

const flatItems = groups.flat();
const groupStarts = groups.reduce<number[]>((acc, _group, i) => {
  acc.push(i === 0 ? 0 : acc[i - 1] + groups[i - 1].length);
  return acc;
}, []);

export default function MenuDropdown() {
  const reduce = useReducedMotion();
  const uid = useId();
  const buttonId = `${uid}-trigger`;
  const menuId = `${uid}-menu`;

  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const [lastAction, setLastAction] = useState<string | null>(null);

  const buttonRef = useRef<HTMLButtonElement | null>(null);
  const menuRef = useRef<HTMLDivElement | null>(null);
  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const typeahead = useRef<{ str: string; at: number }>({ str: "", at: 0 });

  useEffect(() => {
    if (!open) return;
    itemRefs.current[activeIndex]?.focus();
  }, [open, activeIndex]);

  useEffect(() => {
    if (!open) return;
    function onPointerDown(event: PointerEvent) {
      const target = event.target as Node | null;
      if (!target) return;
      if (menuRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
      setOpen(false);
    }
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  function openMenu(edge: "first" | "last") {
    setActiveIndex(edge === "first" ? 0 : flatItems.length - 1);
    setOpen(true);
  }

  function closeMenu(returnFocus: boolean) {
    setOpen(false);
    if (returnFocus) {
      requestAnimationFrame(() => buttonRef.current?.focus());
    }
  }

  function moveFocus(next: number) {
    const count = flatItems.length;
    const idx = ((next % count) + count) % count;
    setActiveIndex(idx);
    itemRefs.current[idx]?.focus();
  }

  function focusByChar(char: string) {
    const now = Date.now();
    const str = now - typeahead.current.at > 600 ? char : typeahead.current.str + char;
    typeahead.current = { str, at: now };
    const query = str.toLowerCase();
    const count = flatItems.length;
    const start = str.length > 1 ? activeIndex : activeIndex + 1;
    for (let i = 0; i < count; i++) {
      const idx = (start + i) % count;
      if (flatItems[idx].label.toLowerCase().startsWith(query)) {
        moveFocus(idx);
        return;
      }
    }
  }

  function onSelect(item: MenuAction) {
    setLastAction(item.label);
    closeMenu(true);
  }

  function onButtonKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
    if (event.key === "ArrowDown") {
      event.preventDefault();
      openMenu("first");
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      openMenu("last");
    }
  }

  function onMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        moveFocus(activeIndex + 1);
        break;
      case "ArrowUp":
        event.preventDefault();
        moveFocus(activeIndex - 1);
        break;
      case "Home":
        event.preventDefault();
        moveFocus(0);
        break;
      case "End":
        event.preventDefault();
        moveFocus(flatItems.length - 1);
        break;
      case "Escape":
        event.preventDefault();
        closeMenu(true);
        break;
      case "Tab":
        setOpen(false);
        break;
      default:
        if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
          event.preventDefault();
          focusByChar(event.key);
        }
    }
  }

  const menuMotion = reduce
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0 },
        transition: { duration: 0.12 },
      }
    : {
        initial: { opacity: 0, y: -8, scale: 0.96 },
        animate: { opacity: 1, y: 0, scale: 1 },
        exit: { opacity: 0, y: -6, scale: 0.97 },
        transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] as const },
      };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 sm:py-28">
      <style>{`
        @keyframes menudd-ping {
          0% { transform: scale(1); opacity: 0.5; }
          80%, 100% { transform: scale(2.4); opacity: 0; }
        }
        .menudd-ping { animation: menudd-ping 2.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
        @keyframes menudd-rise {
          from { opacity: 0; transform: translateY(6px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .menudd-rise { animation: menudd-rise 0.4s ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .menudd-ping, .menudd-rise { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(99,102,241,0.10),transparent)] dark:bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(129,140,248,0.14),transparent)]"
      />

      <div className="relative mx-auto w-full max-w-md">
        <p className="menudd-rise mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
          Workspace
        </p>

        <div className="menudd-rise rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/20">
          <div className="flex items-start justify-between gap-4">
            <div className="flex min-w-0 items-center gap-3">
              <div className="relative grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">
                Q3
                <span className="absolute -right-0.5 -top-0.5 grid h-3.5 w-3.5 place-items-center">
                  <span className="menudd-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
                  <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-zinc-900" />
                </span>
              </div>
              <div className="min-w-0">
                <h2 className="truncate text-base font-semibold text-zinc-900 dark:text-zinc-50">
                  Q3 launch proposal
                </h2>
                <p className="truncate text-sm text-zinc-500 dark:text-zinc-400">
                  Edited 12 minutes ago
                </p>
              </div>
            </div>

            <div className="relative shrink-0">
              <button
                ref={buttonRef}
                id={buttonId}
                type="button"
                aria-haspopup="menu"
                aria-expanded={open}
                aria-controls={open ? menuId : undefined}
                onClick={() => (open ? closeMenu(false) : openMenu("first"))}
                onKeyDown={onButtonKeyDown}
                className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:focus-visible:ring-offset-zinc-900"
              >
                Options
                <svg
                  viewBox="0 0 24 24"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth={2}
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  aria-hidden="true"
                  className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : ""}`}
                >
                  <path d="m6 9 6 6 6-6" />
                </svg>
              </button>

              <AnimatePresence>
                {open && (
                  <motion.div
                    key="menu"
                    ref={menuRef}
                    id={menuId}
                    role="menu"
                    aria-orientation="vertical"
                    aria-labelledby={buttonId}
                    onKeyDown={onMenuKeyDown}
                    initial={menuMotion.initial}
                    animate={menuMotion.animate}
                    exit={menuMotion.exit}
                    transition={menuMotion.transition}
                    style={{ transformOrigin: "top right" }}
                    className="absolute right-0 z-20 mt-2 w-72 rounded-xl border border-zinc-200 bg-white p-1.5 shadow-xl shadow-zinc-900/10 ring-1 ring-black/5 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/40 dark:ring-white/5"
                  >
                    {groups.map((group, gi) => (
                      <Fragment key={group[0].id}>
                        {gi > 0 && (
                          <div role="separator" className="mx-1 my-1 h-px bg-zinc-200 dark:bg-zinc-800" />
                        )}
                        {group.map((item, li) => {
                          const index = groupStarts[gi] + li;
                          const danger = item.tone === "danger";
                          return (
                            <button
                              key={item.id}
                              ref={(el) => {
                                itemRefs.current[index] = el;
                              }}
                              type="button"
                              role="menuitem"
                              tabIndex={index === activeIndex ? 0 : -1}
                              onClick={() => onSelect(item)}
                              onMouseEnter={() => setActiveIndex(index)}
                              className={`group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset ${
                                danger
                                  ? "text-rose-600 hover:bg-rose-50 focus-visible:ring-rose-500 dark:text-rose-400 dark:hover:bg-rose-950/40"
                                  : "text-zinc-700 hover:bg-zinc-100 focus-visible:ring-indigo-500 dark:text-zinc-200 dark:hover:bg-zinc-800"
                              }`}
                            >
                              <span
                                className={
                                  danger
                                    ? "text-rose-500 dark:text-rose-400"
                                    : "text-zinc-400 group-hover:text-indigo-500 dark:text-zinc-500 dark:group-hover:text-indigo-400"
                                }
                              >
                                {item.icon}
                              </span>
                              <span className="flex-1 font-medium">{item.label}</span>
                              {item.shortcut && (
                                <kbd
                                  className={`rounded border px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${
                                    danger
                                      ? "border-rose-200 text-rose-500 dark:border-rose-900/60 dark:text-rose-400/80"
                                      : "border-zinc-200 text-zinc-400 dark:border-zinc-700 dark:text-zinc-500"
                                  }`}
                                >
                                  {item.shortcut}
                                </kbd>
                              )}
                            </button>
                          );
                        })}
                      </Fragment>
                    ))}
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </div>

          <div className="mt-5 flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800">
            <span className="text-sm text-zinc-500 dark:text-zinc-400">Last action</span>
            <span
              aria-live="polite"
              className={`text-sm font-semibold ${
                lastAction ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-400 dark:text-zinc-600"
              }`}
            >
              {lastAction ?? "None yet"}
            </span>
          </div>
        </div>

        <p className="menudd-rise mt-4 text-center text-xs text-zinc-500 dark:text-zinc-500">
          Open the menu, then use{" "}
          <kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">↑</kbd>{" "}
          <kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">↓</kbd> to move,{" "}
          <kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">Enter</kbd> to select,{" "}
          <kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">Esc</kbd> to close.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →