ESC

Type to search the knowledge base.

Smart Tooltip Positioning

Machine-coding brief for smart tooltips — flip/shift positioning, delay, hover/focus, and accessible naming.

intermediate5 min read
  • machine-coding
  • interview
  • react
  • a11y
  • positioning

Problem statement

Build a tooltip that positions itself around an anchor and flips/shifts when near viewport edges. Interviewers score measurement (getBoundingClientRect), portal rendering, hover/focus triggers, and not breaking accessibility (tooltips are not dialogs).

Requirements

Must have

  • Show tooltip on hover and keyboard focus of trigger
  • Hide on blur / pointer leave (with small delay to allow move into tooltip if interactive — usually tooltips are not interactive)
  • Preferred placement: top | bottom | left | right
  • Flip to opposite side if no room
  • Portal to document.body so overflow isn’t clipped
  • Accessible name: trigger gets aria-describedby pointing at tooltip id

Should have

  • Shift along axis to stay in viewport padding
  • Show delay (300ms) / hide delay (100ms)
  • Arrow pointer

Nice to have

  • Collision with multiple edges (use Floating UI algorithm verbally)
  • Touch long-press

Planning (5 minutes out loud)

  1. Non-interactive tooltip content only — if needs links, use popover/disclosure
  2. Measure trigger + tooltip + viewport
  3. Portal + fixed coordinates
  4. MVP — top placement + show/hide; then flip + shift
  5. Don’t use title attribute as the only solution

Architecture

Tooltip
├── Trigger (clone/wrapper)
└── Portal content (role=tooltip)

API

type Placement = "top" | "bottom" | "left" | "right";

type TooltipProps = {
  content: React.ReactNode;
  placement?: Placement;
  children: React.ReactElement;
  delayMs?: number;
};

Implementation sketch

Positioning math

type Coords = { top: number; left: number; placement: Placement };

function computePosition(
  trigger: DOMRect,
  tip: DOMRect,
  preferred: Placement,
  pad = 8
): Coords {
  const vw = window.innerWidth;
  const vh = window.innerHeight;

  const candidates: Placement[] = [
    preferred,
    opposite(preferred),
    ...(["top", "bottom", "left", "right"] as Placement[]).filter(
      (p) => p !== preferred && p !== opposite(preferred)
    ),
  ];

  for (const placement of candidates) {
    let top = 0;
    let left = 0;
    if (placement === "top") {
      top = trigger.top - tip.height - pad;
      left = trigger.left + (trigger.width - tip.width) / 2;
    } else if (placement === "bottom") {
      top = trigger.bottom + pad;
      left = trigger.left + (trigger.width - tip.width) / 2;
    } else if (placement === "left") {
      top = trigger.top + (trigger.height - tip.height) / 2;
      left = trigger.left - tip.width - pad;
    } else {
      top = trigger.top + (trigger.height - tip.height) / 2;
      left = trigger.right + pad;
    }

    // shift into viewport
    left = Math.min(Math.max(pad, left), vw - tip.width - pad);
    top = Math.min(Math.max(pad, top), vh - tip.height - pad);

    const fits =
      placement === "top"
        ? trigger.top - tip.height - pad >= 0
        : placement === "bottom"
          ? trigger.bottom + tip.height + pad <= vh
          : placement === "left"
            ? trigger.left - tip.width - pad >= 0
            : trigger.right + tip.width + pad <= vw;

    if (fits) return { top, left, placement };
  }

  // fallback last candidate with shift only
  return { top: pad, left: pad, placement: preferred };
}

function opposite(p: Placement): Placement {
  return { top: "bottom", bottom: "top", left: "right", right: "left" }[p];
}

Component core

function Tooltip({
  content,
  placement = "top",
  children,
  delayMs = 300,
}: TooltipProps) {
  const [open, setOpen] = useState(false);
  const [coords, setCoords] = useState<Coords | null>(null);
  const triggerRef = useRef<HTMLElement | null>(null);
  const tipRef = useRef<HTMLDivElement>(null);
  const tipId = useId();
  const showT = useRef<number>();
  const hideT = useRef<number>();

  function scheduleOpen() {
    window.clearTimeout(hideT.current);
    showT.current = window.setTimeout(() => setOpen(true), delayMs);
  }
  function scheduleClose() {
    window.clearTimeout(showT.current);
    hideT.current = window.setTimeout(() => setOpen(false), 100);
  }

  useLayoutEffect(() => {
    if (!open || !triggerRef.current || !tipRef.current) return;
    const next = computePosition(
      triggerRef.current.getBoundingClientRect(),
      tipRef.current.getBoundingClientRect(),
      placement
    );
    setCoords(next);
  }, [open, content, placement]);

  const trigger = React.cloneElement(children, {
    ref: (node: HTMLElement) => {
      triggerRef.current = node;
    },
    "aria-describedby": open ? tipId : undefined,
    onMouseEnter: scheduleOpen,
    onMouseLeave: scheduleClose,
    onFocus: scheduleOpen,
    onBlur: scheduleClose,
  });

  return (
    <>
      {trigger}
      {open &&
        createPortal(
          <div
            ref={tipRef}
            id={tipId}
            role="tooltip"
            style={{
              position: "fixed",
              top: coords?.top ?? -9999,
              left: coords?.left ?? -9999,
            }}
          >
            {content}
          </div>,
          document.body
        )}
    </>
  );
}

First paint may use offscreen measure (-9999) then layout effect places it — acceptable for interviews; or render hidden for measure.

Accessibility essentials

  • role="tooltip" + aria-describedby on trigger
  • Must work with keyboard focus, not hover only
  • Don’t put focusable controls inside tooltips
  • Escape can dismiss (optional APG)
  • Respect delayed show so pointer transit doesn’t flicker

Performance notes

  • Reposition on scroll/resize (capture scroll on window) while open
  • Throttle with requestAnimationFrame
  • Libraries (Floating UI) exist — OK to say you’d use them in prod

Footguns

  1. Overflow:hidden ancestors clipping without portal
  2. Hover-only tooltips excluding keyboard users
  3. Using title= with long delays and no styling control
  4. Interactive content in tooltip (use popover)
  5. Layout thrash measuring in tight loops without rAF

Interview out-loud answer

Tooltip content portals to body, is described by aria-describedby, and shows on focus and hover with delays. Positioning measures trigger and tooltip rects, tries preferred placement, flips, then shifts into the viewport. MVP is top+flip; arrow and scroll listeners next. For complex cases I’d use Floating UI rather than reimplementing middleware.

Further reading