Dropdown / Context Menu Portal
Dropdown / Context Menu Portal
Dropdowns in tables, cards, or sidebar panels are often clipped. A Portal renders them into
the body and positions them precisely below the trigger button using the same
getBoundingClientRect() technique.
Dropdown.jsx
import { createPortal } from 'react-dom';
import { useState, useRef, useEffect } from 'react';
function Dropdown({ label, options, onSelect }) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const buttonRef = useRef(null);
const dropdownRef = useRef(null);
function toggle() {
if (!open) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX
});
}
setOpen(prev => !prev);
}
// Close when clicking outside
useEffect(() => {
function handleClick(e) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!buttonRef.current.contains(e.target)
) {
setOpen(false);
}
}
if (open) {
document.addEventListener('mousedown', handleClick);
}
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
return (
<>
<button ref={buttonRef} onClick={toggle} aria-haspopup="listbox" aria-expanded={open}>
{label}
</button>
{open && createPortal(
<ul
ref={dropdownRef}
role="listbox"
style={{
position: 'absolute',
top: position.top,
left: position.left,
background: '#fff',
border: '1px solid #ccc',
listStyle: 'none',
margin: 0,
padding: 0,
zIndex: 9999,
minWidth: '160px'
}}
>
{options.map((opt, i) => (
<li
key={i}
role="option"
style={{ padding: '8px 12px', cursor: 'pointer' }}
onClick={() => { onSelect(opt); setOpen(false); }}
>
{opt}
</li>
))}
</ul>,
document.body
)}
</>
);
}
export default Dropdown;
Usage
<Dropdown
label="Select Option"
options={['Edit', 'Duplicate', 'Delete']}
onSelect={(opt) => console.log('Selected:', opt)}
/>
