June 28, 2026 · 6 min
A dropdown menu without a UI kit
Click outside, Escape, and a portal. The 80% of Headless UI’s Menu that you actually use.
- React
- TypeScript
- a11y
Try it outClick around. This is the real component.
Preview
1"use client";2 3import { useEffect, useId, useRef, useState } from "react";4import { createPortal } from "react-dom";5 6type Item = { id: string; label: string; onSelect: () => void };7 8export function Dropdown({ label, items }: { label: string; items: Item[] }) {9 const buttonId = useId();10 const menuId = useId();11 const rootRef = useRef<HTMLDivElement>(null);12 const buttonRef = useRef<HTMLButtonElement>(null);13 const [open, setOpen] = useState(false);14 const [coords, setCoords] = useState({ top: 0, left: 0 });15 16 const close = () => {17 setOpen(false);18 buttonRef.current?.focus();19 };20 21 const toggle = () => {22 const rect = buttonRef.current?.getBoundingClientRect();23 if (rect) {24 setCoords({ top: rect.bottom + 8, left: rect.left });25 }26 setOpen((value) => !value);27 };28 29 useEffect(() => {30 if (!open) return;31 32 const onPointerDown = (event: PointerEvent) => {33 const target = event.target as Node;34 if (rootRef.current?.contains(target)) return;35 if (buttonRef.current?.contains(target)) return;36 setOpen(false);37 };38 39 const onKeyDown = (event: KeyboardEvent) => {40 if (event.key === "Escape") close();41 };42 43 window.addEventListener("pointerdown", onPointerDown);44 window.addEventListener("keydown", onKeyDown);45 return () => {46 window.removeEventListener("pointerdown", onPointerDown);47 window.removeEventListener("keydown", onKeyDown);48 };49 }, [open]);50 51 return (52 <div ref={rootRef} className="inline-block">53 <button54 ref={buttonRef}55 type="button"56 id={buttonId}57 aria-haspopup="menu"58 aria-expanded={open}59 aria-controls={menuId}60 onClick={toggle}61 className="rounded-full border border-white/15 px-3 py-1.5 text-sm"62 >63 {label}64 </button>65 66 {open67 ? createPortal(68 <ul69 id={menuId}70 role="menu"71 aria-labelledby={buttonId}72 style={{ top: coords.top, left: coords.left }}73 className="fixed z-50 min-w-44 overflow-hidden rounded-xl border border-white/10 bg-zinc-950 py-1 shadow-2xl"74 >75 {items.map((item) => (76 <li key={item.id} role="none">77 <button78 type="button"79 role="menuitem"80 className="block w-full px-3 py-2 text-left text-sm hover:bg-white/5"81 onClick={() => {82 item.onSelect();83 close();84 }}85 >86 {item.label}87 </button>88 </li>89 ))}90 </ul>,91 document.body,92 )93 : null}94 </div>95 );96}A dropdown is a button that reveals a list, then gets out of the way. The traps: clicks on the page should close it, Escape should close it, and the menu should not be clipped by overflow: hidden on a parent card.
A portal solves clipping. A pointerdown listener on window solves clicks outside the menu. The rest is aria-expanded and aria-haspopup.
Usage
1"use client";2 3import { Dropdown } from "./dropdown";4 5export function ProjectMenu() {6 return (7 <Dropdown8 label="Actions"9 items={[10 { id: "copy", label: "Copy link", onSelect: () => navigator.clipboard.writeText(location.href) },11 { id: "open", label: "Open GitHub", onSelect: () => window.open("https://github.com", "_blank") },12 ]}13 />14 );15}Button vs menu vs select
If the user is picking one value that stays on the button, that is a select (or a combobox), not a menu. Menus perform actions. Mixing the two is how you end up with a “dropdown” that fights the platform. Keep this component for actions; use native <select> when you can.