Tooltips Without Libraries: Positioning, Arrow, and ARIA-Friendly Patterns

Was this helpful?

Tooltips are the UI equivalent of “just a tiny change.” You add a question-mark icon, slap some text on hover, ship it, and then production teaches you what you forgot: scrolling containers, clipped overflow, mobile taps, z-index hell, and keyboard users who can’t hover.

This is the pragmatic path: build tooltips without a library, but with library-level rigor. If your tooltip can survive a sticky header, a transformed parent, a modal, a 200% zoom, and a screen reader, it can survive your next redesign.

What a tooltip is (and what it is not)

A tooltip is supplemental information tied to a control or inline element. It should clarify, not carry critical meaning. If hiding the tooltip makes the UI unusable, you didn’t build a tooltip—you built a broken form label.

Tooltips are also not popovers, dialogs, or toast notifications:

  • Tooltip: small, ephemeral, anchored to a target, usually no interactive content.
  • Popover: anchored like a tooltip but can contain interactive UI (links, buttons, form fields). Needs richer keyboard handling.
  • Dialog: modal or non-modal, takes focus, has a backdrop (often), and must be dismissible via keyboard.

Decide this early, because it controls everything: ARIA roles, focus behavior, dismissal, and whether you can use hover-only triggers.

Opinion: If the content contains a link, checkbox, or “Copy” button, stop calling it a tooltip. You’re building a popover; treat it like one.

Facts and history worth knowing

  • Fact 1: The HTML title attribute was the original “free tooltip,” but it’s inconsistent across browsers, hard to style, and unreliable for accessibility.
  • Fact 2: Tooltips are one of the earliest UI patterns inherited from desktop GUIs (think classic Windows help balloons), then awkwardly transplanted into the web.
  • Fact 3: Early web tooltips often used onmouseover and document.write. We survived that era. Barely.
  • Fact 4: The “z-index problem” is older than many front-end frameworks; stacking contexts were biting teams long before “component” was a job title.
  • Fact 5: ARIA guidance for tooltips matured later than guidance for dialogs and buttons, which is why you still see mismatched patterns in production apps.
  • Fact 6: Touch interfaces forced tooltips to grow up: hover doesn’t exist on most phones, so your “simple hover hint” becomes a design decision.
  • Fact 7: The push toward “portal to body” patterns came from real layout failures: overflow clipping, transforms, and nested scroll containers made inline tooltips fragile.
  • Fact 8: Modern browsers introduced primitives that help (like ResizeObserver), but they also introduced new landmines (like “contain” and pervasive transforms).

Non-negotiable requirements for production tooltips

Before code, write down what “works” means. Here’s the bar I use in production:

1) It must be discoverable without hover

Keyboard users should trigger tooltips on focus. That usually means the trigger is focusable (<button>, <a>, or tabindex="0" as a last resort).

2) It must not steal focus

Tooltips are non-interactive. Focus stays on the trigger. If the tooltip needs interaction, upgrade it to a popover with a focus-management plan.

3) It must not cause layout shifts

No pushing content around. Tooltips should be positioned overlays. CLS is not just for marketing pages; internal dashboards get judged too—by angry humans.

4) It must survive scroll, zoom, and resize

Not “kinda.” Not “most of the time.” Users scroll while hovering, trackpads exist, and zoom is common in enterprise environments.

5) It must not be clipped

Overflow-hidden containers, transformed ancestors, and scrollable panes are where naive tooltips go to die.

6) It must not block the thing you’re trying to use

Tooltips that cover their trigger create flicker loops and user rage. You need sensible offset, pointer-event handling, and delayed close.

7) It must be testable

If your tooltip can’t be selected reliably in tests, it’ll be “fixed” by someone with a hammer. Provide stable attributes (like data-tooltip-id) and deterministic behavior.

Joke #1: A tooltip that blocks the button is like a safety sign welded over the emergency exit. Technically present, practically cruel.

Architecture: inline vs portal, and why “position: absolute” isn’t enough

There are two sane architectures when you refuse libraries:

A) Inline tooltip (same DOM subtree)

You render the tooltip near the trigger and position with CSS (position: absolute) inside a relatively positioned container.

Pros: simple, fewer DOM operations, predictable CSS if the container is stable.

Cons: clipped by overflow: hidden/auto; weirdness with stacking contexts; nested transforms can do surprising things.

B) Portaled tooltip (rendered under document.body)

You render the tooltip in a top-level overlay layer (often a dedicated <div id="overlays">) and position it using viewport coordinates. This is what mature libraries do because browsers and layout are not sentimental.

Pros: avoids clipping by local overflow; easier z-index management; consistent placement in modals and drawers.

Cons: you must compute coordinates; you must track scroll/resize; you must handle containing blocks and visual viewport on mobile.

Decision: If you have any scrollable panels or modals (you do), default to a portal. Inline tooltips are fine for static pages, docs, and small internal tools where clipping risk is low.

Stacking contexts: why your z-index “does nothing”

Tooltips fail quietly when they appear behind something. Typical culprits:

  • A parent with transform, filter, opacity < 1, contain, or isolation creating a new stacking context.
  • Positioned elements with their own z-index layers.
  • Fixed headers and sticky elements with high z-index.

Portal-based tooltips sidestep most of these by living in a known top layer. But even then, your overlay layer must itself be above other UI chrome.

Positioning algorithm: measure, place, flip, shift, clamp

Here’s the reliable mental model: every tooltip placement is a negotiation between the trigger’s rectangle, the tooltip’s size, the viewport, and your preferred side.

The minimum viable algorithm

  1. Measure the trigger: targetRect = target.getBoundingClientRect().
  2. Measure the tooltip: render it offscreen or hidden, then read tooltipRect.
  3. Place it on the preferred side with an offset (e.g., 8px).
  4. Flip if it would overflow (e.g., top doesn’t fit, so place bottom).
  5. Shift along the cross-axis to keep it inside the viewport (e.g., nudge left/right).
  6. Clamp final coordinates so it stays visible, with some padding from edges.

That’s it. Everything else is bug fixes wearing a trench coat.

Viewport considerations: layout viewport vs visual viewport

Mobile browsers complicate “viewport.” When the on-screen keyboard is up or the page is zoomed, the visual viewport can differ from the layout viewport. If you position based only on innerWidth/innerHeight, you can end up with tooltips floating in the wrong universe.

If you want to be serious: use window.visualViewport when available (and fall back when it isn’t). Account for visualViewport.offsetLeft/offsetTop when computing coordinates.

Scroll containers: the classic bug

getBoundingClientRect() returns coordinates relative to the viewport, which is good. But if you render the tooltip inside a scrolled container (inline architecture), you’ll need to translate from viewport coordinates into the container’s coordinate space. This is where many “it works on my machine” stories begin.

A clean, dependency-free positioning function (pseudo-code)

Not a full library. Just enough structure so you don’t regret your life choices:

  • Inputs: target rect, tooltip size, placement preference, viewport size, padding, offset.
  • Output: x, y, placement used, arrow offset.

Implement it as a pure function. Then unit test it with rectangles. You can’t integration-test your way out of geometry.

Dealing with transforms and fixed positioning

If you portal to body, position: fixed on the tooltip is often easiest: your x/y are viewport coordinates. If you use position: absolute, you’ll need to add scroll offsets (window.scrollX, window.scrollY) and handle document scrolling separately. Fixed wins for sanity.

Arrow placement depends on the final shifted position

The arrow is not decoration; it’s a directional cue. If you shift the tooltip left to avoid clipping, the arrow needs to move too. Otherwise it points at empty space, which is subtle but real UX damage.

Arrows: triangles, rotated squares, SVG, and “don’t lie to the user”

Arrows seem easy until you ship them. Here are the common implementations:

Option 1: CSS border triangle

The old classic: a zero-size element with borders, where one border has color and the others are transparent.

Pros: works everywhere, light on DOM.

Cons: hard to add shadows cleanly; anti-aliasing can look jagged; scaling and theming are annoying.

Option 2: Rotated square (“diamond”) using transform: rotate(45deg)

This is my default: a small square, rotated, positioned so half overlaps the tooltip box.

Pros: shadows work; rounded corners possible; easier theming.

Cons: needs careful overlap to avoid seams; transforms can create stacking contexts (yes, again).

Option 3: Inline SVG

Pros: crisp, easy to shadow/filter, precise alignment, can match design systems.

Cons: slightly more markup; if you’re sloppy, you’ll end up with pointer-events issues.

How to keep the arrow honest

Compute an arrow offset along the tooltip’s cross-axis:

  • If tooltip is placed above/below target: arrow moves left/right within the tooltip.
  • If placed left/right: arrow moves up/down.

Then clamp that offset too, so the arrow doesn’t end up on the tooltip’s rounded corner. Rounded corners + arrow on the radius = visual glitch.

ARIA-friendly patterns: keyboard, focus, and screen readers

Accessibility is not a moral lecture; it’s an outage-prevention strategy. The moment someone can’t understand an error icon because the tooltip doesn’t announce, you get tickets, escalations, and a “quick fix” that breaks something else.

Use the right role and relationship

For a true tooltip (non-interactive content), use:

  • role="tooltip" on the tooltip element.
  • aria-describedby="tooltip-id" on the trigger element.

This tells assistive tech: “this thing describes that thing.” It’s simple. Don’t get clever.

When to use aria-label vs aria-describedby

  • Use aria-label when the trigger has no visible label (e.g., icon-only button). That label should be short and stable.
  • Use aria-describedby for supplemental text, error explanations, keyboard hints, or clarifications that you don’t want as the primary name.

Don’t use tooltips to patch missing labels. Screen readers shouldn’t have to “discover” the name of a control.

Show on focus, hide on blur (with a delay)

Basic rules:

  • On focus: open tooltip.
  • On blur: close tooltip.
  • On Escape: close tooltip (even if it’s “just a tooltip”).

Add a small close delay (like 100–200ms) to avoid flicker when the user’s pointer transitions between trigger and tooltip. And yes, even for non-interactive tooltips, users do this. Humans are chaos engineers.

Don’t trap screen readers in a hover-only world

Hover is not a universal interaction. Many users don’t use a mouse. Many devices don’t have hover. Provide focus triggers and ensure the tooltip content is announced via the described-by relationship.

Keep tooltip content short and boring

Tooltips should be one or two sentences. If you need a paragraph, you’re writing documentation. If you need a table, you’re building a popover. If you need a form, you’re building a dialog. Words have meaning; your UI should too.

Paraphrased idea (attributed): “Hope is not a strategy,” often credited in ops circles to reliability-minded leaders like Gene Kranz. Treat accessibility the same way: plan it.

Event model: hover, focus, touch, and dismissal

Tooltips fail in the seams between input methods. Your job is to make those seams boring.

Pointer events: use them, but don’t trust them blindly

pointerenter/pointerleave can replace separate mouse and touch logic, but you still need to decide what to do on coarse pointers (touch). A tooltip that opens on tap can be okay if:

  • Tap opens tooltip.
  • Second tap (or outside tap) closes it.
  • Tooltip doesn’t block the next intended action.

Dismissal rules that don’t annoy people

  • Mouse: close on pointerleave with a small delay; keep open if pointer moves into tooltip (optional).
  • Keyboard: close on blur or Escape.
  • Touch: close on outside tap; consider close on scroll.

Jitter and flicker: the “I moved my mouse one pixel” problem

The flicker loop often happens because the tooltip overlaps the trigger. When it appears, the trigger is no longer hovered, so it disappears, so the trigger is hovered again, etc.

Fixes:

  • Offset the tooltip so it doesn’t overlap.
  • Set pointer-events: none on the tooltip if it doesn’t need hover persistence.
  • Or implement a hoverable tooltip with an “interactive boundary” and timers.

Performance and reliability: jitter, reflow, and scroll handling

Tooltips are small, but they can trigger expensive work at exactly the wrong time: scroll, resize, and pointer move. In other words: continuously.

Don’t reposition on every mousemove

Position on open, then reposition on:

  • scroll (throttled)
  • resize (throttled)
  • target resize (ResizeObserver)
  • tooltip content change (ResizeObserver, or re-measure after render)

If you reposition on mousemove, you’ll create micro-jank on lower-end machines and on pages with heavy layout.

Throttle with requestAnimationFrame

For scroll/resize, collect events and do one reposition per animation frame. That keeps you aligned with the browser’s render loop and avoids redundant layout calculations.

Minimize forced reflow

getBoundingClientRect() can force layout if you read after writing layout-affecting styles. Batch reads first, then writes. One of the easiest ways: compute all geometry, then set style.transform = translate3d(x,y,0) in one go.

Prefer transforms over top/left

Use transform: translate3d() for positioning, especially when the tooltip animates. It tends to be smoother and avoids layout. “Tends” because the web is a democracy of edge cases.

Joke #2: If you measure layout in a tight loop, Chrome will happily show you what “eventually consistent” feels like—by eventually rendering your tooltip somewhere else.

Fast diagnosis playbook

When a tooltip is “broken,” engineers often argue about CSS for an hour. Don’t. Run the playbook. Find the bottleneck fast.

First: identify the failure class

  • Invisible: not rendering, opacity 0, display none, or behind another layer.
  • Mispositioned: wrong coordinates, wrong coordinate space, wrong scroll offsets.
  • Clipped: overflow hidden/auto, or container clips due to stacking context/containment.
  • Flickering: event loop (hover) or reposition thrash (scroll/resize).
  • Unannounced: screen reader doesn’t read it (ARIA wiring or focus behavior).

Second: check coordinate space assumptions

  1. Are you using a portal? If yes, prefer position: fixed and viewport coords.
  2. If not portaled: what is the offset parent? Is there a transformed ancestor?
  3. Are you mixing pageX/pageY with viewport-based rects?

Third: confirm stacking and clipping

  1. Is the tooltip inside an element with overflow: hidden or overflow: auto?
  2. Is any ancestor establishing a stacking context (transform/opacity/filter/contain)?
  3. Is your overlay layer’s z-index actually above the header/modal?

Fourth: confirm a11y wiring

  1. Does the trigger have aria-describedby pointing to an existing ID?
  2. Does the tooltip have role="tooltip"?
  3. Does the tooltip open on focus and close on blur/Escape?

Common mistakes: symptoms → root cause → fix

1) Tooltip appears at (0,0) or top-left of the page

  • Symptom: tooltip pinned to corner, regardless of trigger.
  • Root cause: measurement happening before the element is in the DOM, or getBoundingClientRect() called on a null/hidden target; sometimes a stale ref.
  • Fix: render tooltip hidden (visibility: hidden) first, then measure in the next frame; guard against missing refs; log rects.

2) Tooltip is correct until you scroll a container

  • Symptom: tooltip drifts away from the target during scroll.
  • Root cause: you’re listening only to window scroll, not the scrollable ancestor; or using absolute positioning with wrong scroll offsets.
  • Fix: portal + fixed positioning; or detect and listen to the nearest scroll container; reposition on scroll events via rAF.

3) Tooltip is cut off inside a card/modal

  • Symptom: tooltip visibly clipped at the container boundary.
  • Root cause: tooltip lives inside an element with overflow: hidden/auto.
  • Fix: portal to a top-level overlay layer; or relax overflow (rarely acceptable); or use position: fixed with proper stacking.

4) Tooltip appears behind the header

  • Symptom: tooltip exists but is not visible over sticky elements.
  • Root cause: overlay layer under a higher z-index stacking context; or tooltip itself is in a lower stacking context due to an ancestor.
  • Fix: central overlay root with known high z-index; avoid transforms on overlay ancestors; audit stacking contexts.

5) Flicker on hover

  • Symptom: tooltip rapidly shows/hides when moving pointer near trigger.
  • Root cause: tooltip overlaps target and steals hover; immediate close on leave without delay.
  • Fix: add offset; apply pointer-events: none for non-interactive tooltip; add close delay; consider “hover bridge” (invisible padding) if needed.

6) Screen reader doesn’t announce tooltip

  • Symptom: tooltip visible visually, but not read when focusing trigger.
  • Root cause: missing aria-describedby; tooltip ID mismatch; tooltip content not present in DOM until after focus event and SR doesn’t re-announce.
  • Fix: keep tooltip element in DOM (hidden) so described-by points to real content; verify ID wiring; open on focus consistently.

7) Tooltip causes scroll jank

  • Symptom: scrolling becomes choppy when tooltip open.
  • Root cause: reposition on every scroll event with forced layout; heavy box-shadow or filter; too many observers.
  • Fix: throttle via rAF; reduce expensive CSS; avoid filter effects; reposition only when open.

Practical tasks (with commands): debug like an SRE

You can debug UI in the browser, sure. But when the bug is “only in prod” and “only for some users,” you need system-level observability too: which build, which headers, which CSP, which assets, which errors. These tasks are the kind of boring muscle memory that prevents heroic guessing.

Task 1: Confirm which HTML shipped (and if the tooltip markup exists)

cr0x@server:~$ curl -sS -D- https://app.example.internal/settings | sed -n '1,40p'
HTTP/2 200
date: Mon, 29 Dec 2025 10:11:12 GMT
content-type: text/html; charset=utf-8
content-security-policy: default-src 'self'; script-src 'self'
etag: W/"a1b2c3"
...

What the output means: You’re verifying status, headers (especially CSP), and whether you’re getting HTML at all. CSP matters because tooltip patterns often rely on inline styles or scripts.

Decision: If CSP blocks inline styles/scripts and your tooltip uses them, you either refactor to external assets or update CSP safely.

Task 2: Verify the tooltip JS bundle is present and cacheable

cr0x@server:~$ curl -sS -I https://app.example.internal/assets/tooltip.js
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
etag: "9f8e7d6c"

What the output means: The bundle exists and is cache-friendly. If you see 404 or short cache times, you’ve found a deployment or cache invalidation problem.

Decision: If caching is wrong, fix asset fingerprinting or CDN config before touching tooltip code.

Task 3: Check whether errors spike when tooltips should render

cr0x@server:~$ kubectl -n web logs deploy/frontend --since=15m | grep -E "TypeError|ReferenceError|CSP|tooltip" | tail -n 20
TypeError: Cannot read properties of null (reading 'getBoundingClientRect')
CSP: Refused to apply inline style because it violates the following Content Security Policy directive...

What the output means: A null reference suggests the trigger element doesn’t exist when measured (race), or the selector is wrong in prod. CSP error tells you styles are blocked.

Decision: If you see CSP violations, stop. Fix policy or implementation. If you see null rect, add guards and defer measurement.

Task 4: Confirm the rollout version (because “only prod” is often “only that canary”)

cr0x@server:~$ kubectl -n web describe deploy/frontend | sed -n '1,120p'
Name:                   frontend
Namespace:              web
Labels:                 app=frontend
Annotations:            deployment.kubernetes.io/revision: 42
Containers:
  frontend:
    Image:              registry.internal/frontend:sha-8c1d2a7
    Ports:              8080/TCP

What the output means: You’re verifying the running image and revision. Tooltip bugs frequently correlate with one release.

Decision: If the bug started with a revision, bisect by rollback or compare changes around tooltip code and CSS layers.

Task 5: Check if a reverse proxy is stripping needed headers

cr0x@server:~$ curl -sS -I https://app.example.internal/ | grep -i -E "content-security-policy|x-frame-options|referrer-policy"
content-security-policy: default-src 'self'; script-src 'self'
referrer-policy: strict-origin-when-cross-origin

What the output means: Security headers can affect tooltip strategies (e.g., if you were relying on inline styles). Frame policies matter if your app is embedded.

Decision: If headers differ across environments, align them; otherwise you’ll keep shipping “works in staging” tooltips.

Task 6: Find which client-side route/version users are hitting via access logs

cr0x@server:~$ sudo tail -n 200 /var/log/nginx/access.log | grep -E "GET /assets/|GET /settings" | tail -n 20
10.1.2.3 - - "GET /settings HTTP/2.0" 200 42103 "-" "Mozilla/5.0 ..."
10.1.2.3 - - "GET /assets/tooltip.js HTTP/2.0" 200 18342 "-" "Mozilla/5.0 ..."

What the output means: You can see whether the tooltip asset is fetched and if it’s 200 or 304. Missing fetch often means the HTML didn’t reference it or a client blocked it.

Decision: If you don’t see asset requests, confirm bundling and HTML inclusion. If you see 403/404, fix routing/CDN.

Task 7: Verify gzip/brotli isn’t corrupting the JS payload (rare, but real)

cr0x@server:~$ curl -sS -H 'Accept-Encoding: gzip' --compressed https://app.example.internal/assets/tooltip.js | head -n 5
(function(){'use strict';
var Tooltip=function(){...}

What the output means: You’re confirming the compressed response decompresses into valid JS text.

Decision: If you get binary garbage or truncated content, inspect proxy compression configuration.

Task 8: Measure client errors in real time via server logs (CSP reports)

cr0x@server:~$ kubectl -n web logs deploy/csp-report-collector --since=30m | tail -n 20
{"blocked-uri":"inline","violated-directive":"style-src-elem","document-uri":"https://app.example.internal/settings"}

What the output means: Users are triggering CSP violations that might prevent tooltip styling or visibility.

Decision: Remove inline styles/scripts from tooltip implementation or update CSP with nonces/hashes (carefully).

Task 9: Confirm the overlay root exists in the DOM template (SSR/templating failure mode)

cr0x@server:~$ curl -sS https://app.example.internal/ | grep -n 'id="overlays"' | head -n 5
118:    

What the output means: If your portal expects an overlay root and it’s missing, tooltips will silently fail or attach to body incorrectly.

Decision: If missing, fix the base template and add a runtime fallback that creates the node.

Task 10: Detect whether a CSS change introduced overflow: hidden on a key ancestor

cr0x@server:~$ git show HEAD~1:ui/styles/components/card.css | sed -n '1,120p'
.card {
  position: relative;
  overflow: hidden;
  border-radius: 12px;
}

What the output means: A recent change added overflow clipping. This is a top-three tooltip killer.

Decision: Either portal the tooltip out of the card, or remove overflow hidden if it’s not required (it usually is, for rounded corners).

Task 11: Reproduce the clipping locally with a deterministic environment

cr0x@server:~$ docker run --rm -p 8080:8080 registry.internal/frontend:sha-8c1d2a7
Listening on http://0.0.0.0:8080

What the output means: You’re running the same image as prod. No “works on my laptop” excuses.

Decision: If it reproduces, you can debug in-browser with confidence. If not, the difference is config, headers, or upstream layers.

Task 12: Check whether user agents differ (touch vs hover behavior)

cr0x@server:~$ sudo awk -F\" '{print $6}' /var/log/nginx/access.log | tail -n 200 | sort | uniq -c | sort -nr | head
  83 Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 ...
  52 Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...

What the output means: A spike in iPhone traffic can surface tooltip failures caused by hover assumptions.

Decision: If touch devices dominate the bug reports, ensure you have a tap-friendly behavior and don’t rely on hover.

Task 13: Identify whether the tooltip code is causing CPU spikes (server-side correlation)

cr0x@server:~$ kubectl -n web top pods | head
NAME                           CPU(cores)   MEMORY(bytes)
frontend-6c7b9c9f8d-2kq9m      120m         310Mi
frontend-6c7b9c9f8d-l8z2p      115m         305Mi

What the output means: This doesn’t measure client CPU, but it can reveal correlated spikes (e.g., SSR rendering or excessive HTML generation).

Decision: If server CPU jumped with tooltip rollout, investigate SSR loops, logging, or template changes tied to tooltip rendering.

Task 14: Confirm that source maps aren’t missing in production (debuggability task)

cr0x@server:~$ ls -lh /srv/app/assets | grep -E 'tooltip\.js(\.map)?' 
-rw-r--r-- 1 www-data www-data  18K Dec 29 09:58 tooltip.js
-rw-r--r-- 1 www-data www-data  61K Dec 29 09:58 tooltip.js.map

What the output means: You have sourcemaps available (even if gated). Debugging tooltip geometry without sourcemaps is self-harm.

Decision: If maps are missing, decide whether to ship them securely (restricted) or improve server-side logging around tooltip placement decisions.

Three corporate mini-stories from the tooltip mines

Mini-story 1: The incident caused by a wrong assumption

A team built a clean tooltip component for a billing dashboard. It worked in dev, in staging, and in a demo where everyone politely used a mouse like it was 2008. The assumption: “tooltips are hover UI.” They shipped it.

Within a day, support tickets came in: users couldn’t figure out why their invoices were failing. The error icon had the only explanation, and the explanation lived exclusively in the tooltip. Many users navigated by keyboard due to RSI, and some used high zoom where hover precision gets harder.

The first “fix” was to open the tooltip on click. That made it accessible-ish, but it also blocked the error icon and adjacent links, creating a new class of bug: clicks intended for the icon just toggled the tooltip, while screen readers still got inconsistent announcements because the tooltip DOM was created only after interaction.

The eventual repair was boring and effective: proper labels for errors inline, tooltips downgraded to short clarifications, and aria-describedby wired to an always-present tooltip element that opens on focus. The incident wasn’t about geometry. It was about semantics. The postmortem headline could have been: “We used a tooltip as a label.”

Mini-story 2: The optimization that backfired

Another org decided tooltips were “expensive” because opening one caused a layout measurement. Someone profiled a slow machine and concluded: “We should precompute positions for all tooltips on page load.” It sounded efficient. It was also wrong in a very web-shaped way.

They built a prepass: iterate through every tooltip trigger, measure rects, and store coordinates. Then, on hover, render the tooltip at the stored x/y. The page had hundreds of rows, each with several icons. Page load got heavier, but still acceptable in the lab.

In production, users filtered tables, resized columns, and opened side panels. The stored coordinates became stale. Tooltips drifted, flipped incorrectly, and occasionally appeared offscreen. Worse: the prepass itself caused forced reflows, making the page feel sluggish before users even interacted.

The fix was to do the opposite: compute positions only when a tooltip is open, and recompute only on relevant changes (scroll/resize/observer events) throttled to animation frames. “Optimization” was replaced with “doing less, later, correctly.”

Mini-story 3: The boring but correct practice that saved the day

A security-conscious company introduced a stricter Content Security Policy. It was a good move. It also broke a surprising number of UI elements that had been quietly relying on inline styles.

The tooltip implementation was one of them. It used a quick trick: set style="top: ...px; left: ...px" at runtime. CSP had style-src 'self' without allowances for inline styles, so tooltips rendered at their default CSS position—right in the top-left corner of the overlay layer.

Most teams scrambled and requested CSP exceptions. One team didn’t. They had a pre-deployment checklist item: “Run CSP report-only in staging and review violations.” They’d built a small collector endpoint and actually looked at it, because they were tired of surprise Fridays.

They migrated tooltip positioning to CSS variables updated via a stylesheet rule rather than inline style attributes, and they ensured the required CSS was in a versioned asset. When the stricter policy went live, their tooltips kept working. The hero was a checklist and a report queue. Glamorous? No. Effective? Extremely.

Checklists / step-by-step plan

Step-by-step: build a tooltip that survives reality

  1. Decide semantics: tooltip vs popover. If interactive, it’s a popover—don’t fake it.
  2. Choose architecture: portal to a top overlay root by default.
  3. Create stable wiring: generate a tooltip ID and set aria-describedby on the trigger.
  4. Keep tooltip DOM present: hide via visibility and opacity; don’t create/destroy on every hover if you need consistent SR behavior.
  5. Position with fixed + transform: compute viewport x/y; apply with translate3d.
  6. Implement flip + shift: don’t let tooltips go offscreen. Clamp with padding.
  7. Arrow follows final placement: compute arrow offset after shift; clamp away from rounded corners.
  8. Events: open on hover and focus; close on leave/blur/Escape; close on outside pointerdown for touch.
  9. Throttle updates: rAF for scroll/resize; observers only when open.
  10. Testing: add deterministic selectors and unit-test geometry function with fixture rectangles.
  11. Hardening: handle missing overlay root; guard null refs; treat CSP as a design input.

Checklist: production readiness gates

  • Works with keyboard-only navigation (focus triggers, Escape closes).
  • Announced by screen readers (role + described-by + stable DOM).
  • Survives nested scroll containers (reposition on scroll).
  • Not clipped by overflow (portal).
  • No flicker loops (offset + pointer-events strategy + delay).
  • Doesn’t cause noticeable scroll jank (throttled measurement, no mousemove reposition).
  • Has a defined z-index layer strategy (overlay root above headers/modals).
  • CSP-compatible (no inline style if policy forbids it).

FAQ

1) Can I just use the HTML title attribute?
You can, but you’ll lose styling control, get inconsistent timing across browsers, and often get weak accessibility. Use it only as a last-ditch fallback.
2) Should the tooltip content be in the DOM when hidden?
Usually yes. It makes aria-describedby stable and improves screen reader consistency. Hide with visibility: hidden and opacity: 0, not display: none, if you need it to be referenced.
3) What ARIA role should I use?
role="tooltip" on the tooltip element. Then attach it to the trigger using aria-describedby. Keep the content non-interactive.
4) Should tooltips open on click?
On touch devices, click/tap can be a reasonable trigger. On desktop, prefer hover + focus. If you open on click, you must define dismissal and ensure it doesn’t block the next action.
5) Why does my tooltip z-index not work?
Because z-index only competes inside the same stacking context. Transforms, opacity, and certain CSS properties create new stacking contexts. Portal to a known overlay root and manage z-index centrally.
6) How do I prevent clipping in overflow containers?
Portal the tooltip out of the container (typically to document.body or an overlay root) and position with viewport coordinates using position: fixed.
7) How often should I recompute tooltip position?
On open, and then when something relevant changes: scroll, resize, target size changes, or tooltip content changes. Throttle to one update per animation frame.
8) Can I animate tooltips safely?
Yes: animate opacity and transform (small translate). Avoid animating top/left. Keep animations short and respect reduced-motion preferences if your product has that requirement.
9) Should a tooltip be hoverable?
If it’s non-interactive, it doesn’t need to be hoverable. Use pointer-events: none and avoid flicker loops. If you need persistence for reading longer text, add a close delay and allow pointer to enter the tooltip.
10) What’s the simplest robust placement strategy?
Preferred side + flip if it doesn’t fit + shift to keep in viewport + clamp with padding. Do not skip shift/clamp unless your UI never scrolls and never resizes—which is a fairy tale.

Conclusion: next steps that won’t betray you later

If you want tooltips without libraries, the trick is not writing clever code. It’s writing code that assumes your layout will be hostile, your users will be diverse, and your CSS will be “improved” by someone who doesn’t know your component exists.

Next steps that pay off quickly:

  • Pick portal + fixed positioning as your default architecture.
  • Implement the geometry as a pure function and unit test it with rectangles.
  • Wire aria-describedby + role="tooltip" and open on focus, not just hover.
  • Add rAF-throttled scroll/resize repositioning and stop measuring on mousemove.
  • Run the diagnosis playbook on a page with modals, sticky headers, and nested scroll panes—because that’s where the truth lives.

Operational mindset: Treat tooltips like any other production feature. They have dependencies (CSP, z-index layers, scroll containers) and failure modes. Make them observable and boring.

← Previous
Docker Daemon Won’t Start: Read This Log First (Then Fix It)
Next →
Ubuntu 24.04: Cron Runs Manually but Not on Schedule — PATH/Env Gotchas and Fixes

Leave a comment