Plate № 11 · UX primitives · interactivity
A pattern from the gf.cx specimen book
A tooltip you can reach into
"Lets A/B test — for selector-area tooltips that have an embedded link that lights up red. This involved smart demarcation of the zone, to allow the user to 'click it' without it disappearing."
The problem
The CSS-only data-tip tooltip is text-only. content: attr(data-tip) renders as a plain string — no HTML, no links, no interactive elements inside the card. The pseudo-element also carries pointer-events: none, so even if you could inject a link, the cursor would fall straight through it. For glossary terms that have canonical reference pages (FCP → web.dev/articles/fcp), a "Learn more ↗" link inside the tooltip is genuinely useful — but CSS can't deliver it.
| Piece | What it does |
|---|---|
data-tip-href="https://…" | Signals that this element needs the JS card — the CSS card is suppressed for these elements via an explicit override rule in tooltip.css |
tooltip-interactive.js | Self-contained IIFE. Queries all [data-tip][data-tip-href] elements, injects a single shared DOM card (.tip-interactive) into document.body, positions it above each trigger on mouseenter |
| 120ms hide delay | The guard that makes the pattern work. On mouseleave a timer schedules hide rather than hiding immediately — the cursor can travel from the trigger across the gap into the card. mouseenter on the card cancels the timer |
| Red "Learn more ↗" link | .tip-interactive__link — color: #c8364c, bold, opens in new tab with rel="noopener" |
The critical trap is the pixel gap between the trigger element and the card (the caret arrow space). If you hide the card on mouseleave of the trigger immediately, the cursor momentarily has no target and the card vanishes before the user reaches the link. The 120ms delay is the fix: small enough to feel instant, large enough for a deliberate move from trigger to card.
The element, in-page
Hover the dotted term below. When the card appears, slide your cursor across the gap to click the red "Learn more" link — the 120ms delay keeps the card open while you travel. The demo renders inline (not in an iframe) so pattern-preview's inline mode can share the tooltip-interactive.js already loaded on this page.
The browser first paints meaningful content at FCP.
The CSS suppression rule (already baked into tooltip.css v0.2.0) prevents the plain CSS card from doubling up with the JS card:
abbr[data-tip][data-tip-href]:hover::after,
abbr[data-tip][data-tip-href]:hover::before { display: none !important; }
Where it applies
- Glossary terms in technical reports that have a canonical reference URL — live in dash.gf.cx/reports/ where terms like FCP, LCP, and CLS carry "Learn more ↗" links to web.dev
- Any tooltip where a secondary action (read more, open docs, copy value) adds genuine utility — the JS card is a general container, not FCP-specific
- Per-datapoint drill-downs on a chart — the storage sparklines on status.gf.cx/r2/ upgrade a significant bar's hover into a "Learn more ↗" that opens that bucket's file-type manifest (see the Update below)
- Not needed when the tooltip is self-contained — the pure-CSS
data-tippattern is lighter and sufficient for most cases
Update · gate the upgrade on significance
2026-08-20 — a second live adoption, on the status.gf.cx/r2/ bucket pages, stretched the contract in two ways worth folding back in.
- The
data-tip-hreftarget can be a same-surface drill-down, not only an external glossary URL. Each bucket page's "Learn more ↗" points at that bucket's own /manifest/ — the file-type breakdown that decodes a storage jump into which files changed. The card is a router into your own surface just as readily as an outbound reference. - You don't have to upgrade every trigger of a type — gate on significance. A bucket's sparkline has one hoverable bar per daily snapshot, but only bars whose day-over-day delta is notable — cleared an absolute-bytes floor or a percentage spike, the same test that already earns the tooltip's "→ reason" — receive
data-tip-href. Flat and daily-noise bars keep the plain CSS hover, no link. Because presence of the attribute is the sole activation signal, the gate is a one-line data-driven conditional at render time — no change totooltip-interactive.js. The payoff: the interactive card appears exactly where a drill-down earns its click, and nowhere it would just be noise.
Reads the room — adaptive placement
"I like how our pattern can adapt to the space, above or below based on its position."
The card defaults to sitting above its trigger — out of the way of the text being read. But "above" is only free real estate when there's room above. A term in the page's first heading, or in the lede, has almost nothing between it and the top of the viewport: place a 90-pixel-tall card above a trigger 30 pixels down the page and its top edge lands at a negative coordinate — the card renders off-screen. Placement has to be decided per trigger, at hover time, from the actual geometry.
The whole decision is three lines. ch is the card height, r the trigger's rectangle; the ternary picks the vertical origin and the class toggle tells CSS which way the caret points:
var roomAbove = (r.top - ch - 10) >= 8;
card.classList.toggle('tip-below', !roomAbove);
var top = (roomAbove ? r.top - ch - 10 : r.bottom + 10) + window.scrollY;
The caret is pure CSS — the flip is a class, never inline style. By default the caret hangs off the card's bottom edge pointing down at the trigger; .tip-below moves it to the top edge pointing up, so a flipped card still visibly ties to the word it annotates:
.tip-interactive::after { top: 100%; border-top-color: var(--tip-bg); }
.tip-interactive.tip-below::after { top: auto; bottom: 100%;
border-top-color: transparent; border-bottom-color: var(--tip-bg); }
The same idea already governs the horizontal axis: the card is clamped 16px inside the viewport edges, and --tip-caret-x keeps the caret pointing at the trigger's centre after the body is nudged inward. Vertical flip and horizontal clamp are the two halves of one principle — measure, then place. See it live on any dash.gf.cx report, where a glossary term in the opening line drops its card below, caret up.
Reusable elements
The named, copyable pieces — lift any one without the others:
assets.gf.cx/tooltip/tooltip.css— base tooltip styles including the[data-tip][data-tip-href]suppression rule that prevents the CSS pseudo-element card from doubling with the JS card. One<link>tag, no build step.assets.gf.cx/tooltip/tooltip-interactive.js— self-contained IIFE; injects a single shared.tip-interactiveDOM card intodocument.body, positions it above each trigger onmouseenter, tears it down 120ms aftermouseleave. One<script>tag.data-tip="…"attribute contract — the tooltip text label, placed on the trigger element. Required by both the CSS card and as the fallback accessible label (aria-label). The JS card reads this for the card body copy.data-tip-href="https://…"attribute contract — the URL for the "Learn more ↗" link. Presence of this attribute is the signal that activates the JS card; absence leaves the plain CSS tooltip in place. One attribute, no JS class toggling required.- 120ms hide-delay guard — the
setTimeout/clearTimeoutpattern insidetooltip-interactive.js. Copy this single idiom whenever you need a hoverable popup that the cursor must travel into — menus, preview popovers, any case where there is a gap between trigger and floating element. - the
roomAboveflip — the three-line measure/decide/place idiom intooltip-interactive.jspos()(v1.2.0). Portable to any floating element: read the trigger rect and the floater's height, test whether the preferred side fits, place on the other side if it doesn't. - the
tip-belowstate class — JS decides, CSS draws. One class toggle carries the entire flipped appearance (origin edge + caret direction) so the geometry lives in the stylesheet, not in inline styles the script has to re-compute. - the
--tip-caret-*token pair —--tip-caret-size/--tip-caret-xintooltip.css, the single source of truth for the pointer geometry shared by the pure-CSS and interactive cards, so a flipped or edge-clamped caret can never drift. - measure-then-place, as a discipline — never hard-code a side. Both axes (vertical flip, horizontal clamp) read live geometry at hover and derive placement from it.
Reference
- Source
- Dan, 2026-06-01 · interactive tooltip developed during A/B testing on selector-area glossary terms in devreports.
- In use / example
- dash.gf.cx/reports/ — live, with
[data-tip][data-tip-href]on glossary terms (FCP, LCP, CLS, etc.); hover a dotted term for the red "Learn more ↗" link, and note a term in the first heading flips its card below. Also status.gf.cx/r2/ (2026-08-20) — significance-gated storage sparklines. Also home.gf.cx/vehicles/ford-f250 — the cost-when-new figure's card. - Reusable elements
tooltip.css·tooltip-interactive.js·data-tip/data-tip-hrefattribute contract · 120ms hide-delay guard (listed above).- Origin
- A/B test on selector-area tooltip interactivity, 2026-06-01 — the question was whether a link inside a tooltip is reachable; the 120ms delay is the answer. The adaptive above/below flip (
pos()) was extracted intotooltip-interactive.jsat v1.2.0 (2026-08-13) and folded into this pattern 2026-08-21 (was the separateadaptive-tooltip-placementpattern).