Skip to content
All notes

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.

dropdown.tsx

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 <button
54 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 {open
67 ? createPortal(
68 <ul
69 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 <button
78 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

example.tsx
1"use client";
2
3import { Dropdown } from "./dropdown";
4
5export function ProjectMenu() {
6 return (
7 <Dropdown
8 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.