Clipped tooltips: portals, the top layer and CSS anchors

Comparison Last tested 24 October 2026

Identify the ancestor that clips the tooltip, then choose whether to move its node outside that ancestor or render it in the browser’s top layer. CSS anchor positioning can place the tooltip relative to its trigger; opening, persistence and dismissal still need their own state.

Separate placement from interaction

A tooltip has two jobs here. Placement determines where its border and text appear relative to the trigger and viewport. Interaction state determines why it opens, what keeps it open and what dismisses it. Keep these responsibilities separate so a correct position cannot conceal a stale dismissal flag.

The comparison contains four original cases with the same opening rules: a deliberately clipped baseline, a body portal, a manual top-layer popover and a top-layer popover using CSS anchor positioning. The descriptive content contains no interactive control. That keeps the comparison focused on placing a description rather than comparing four different tasks.

overflow: hidden clips overflow at the element’s padding box 1. The baseline places a large description beyond a small box with that property. It also has a high stacking value. The important boundary is the clipping ancestor; increasing a stacking value is not the placement strategy used by the other three cases.

Move the node into a body portal

The portal case appends the description to the document body and uses fixed positioning. It reads the trigger’s getBoundingClientRect() in viewport coordinates 2. The placement function chooses above or below from the available space, clamps the horizontal position and runs again on scroll or resize.

Moving the node does not remove its ownership problem. Keep the description ID, trigger reference, pointer state and focus state together. The component still has to close the description and release resources when it is removed. Inherited styling and transformed ancestors are integration concerns even when the original clipping box is no longer its parent.

A body portal trades the clipping relationship for explicit placement work. This implementation owns window listeners and a visual layer outside the trigger’s immediate subtree. It has no destruction API: removing the trigger alone would leave those resources behind. Add and separately test cleanup before using this pattern in a component that repeatedly mounts and unmounts.

Put the description in the top layer

showPopover() adds an element to the top layer; Popover API popovers are non-modal 3. The second replacement uses a manual popover. The same custom focus, hover and Escape rules remain responsible for its lifetime.

This case deliberately keeps the small JavaScript positioning function. Rendering layer and placement system are separate decisions: the top layer supplies one, while the trigger rectangle and available-space calculation supply the other. A popover method does not by itself specify the description’s location or interaction contract.

The component checks for the relevant method before enabling this case. There is no polyfill or external service. An unavailable method leaves the case unavailable instead of silently switching it to a different implementation. Keep that distinction when adding a fallback to an application.

Use CSS to attach the position

CSS anchor positioning associates an element’s position or size with another element and provides alternate positions 4. The anchored case combines that system with a top-layer popover. It declares one named anchor and alternate block and inline directions.

The source checks anchor-related syntax before enabling the case. That check permits an attempt; it is not a substitute for inspecting the resulting position. The relationship still needs the correct anchor name, suitable available space and a useful placement for the surrounding task.

JavaScript continues to own interaction state. CSS does not decide whether Escape dismissed the description or whether pointer hover remains active. Keeping one opening model across the cases makes a positioning problem easier to distinguish from an event-state problem.

The component

Original code, MIT licensed. Save this HTML locally. It contains the four cases, their shared interaction state and placement code; each case has its own trigger and description.

<!doctype html>
<html lang="en">
  <head>
    <!-- SPDX-License-Identifier: MIT -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Four tooltip placement approaches</title>
    <style>
      body { font: 1rem/1.5 system-ui; margin: 1rem; }
      .case { margin-block: 2rem 7rem; }
      .clip { position: relative; width: 300px; max-width: 100%; height: 64px; overflow: hidden; border: 3px dashed #963b27; }
      button { font: inherit; margin: .5rem; }
      .bubble { box-sizing: border-box; width: 260px; padding: .75rem; color: white; background: #123f59; border: 3px solid #e7bd55; }
      .local { position: absolute; left: 40px; top: 44px; z-index: 9999; }
      .portal, .layer, .anchor { position: fixed; inset: auto; margin: 0; }
      #anchor-button { anchor-name: --description; }
      .anchor { position-anchor: --description; top: anchor(bottom); left: anchor(left); position-try-fallbacks: flip-block, flip-inline; }
      [hidden] { display: none; }
      :focus-visible { outline: 3px solid #a43b00; outline-offset: 2px; }
    </style>
  </head>
  <body>
    <div class="case clip" data-kind="local">
      <button aria-describedby="local-tip">Clipped baseline</button>
      <span id="local-tip" class="bubble local" role="tooltip" hidden>Configuration detail: this description crosses the container edge.</span>
    </div>
    <div class="case clip" data-kind="portal">
      <button aria-describedby="portal-tip">Body portal</button>
      <span id="portal-tip" class="bubble portal" role="tooltip" hidden>Configuration detail: this description crosses the container edge.</span>
    </div>
    <div class="case clip" data-kind="layer">
      <button aria-describedby="layer-tip">Top-layer popover</button>
      <span id="layer-tip" class="bubble layer" role="tooltip" popover="manual" hidden>Configuration detail: this description crosses the container edge.</span>
    </div>
    <div class="case clip" data-kind="anchor">
      <button id="anchor-button" aria-describedby="anchor-tip">Anchored popover</button>
      <span id="anchor-tip" class="bubble anchor" role="tooltip" popover="manual" hidden>Configuration detail: this description crosses the container edge.</span>
    </div>
    <script>
      for (const box of document.querySelectorAll(".case")) {
        const kind = box.dataset.kind;
        const trigger = box.querySelector("button");
        const tip = box.querySelector(".bubble");
        const topLayer = kind === "layer" || kind === "anchor";
        if (kind === "portal") document.body.append(tip);
        if ((topLayer && !tip.showPopover) || (kind === "anchor" && !CSS.supports("top: anchor(bottom)"))) {
          trigger.disabled = true;
          continue;
        }
        const open = {focus: false, trigger: false, content: false};
        let dismissed = false;
        let timer;
        function place() {
          if (tip.hidden || !["portal", "layer"].includes(kind)) return;
          const rect = trigger.getBoundingClientRect();
          const width = tip.offsetWidth;
          const height = tip.offsetHeight;
          const below = rect.bottom + 6;
          tip.style.left = `${Math.max(6, Math.min(rect.left, innerWidth - width - 6))}px`;
          tip.style.top = `${below + height < innerHeight ? below : rect.top - height - 6}px`;
        }
        function render() {
          clearTimeout(timer);
          const reason = open.focus || open.trigger || open.content;
          if (!reason) dismissed = false;
          const show = reason && !dismissed;
          if (!show && topLayer && tip.matches(":popover-open")) tip.hidePopover();
          tip.hidden = !show;
          if (show && topLayer && !tip.matches(":popover-open")) tip.showPopover();
          place();
        }
        trigger.addEventListener("focus", () => { open.focus = true; render(); });
        trigger.addEventListener("blur", () => {
          open.focus = false;
          if (!open.trigger && !open.content) dismissed = false;
          render();
        });
        for (const [element, key] of [[trigger, "trigger"], [tip, "content"]]) {
          element.addEventListener("pointerenter", event => {
            if (event.pointerType === "touch") return;
            open[key] = true;
            render();
          });
          element.addEventListener("pointerleave", () => {
            open[key] = false;
            clearTimeout(timer);
            timer = setTimeout(render, 150);
          });
        }
        document.addEventListener("keydown", event => {
          if (event.key === "Escape" && !tip.hidden) {
            dismissed = true;
            render();
          }
        });
        window.addEventListener("scroll", place, true);
        window.addEventListener("resize", place);
      }
    </script>
  </body>
</html>

Walk through the awkward cases

Compare the clipped baseline with the other cases in the component printed above; the separate instrumented demo provides additional controls. Keep the same descriptive task while changing its placement:

Migration

Replace the clipping relationship and placement work your application actually needs. Keep one description ID and one interaction model when moving from a nested tooltip to a portal or top-layer implementation. This original comparison reuses no former plugin or article and supplies no compatibility layer for old options.

Not covered on this page

Sources

  1. MDN, overflow, Living documentation. https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow (opened 24 October 2026). ↩

  2. MDN, Element.getBoundingClientRect(), Living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect (opened 24 October 2026). ↩

  3. MDN, Popover API, Living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Popover_API (opened 24 October 2026). ↩

  4. MDN, CSS anchor positioning, Living documentation. https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning (opened 24 October 2026). ↩

Code on this page is original and MIT licensed.

How we test: methodology. Found a mistake? Corrections.