Home

Building a dependency-free tooltip

Positioning, animation, and accessibility without Floating UI or Popper.

Tooltip placements demo: hover triggers on top, bottom, left, and right
Placement demo — top, bottom, left, and right relative to the trigger.

Why build without a library?

Most tooltip libraries (react-laag, Floating UI, Popper.js) handle positioning for you, but they come with tradeoffs: added bundle weight, dependency maintenance risk, and styling systems that can be hard to override. Building your own gives you full control with surprisingly little code.

1. Trigger wrapper

Wrap the trigger in a container and attach mouse and focus handlers. onFocus / onBlur are required for keyboard accessibility — hover-only tooltips exclude keyboard users entirely.

<div
  ref={triggerRef}
  style={{ display: "inline-flex" }}
  onMouseEnter={showTooltip}
  onMouseLeave={hideTooltip}
  onFocus={showTooltip}
  onBlur={hideTooltip}
>
  {children}
</div>

2. Portal

Mount the tooltip bubble into document.body using a React portal.

import { createPortal } from "react-dom";

const Portal = ({ children }) => {
  const [mountNode, setMountNode] = useState(null);

  useEffect(() => setMountNode(document.body), []);

  if (!mountNode) return null;
  return createPortal(children, mountNode);
};

If the tooltip stays inside the trigger’s DOM subtree it can be:

  • Clipped by overflow: hidden on any ancestor
  • Obscured by a lower z-index stacking context
  • Mispositioned if an ancestor has transform, filter, or will-change

Portaling to body escapes all of these constraints, regardless of where the trigger lives on the page.

3. Positioning

Read the trigger’s position with getBoundingClientRect() and apply it as position: fixed. Use CSS transform with percentage units to center the tooltip — you never need to measure the tooltip’s own dimensions.

const rect = triggerRef.current.getBoundingClientRect();

// top
{
  position: "fixed",
  top: rect.top - offset,
  left: rect.left + rect.width / 2,
  transform: "translateX(-50%) translateY(-100%)",
}

// bottom
{
  position: "fixed",
  top: rect.bottom + offset,
  left: rect.left + rect.width / 2,
  transform: "translateX(-50%)",
}

// left
{
  position: "fixed",
  top: rect.top + rect.height / 2,
  left: rect.left - offset,
  transform: "translateX(-100%) translateY(-50%)",
}

// right
{
  position: "fixed",
  top: rect.top + rect.height / 2,
  left: rect.right + offset,
  transform: "translateY(-50%)",
}

Keep the position updated on scroll and resize:

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

  const update = () => {
    setRect(triggerRef.current.getBoundingClientRect());
  };

  window.addEventListener("scroll", update, true);
  window.addEventListener("resize", update);

  return () => {
    window.removeEventListener("scroll", update, true);
    window.removeEventListener("resize", update);
  };
}, [isVisible]);

4. Preventing pop-in with requestAnimationFrame

A double RAF defers visibility until the browser has had two full frames to settle. A single RAF fires before the next paint — the DOM update from setRect may not yet be reflected in layout. The second RAF guarantees the browser has had one complete opportunity to process it.

const showTooltip = () => {
  setRect(triggerRef.current.getBoundingClientRect());

  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      setIsVisible(true);
    });
  });
};

5. Show and hide

Do not conditionally render the tooltip with {isVisible && <Tooltip />} — no exit animation is possible. Render it once and toggle visibility with CSS. visibility: hidden is needed alongside opacity: 0 because an opacity-0 element still intercepts pointer events.

opacity: isVisible ? 1 : 0,
visibility: isVisible ? "visible" : "hidden",
transition: "opacity 150ms, transform 150ms, visibility 150ms cubic-bezier(.4, 0, .2, 1)",
pointerEvents: "none",

6. Animation

The tooltip slides toward the trigger as it fades out, and away from it as it fades in — no separate keyframes needed.

const slideOffset = isVisible ? 0 : -8;
const scale = isVisible ? "scale(1)" : "scale(0.95)";

// top
transform: `translateX(-50%) translateY(calc(-100% - ${slideOffset}px)) ${scale}`

// bottom
transform: `translateX(-50%) translateY(${slideOffset}px) ${scale}`

7. Enter and leave delays

Cancel the opposite timer so rapid hover does not flicker. Also cancel any pending RAF on hide.

const showTooltip = () => {
  if (leaveTimeout.current) clearTimeout(leaveTimeout.current);
  enterTimeout.current = setTimeout(() => setIsVisible(true), delayEnter);
};

const hideTooltip = () => {
  if (enterTimeout.current) clearTimeout(enterTimeout.current);
  if (rafRef.current) cancelAnimationFrame(rafRef.current);
  leaveTimeout.current = setTimeout(() => setIsVisible(false), delayLeave);
};

8. The arrow

Apply shadow on the container using filter: drop-shadow, not box-shadow. box-shadow does not follow the rotated arrow shape.

{
  position: "absolute",
  width: 10,
  height: 10,
  transform: "rotate(45deg)",
  borderRadius: 1,
  backgroundColor: "<tooltip-bg>",
  bottom: -5,
  left: "50%",
  marginLeft: -5,
}

filter: "drop-shadow(0 6px 10px rgba(0,0,0,0.1)) drop-shadow(0 2px 3px rgba(0,0,0,0.2))"

9. Accessibility

RequirementImplementation
Screen reader associationaria-describedby on the trigger, matching id on the tooltip (only when visible)
Rolerole="tooltip" on the bubble
Hidden statearia-hidden="true" when not visible
Keyboard dismissEscape keydown listener on document
Keyboard triggeronFocus / onBlur on the wrapper

Summary

DecisionWhy
No positioning libraryNo extra dependency to maintain
Portal to document.bodyEscapes overflow and stacking-context issues
position: fixed + CSS transformsNo need to measure the tooltip
Double RAF before showingPrevents pop-in at the wrong position
Keep in DOM, use opacity/visibilityExit animation always works
filter: drop-shadow on containerShadow follows the arrow shape