typescriptintermediate

Portal Dropdown Component

Dropdown menu rendered via Portal to avoid overflow clipping, with click-outside dismiss.

typescript
import { useState, useRef, useEffect, ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface DropdownProps {
  trigger: ReactNode;
  children: ReactNode;
  align?: 'left' | 'right';
}

export function PortalDropdown({ trigger, children, align = 'left' }: DropdownProps) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState({ top: 0, left: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!open || !triggerRef.current) return;
    const rect = triggerRef.current.getBoundingClientRect();
    setPos({
      top: rect.bottom + window.scrollY + 4,
      left: align === 'right' ? rect.right + window.scrollX : rect.left + window.scrollX,
    });
  }, [open, align]);

  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (!menuRef.current?.contains(e.target as Node) && !triggerRef.current?.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, [open]);

  return (
    <>
      <div ref={triggerRef} onClick={() => setOpen(o => !o)}>{trigger}</div>
      {open && createPortal(
        <div ref={menuRef} className="absolute bg-[#111] border border-white/10 rounded-lg shadow-xl p-2 z-50"
          style={{ top: pos.top, left: pos.left, transform: align === 'right' ? 'translateX(-100%)' : undefined }}>
          {children}
        </div>,
        document.body
      )}
    </>
  );
}

Use Cases

  • Navigation menus
  • Context actions

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.