Overlay

Popover

Floating panel with a custom trigger

Preview

Anything goes here. Text, a control or a list.

side
align

Code

import css from "./Popover.module.css";
import {
  cloneElement,
  isValidElement,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";

type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";

interface TriggerProps {
  ref?: React.Ref<HTMLElement>;
  onClick?: (ev: React.MouseEvent<HTMLElement>) => void;
  "aria-expanded"?: boolean;
  "aria-controls"?: string;
}

interface PopoverProps {
  trigger: React.ReactElement<TriggerProps>;
  side: Side;
  align: Align;
  children: React.ReactNode | ((close: () => void) => React.ReactNode);
}

export default function Popover({
  trigger,
  side,
  align,
  children,
}: PopoverProps) {
  const popover = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLElement>(null);
  const content = useRef<HTMLDivElement>(null);

  const contentId = useId();

  const [open, setOpen] = useState(false);
  const [flipSide, setFlipSide] = useState<Side>(side);
  const [shift, setShift] = useState(0);
  const [placed, setPlaced] = useState(false);

  const toggle = () => setOpen((open) => !open);

  // Closing from inside (an item was chosen) hands focus back to the trigger:
  // the content goes hidden, and a focused item would drop it to the body.
  const close = () => {
    if (content.current?.contains(document.activeElement)) {
      triggerRef.current?.focus();
    }
    setOpen(false);
  };

  useLayoutEffect(() => {
    if (!open) {
      setPlaced(false);
      return;
    }

    if (!triggerRef.current || !content.current) return;

    const rect = triggerRef.current.getBoundingClientRect();
    const { width, height } = content.current.getBoundingClientRect();
    const { clientWidth: viewWidth, clientHeight: viewHeight } = document.documentElement;
    const gutter = parseFloat(getComputedStyle(content.current).getPropertyValue("--page-gutter"));

    const spaceAbove = rect.top;
    const spaceBelow = viewHeight - rect.bottom;
    const spaceLeft = rect.left;
    const spaceRight = viewWidth - rect.right;

    let finalSide = side;
    if (side === "bottom" && spaceBelow < height && spaceAbove > spaceBelow) {
      finalSide = "top";
    } else if (side === "top" && spaceAbove < height && spaceBelow > spaceAbove) {
      finalSide = "bottom";
    } else if (side === "right" && spaceRight < width && spaceLeft > spaceRight) {
      finalSide = "left";
    } else if (side === "left" && spaceLeft < width && spaceRight > spaceLeft) {
      finalSide = "right";
    }

    const across = finalSide === "top" || finalSide === "bottom";
    const size = across ? Math.max(width, rect.width) : height;
    const [from, to] = across ? [rect.left, rect.right] : [rect.top, rect.bottom];
    const start =
      align === "start"
        ? from
        : align === "end"
          ? to - size
          : (from + to - size) / 2;
    const room = (across ? viewWidth : viewHeight) - gutter - size;

    setFlipSide(finalSide);
    setShift(Math.min(Math.max(start, gutter), Math.max(room, gutter)) - start);
    setPlaced(true);
  }, [open]);

  useEffect(() => {
    if (!open) return;

    const onOutside = (ev: Event) => {
      if (popover.current && !popover.current.contains(ev.target as Node)) setOpen(false);
    };

    const onEscape = (ev: KeyboardEvent) => {
      if (ev.key === "Escape") {
        setOpen(false);
        triggerRef.current?.focus();
      }
    };

    document.addEventListener("click", onOutside);
    document.addEventListener("keydown", onEscape);
    document.addEventListener("focusin", onOutside);

    return () => {
      document.removeEventListener("click", onOutside);
      document.removeEventListener("keydown", onEscape);
      document.removeEventListener("focusin", onOutside);
    };
  }, [open]);

  return (
    <div
      ref={popover}
      className={`
        ${css.popover}
        ${css[`side_${flipSide}`]}
        ${css[`align_${align}`]}
        ${open ? css.open : ""}
        ${open && !placed ? css.measuring : ""}
      `}
    >
      {isValidElement(trigger) &&
        cloneElement(trigger, {
          ref: triggerRef,
          onClick: (ev: React.MouseEvent<HTMLElement>) => {
            trigger.props.onClick?.(ev);
            toggle();
          },
          "aria-expanded": open,
          "aria-controls": contentId,
        })}
      <div
        ref={content}
        id={contentId}
        className={css.popover_content}
        style={{ "--popover-shift": `${shift}px` } as React.CSSProperties}
      >
        {typeof children === "function" ? children(close) : children}
      </div>
    </div>
  );
}

Props

PropTypeRequiredDescription
triggerReact.ReactElementrequiredTrigger element; the Popover injects click, ref and aria (expanded, controls) into it.
side"top" | "bottom" | "left" | "right"requiredOpening side; it flips only when space runs out.
align"start" | "center" | "end"requiredPanel alignment relative to the trigger; if it would leave the screen, it slides in to stay --page-gutter from the edge.
childrenReact.ReactNode | ((close: () => void) => React.ReactNode)requiredPanel content. As a function it receives close() to dismiss on select.