Skip to main content
user@aqno:~$ cat tooltips-that-survive-a-fixed-sidebar.md

Tooltips That Survive a Fixed Sidebar

Written by Giuseppe Aquino
Published on: 2026-08-28
css, frontend, astro, ui

Centered absolute tooltips look fine until they meet an edge.

On this site the failure was more specific. Draft cross-links in a post carry a small “upcoming” tip. Near the left of the prose column the tip did not simply run off the screen — it slid under the fixed sidenav, or got chopped by an ancestor with overflow-x: hidden. One-sided CSS fixes just moved the clip to the other side.

The useful question was not “how do I center a tip?” It was: which rectangle is the tip allowed to live in?

Three jobs, one wrong boundary

A tip that stays readable has to do three things:

  1. Tether — stay attached to the trigger across scroll and layout.
  2. Collision — prefer a placement, then flip the axis and shift along the edge so the panel stays inside a chosen boundary.
  3. Paint escape — not sit under an ancestor that clips overflow, or the math is wasted.

CSS Anchor Positioning covers tether and collision when the collision domain is roughly the viewport. position-try-fallbacks with flip-inline / flip-block is the platform version of flip + shift. Mid-pane controls where the usable area ≈ the window are a good fit.

That is what LinkButton uses when a disabled control needs a hover explanation. The panel is named, scoped, and fixed to the trigger:

.tooltip-scope {
  anchor-scope: --aqno-tooltip;
}

.tooltip-trigger {
  anchor-name: --aqno-tooltip;
}

.tooltip-panel {
  position: fixed;
  position-anchor: --aqno-tooltip;
  position-area: block-start center;
  position-try-fallbacks:
    flip-inline,
    flip-block,
    flip-block flip-inline;
}

anchor-scope matters once more than one button shares the same custom name. Without it, every tip binds to the last trigger on the page.

When the viewport includes the chrome

aqno.dev’s layout is not a single full-bleed column. A fixed sidenav owns part of the viewport on large screens. position-try-fallbacks still keeps the tip inside 100vw — which includes that strip. Geometrically “on screen,” visually under the nav.

CSS does not yet give you boundary: #main-content for position-try. So for .upcoming-link tips I stopped asking the viewport to be the whole story.

The tip is a single element on document.body (no ancestor overflow). Placement lives in src/scripts/upcomingTooltips.ts: preferred above the trigger, flip below if that would leave the content pane, then clamp horizontally so the panel stays inside the pane with a little padding.

The boundary is not #main-content alone. On phones the visual viewport can disagree with innerWidth once browser chrome and safe areas show up. The script intersects the content pane with visualViewport and uses that rectangle for both placement and max-width.

function boundaryRect(): DOMRect {
  const main =
    document.querySelector("#main-content")?.getBoundingClientRect() ?? null;
  const vv = window.visualViewport;
  const view = vv
    ? new DOMRect(vv.offsetLeft, vv.offsetTop, vv.width, vv.height)
    : new DOMRect(0, 0, window.innerWidth, window.innerHeight);

  if (!main) return view;

  const left = Math.max(main.left, view.left);
  const top = Math.max(main.top, view.top);
  const right = Math.min(main.right, view.right);
  const bottom = Math.min(main.bottom, view.bottom);
  return new DOMRect(
    left,
    top,
    Math.max(0, right - left),
    Math.max(0, bottom - top),
  );
}

Flip + shift against that box is the same idea Floating UI packages as middleware. One consumer did not justify the dependency; the second one that needs arrows or autoUpdate probably will.

Two measurement details that burned real time:

  • Wrap-friendly triggers need getClientRects()[0], not only getBoundingClientRect(). The union box lies about where the first line actually sits.
  • Set max-width from the live boundary before measuring the panel, and force layout (offsetWidth) so clamp math does not run on width 0.

Paint and flex: the invisible siblings of placement

Flip math cannot save a tip that never paints.

If the tip is a descendant of overflow: hidden or overflow-x: hidden, the browser clips it even when the coordinates are “valid.” Portaling to body (or the top layer) is the escape hatch. Do not put overflow-x-hidden back on <main> without a new paint strategy.

Mobile had a second trap. A flex child defaults to min-width: auto, so <main class="flex-1"> refused to shrink below its content’s intrinsic width. The page scrolled horizontally. That looked like “tooltip clipped on the right” and was really the content pane refusing to get narrower. The layout fix is boring and permanent: min-w-0 on #main-content.

Touch has no hover. Fine pointers get pointerover / pointerout; touch gets tap-to-toggle and outside-tap dismiss. Driving show/hide only from hover leaves phones silent.

Decision table

Situation Choice
Mid-pane, boundary ≈ viewport CSS Anchor + position-try-fallbacks
Content pane ≠ viewport (sidenav, drawers) Body-level tip + flip/shift vs that pane
Need arrows / multi-ref / virtual anchors @floating-ui/dom
Pre-2026 engines only Floating UI / Popper

On aqno.dev both approaches coexist on purpose. LinkButton stays in CSS because its collision domain matches the viewport well enough. Upcoming draft links use the script because their boundary is the prose column, not the window chrome.

The tip that survives a fixed sidebar is the one that argues with the right rectangle.

EOF