Download

name: tooltips user-invocable: true description: >- How to build great desktop tooltips in an Adom app: extensive, well-written content, a 600ms hover-reveal delay so cursor fly-bys stay quiet, a body-appended position:fixed div at z-index 99999 (never a ::after pseudo that gets clipped), cursor-relative placement that never covers the thing you are hovering, and edge-aware flipping so a tooltip never renders off-screen. Read BEFORE adding a tooltip to any hoverable or icon-only control. Includes the canonical z-index ladder, one-tooltip-at-a-time suppression, the no-all-caps rule, and full working CSS and JS. For touch and phones (where hover does not exist) see the responsive-ui skill. Trigger words: tooltip, tooltips, hover tooltip, hover preview, data-tooltip, tooltip placement, tooltip off-screen, tooltip clipped, tooltip z-index, z-index ladder, tooltip delay, hover delay, icon button label, explain this control, tooltip covers cursor, tooltip not showing, title attribute, ::after tooltip.

Tooltips

A tooltip is how a control explains itself. Done well it is the single highest-leverage usability feature in an Adom app: every icon button, every jargon label, every non-obvious number becomes self-explaining without cluttering the screen. Done wrong it is clipped, off-screen, covering the thing you are trying to read, or shouting in all caps. This skill is the one correct way.

The rules here are paranoid-earned. The wrong patterns (title="", a ::after pseudo, z-index tiered to the owner HUD) are plausible-sounding ideas that keep getting reinvented and keep breaking. Use the body-appended fixed-div renderer below and nothing else.

1. Every interactive element gets a descriptive tooltip

Buttons, icon-only controls, labels with jargon, and any non-obvious widget MUST carry a data-tooltip written for a person who has never seen the app.

A button tooltip says:

  1. What the button does, in one sentence.
  2. What changes in the app after you click it.
  3. For destructive or irreversible actions, what is preserved and what is lost.
  4. For jargon-labeled buttons (EBU R128, MPSSE, zUp), define the jargon in plain English.

A label tooltip says: what the label means, its unit (for example "in millimetres"), and how a user would act on the value (is it a warning, a measurement, a setting).

Never use title="". Browsers render native titles inconsistently (instant on some, a 2 second delay on others), handle multi-line poorly, and ignore your brand styling. Use data-tooltip.

2. Reveal after a 600ms hover delay

Instant tooltips spam the user every time the cursor crosses the UI. A 600ms delay means a deliberate hover gets the explanation and a fly-by fires nothing.

[data-tooltip]:hover::after,        /* if you ever use the simple form */
#global-tooltip.shown {
  /* 600ms reveal, 0.18s ease-in */
  transition: opacity 0.18s ease 0.6s, transform 0.18s ease 0.6s;
}

(The standalone delay token is 600ms across Adom apps. A future app may expose --tooltip-delay, but 600ms is the default and the value the rest of the system assumes.)

3. The renderer: ONE body-appended position:fixed div at z-index 99999

A tooltip is the topmost layer of the entire app. It is a single <div> appended directly to <body>, position: fixed, z-index: 99999 flat. It is never a CSS ::after pseudo on the trigger, and its z-index is never tiered against the owner HUD.

This is the only pattern that survives all three failure modes:

  1. overflow: hidden clipping. A ::after bound to its trigger's box is clipped at a HUD edge regardless of z-index if any ancestor has overflow: hidden. Only a position: fixed element outside the clipping tree escapes.
  2. Stacking contexts. A transform, filter, isolation, or z-index on any ancestor caps the descendant's effective z-index. A ::after on a button inside a transformed bar tops out at the bar's z, not 99999. Only a body-reparented fixed element escapes.
  3. Owner-HUD tiering. Tiering tooltip z to ownerHud.z + 1 breaks the instant one HUD covers another HUD's button. Do not tier. A tooltip is always topmost.
<div id="global-tooltip"></div>   <!-- one element for the whole app, appended at init -->
#global-tooltip {
  position: fixed; z-index: 99999; top: 0; left: 0;
  transform: translate(var(--tip-x, 0), var(--tip-y, 0));
  max-width: min(320px, calc(100vw - 40px));
  pointer-events: none; opacity: 0; display: none;
  /* brand styling */
  background: rgba(22,27,34,.96); color: #e6edf3;
  border: 1px solid rgba(0,184,177,.28); border-radius: 10px;
  padding: 9px 13px; font: 400 13px/1.4 'Satoshi', sans-serif;
  box-shadow: 0 12px 40px rgba(0,0,0,.55); backdrop-filter: blur(16px);
  text-transform: none; letter-spacing: 0;   /* see rule 6 */
}
#global-tooltip.visible { display: block; }
#global-tooltip.shown   { opacity: 1; transition: opacity 0.18s ease 0.6s; }
const tip = document.createElement('div');
tip.id = 'global-tooltip';
document.body.appendChild(tip);

let _cx = 0, _cy = 0;
document.addEventListener('mousemove', e => { _cx = e.clientX; _cy = e.clientY; }, true);

function show(trigger) {
  tip.textContent = trigger.getAttribute('data-tooltip') || '';
  tip.classList.add('visible');
  const { x, y } = pickAnchor(trigger, tip.offsetWidth, tip.offsetHeight, _cx, _cy);
  tip.style.setProperty('--tip-x', x + 'px');
  tip.style.setProperty('--tip-y', y + 'px');
  requestAnimationFrame(() => tip.classList.add('shown'));
}
function hide() { tip.classList.remove('visible', 'shown'); }

document.addEventListener('mouseover', e => {
  const t = e.target.closest('[data-tooltip]');
  t ? show(t) : hide();
});

4. Placement: never cover what you are hovering

Position is a separate concern from z-index. Anchor the tooltip relative to the cursor, offset ~14px into the quadrant that does not cover the cursor or the trigger. Do NOT anchor to the trigger element: inside a dense HUD that lands the tooltip far from the cursor or on top of other controls. The one cardinal sin is a tooltip that covers the thing you are hovering.

function pickAnchor(trigger, tipW, tipH, cx, cy) {
  const O = 14;
  const cands = [
    { x: cx + O,        y: cy + O },          // below-right (default)
    { x: cx - tipW - O, y: cy + O },          // below-left
    { x: cx + O,        y: cy - tipH - O },   // above-right
    { x: cx - tipW - O, y: cy - tipH - O },   // above-left
  ];
  const tr = trigger.getBoundingClientRect();
  const vw = window.innerWidth, vh = window.innerHeight;
  const score = c => {
    let p = 0;
    if (c.x < 8 || c.x + tipW > vw - 8) p += 1000;      // off right/left edge
    if (c.y < 8 || c.y + tipH > vh - 8) p += 1000;      // off top/bottom edge
    const ox = Math.max(0, Math.min(c.x+tipW, tr.right) - Math.max(c.x, tr.left));
    const oy = Math.max(0, Math.min(c.y+tipH, tr.bottom) - Math.max(c.y, tr.top));
    p += ox * oy;                                        // overlap with trigger
    return p;
  };
  const best = cands.map(c => ({ c, s: score(c) })).sort((a,b)=>a.s-b.s)[0].c;
  // final clamp into the viewport (or nearest .panel for chrome-reduced apps)
  best.x = Math.max(8, Math.min(best.x, vw - tipW - 8));
  best.y = Math.max(8, Math.min(best.y, vh - tipH - 8));
  return best;
}

5. Edge-aware: never render off-screen

Two guarantees: a max-width that fits the viewport (rule 3), and the clamp in pickAnchor that keeps the box inside the screen. If you use the simpler ::after form anywhere, measure the trigger on mouseenter and flip:

document.addEventListener('mouseenter', e => {
  const el = e.target; if (!el?.matches?.('[data-tooltip]')) return;
  const r = el.getBoundingClientRect(), vw = innerWidth, vh = innerHeight;
  el.toggleAttribute('data-tooltip-v', vh - r.bottom < 240);       // <240px below -> flip up
  el.toggleAttribute('data-tooltip-align', vw - r.left < 340);     // near right edge -> right-align
}, true);
[data-tooltip][data-tooltip-v]::after     { top: auto; bottom: calc(100% + 8px); }
[data-tooltip][data-tooltip-align]::after { left: auto; right: 0; }

6. One at a time, and never ALL CAPS

  • One tooltip in the DOM at any moment, belonging to the innermost [data-tooltip] under the cursor. Browsers fire :hover on every ancestor, so a button and its parent row both showing is a real bug. Suppress ancestors:
    document.addEventListener('mouseover', e => {
      document.querySelectorAll('[data-tooltip-suppress]').forEach(n => n.removeAttribute('data-tooltip-suppress'));
      let n = e.target.closest?.('[data-tooltip]')?.parentElement;
      while (n && n !== document.body) {
        if (n.hasAttribute?.('data-tooltip')) n.setAttribute('data-tooltip-suppress', '');
        n = n.parentElement;
      }
    });
    
  • Never ALL CAPS. RESET CAMERA reads as shouting. Write sentence case ("Reset the camera to the default isometric view"). If a parent sets text-transform: uppercase, the tooltip inherits it, so reset text-transform: none; letter-spacing: 0 on the tooltip element (already in rule 3). For emphasis use weight (600 to 700), not caps.

7. The z-index ladder

Declare these tokens in :root and reference them everywhere, never a hardcoded number, so a session can never invert the stacking order by picking a number out of thin air.

:root {
  --z-hud:           50;     /* draggable HUDs */
  --z-toolbar:       80;     /* fixed header / footer / banner */
  --z-floating-menu: 200;    /* dropdowns, popovers, pickers, autocomplete */
  --z-toast:         400;    /* non-blocking confirmations */
  --z-modal:         600;    /* full-screen overlays, dialogs */
  --z-tooltip:       99999;  /* always on top */
}

A floating menu beats a toolbar so a dropdown can escape the toolbar's box. A tooltip sits two orders of magnitude above everything so it always wins. Note the separate, non-z-index trap: overflow: hidden on an ancestor clips an absolutely-positioned descendant regardless of z-index. Fix with overflow-x: clip; overflow-y: visible, or reposition the element to position: fixed.

Touch and mobile

Hover does not exist on a phone, so a hover tooltip can never fire there and must not be the only way to learn a control. Under @media (hover: none), (pointer: coarse), replace the hover tooltip with a visible label, a tap that opens an info sheet, or a long-press. See the responsive-ui skill for the full touch treatment.

Acceptance check

  • Every icon-only and jargon control has a data-tooltip written for a newcomer.
  • Tooltips reveal after 600ms, not instantly.
  • Rendered as one body-appended position:fixed div at z-index 99999, not a ::after pseudo.
  • The tooltip never covers the cursor or the trigger, and never renders off-screen.
  • Only one tooltip shows at a time; nested triggers do not stack.
  • No ALL CAPS; text-transform reset on the tooltip element.
  • --z-* tokens declared in :root; no hardcoded z-index literals.

MANDATORY ralph-test before you say tooltips are done (added after repeated failures)

Reading the clamp rules is not enough - the off-screen bug keeps shipping because the AI checks one tooltip on a centered control and calls it done. The bug hides at the EDGES. So, non-negotiable:

  1. Trigger the tooltip with the LONGEST tip text on the MOST extreme controls: leftmost, rightmost, topmost, bottommost (toolbar corners, panel edges, last row of a list).
  2. For each, read the rendered rect and ASSERT it is inside the viewport: const r = tip.getBoundingClientRect(); console.assert(r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight, 'TOOLTIP OFF-SCREEN', r);
  3. Drive it live (pup/eval) when you can; otherwise compute the clamp arithmetic for the extreme case and show the numbers. Screenshot proof of ONE centered hover does NOT count.
  4. Also verify inside iframes: clamp to the IFRAME's viewport (document.documentElement.clientWidth/Height), not the parent page's.

If any assert fires, fix the clamp (both axes, margin ~8px, flip instead of clip, measure only after layout) and re-run. Never report tooltips complete without stating this test ran and passed.

human-ui-patterns (the broader interaction rules this was extracted from), floating-windows (HUDs that tooltips render above), toasts (the sibling transient-feedback element), responsive-ui (tooltips on touch), and adom-ui-design (the design-system hub that indexes all of these).