Clipped tooltips: portals, the top layer and CSS anchors
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:
- The border crosses the container edge. The baseline remains a child of the clipping box. Its high stacking value does not change that relationship. Find the ancestor before changing the stacking order again.
- Focus opens the body portal. The opening reason triggers a rectangle read and a fixed position. The description stays associated with its trigger even though its node belongs to the body.
- The same description enters the top layer. The manual popover changes the rendering layer while retaining the placement calculation. The anchor version delegates that calculation to CSS.
- Escape is pressed before another case opens. Dismiss only an open description. Marking an unopened case dismissed would suppress its next legitimate opening, a state error this handler avoids by checking visibility first.
- Focus leaves without either pointer zone active. Clear dismissal independently of the delayed pointer-close timer. A fresh focus interaction can then start without waiting for an unrelated delay.
- The pointer leaves the content. After neither hover zone nor trigger focus remains active, the delayed render closes the description. Removal still needs a separate cleanup path for the listeners and detached nodes.
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
- Full component destruction or memory-leak detection.
- A portal solution for every transformed or scrolling ancestor.
- Interactive menus, modal tasks or controls inside tooltips.
- Accessibility certification or universal browser coverage.
Sources
-
MDN, overflow, Living documentation. https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/overflow (opened 24 October 2026). ↩
-
MDN, Element.getBoundingClientRect(), Living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect (opened 24 October 2026). ↩
-
MDN, Popover API, Living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Popover_API (opened 24 October 2026). ↩
-
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.