---
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.

```css
[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.

```html
<div id="global-tooltip"></div>   <!-- one element for the whole app, appended at init -->
```

```css
#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; }
```

```js
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.

```js
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:

```js
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);
```
```css
[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:
  ```js
  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.

```css
: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.

## Related skills

`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).
