Attributes
Events are delegated from the root, so anything you add to the DOM later picks it up without a re-init.
- data-tip
-
The description, rendered with
textContent, so no HTML. The pill ispointer-events: none. - data-tip-key
-
A keyboard shortcut, set after the text as a
<kbd>chip. Plain text, same asdata-tip. class hah-tip__key, tinted from the pill's own text color with color-mix so it lands on either scheme; --tip-key-bg overrides it - data-tip-pos
-
Which side of the cursor the pill prefers:
left(the default),rightorcenter. The boundary gets the last word. focus, tap and press ignore it and center on the element's box: focus below it, touch above it - data-tip-overflow
-
Show the pill only when the element is actually clipped, tested as
scrollWidth > clientWidth. The words aredata-tipif there is one, otherwise the element's own text. the truncated table cell, and the reason the default selector is '[data-tip],[data-tip-overflow]' - data-tip-theme
-
A name for this one trigger, copied onto the pill when it shows. The library ships no
names of its own. Write the
[data-tip-theme="…"]block yourself. See Theming. - title
-
Not a trigger on its own. Put
[title]in the selector and the first hover moves thattitleintodata-tipand removes it, so the browser's own tooltip never gets its turn. createTooltip({ selector: '[data-tip], [title]' }) - off by default, since it would make a pill of every title on the page
<button data-tip="Deletes the row. There's no undo.">Delete</button>
<a href="/pricing" data-tip="Per seat, billed monthly" data-tip-pos="right">Pricing</a>
<button data-tip="Copy to clipboard" data-tip-key="⌘C" aria-label="Copy">⧉</button>
<td class="truncate" data-tip-overflow>content-security-policy-report-only</td>
Options
createTooltip(options) returns a handle. Nothing visual is an option here, because the look and the motion are both CSS.
- selector
- '[data-tip],[data-tip-overflow]' - the trigger selector. Delegated, so later arrivals work.
- root
- document - the delegation root. Scope an instance to one subtree by passing an element.
- offset
- { x: 10, y: 16 } - gap from the cursor, in px. Sets
--tip-offset-xand--tip-offset-yon the pill. - delay
- 150 - milliseconds a cold hover waits. The cursor carries on being tracked meanwhile, so the pill lands under the pointer rather than where it came in. a handoff between triggers, and a return within delay of leaving, are both instant; focus never waits
- touch
- a long press, except on links - or
'press'for links too,'tap','off'. What a device with no cursor gets. See Touch. - theme
- A name written to
data-tip-themeon every pill this instance shows, for a block you style yourself. A trigger's owndata-tip-themewins over it. - boundary
- the viewport - an element or selector the pill stays inside: a modal, a scroll container, a framed example. The box is re-read every frame, so one that scrolls or resizes still holds.
place()takes the same box as an optionalboundsrect. - injectStyles
- true - set
falseand ship the CSS yourself, from the exportedcssstring or@extramoose/tooltip/tooltip.css. For astyle-srcwithout'unsafe-inline'. - returns
{ hide(), show(el), destroy() }.hide()dismisses whatever is showing and cancels a pending delay or a press in progress;show(el)raises the pill anchored to an element the way a tap would, for a "Copied!" on a phone where nothing else would (a no-op if that element's pill is already up: change itsdata-tipinstead);destroy()removes every listener and the pill itself.
import createTooltip from '@extramoose/tooltip';
const tip = createTooltip({
offset: { x: 12, y: 18 },
delay: 120,
touch: 'press',
});
A script tag takes the same options off the tag itself:
data-selector, data-touch, data-delay,
data-theme and data-boundary. The instance it makes
is Tooltip.tip.
<script src="https://cdn.jsdelivr.net/npm/@extramoose/tooltip@1"
data-touch="press"></script>
@1 floats to the newest 1.x, which is convenient, and it means the
bytes can change under you. If you'd rather audit once and pin, use the
exact version and an integrity hash. This is 1.0.1:
<script
src="https://cdn.jsdelivr.net/npm/@extramoose/tooltip@1.0.1/dist/tooltip.global.js"
integrity="sha384-O31yeI3XmXgNT9WOeIPoBs5uqbcD2o5Fof+C+uC+PFpYaVEn0XmdCJAJ856l+F/i"
crossorigin="anonymous"></script>
The hash for any version is the file through openssl:
curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A.
The bytes on the CDN are the bytes in the npm tarball, which is built and
published from the tagged commit by GitHub Actions with provenance.
Styles inject on the first createTooltip() call, never at import.
Theming
Everything visual is a custom property, read as
var(--name, default). Override it from :root or
from a [data-tip-theme] block. Not from a wrapper around the
trigger: the pill is appended to document.body.
One theme ships, and it inverts with the page on
prefers-color-scheme: a dark pill on a light page, a light
pill on a dark one. Your overrides win in both.
:root{
--tip-bg: #14151a;
--tip-color: #fff;
--tip-radius: 18px;
--tip-shadow: 0 10px 30px rgb(0 0 0 / .22);
}
Properties
Defaults, read out of the library at load.
- loading
- reading the library…
--tip-offset-x, --tip-offset-y and
--tip-margin are read back out of computed style, so those
three stay plain px values.
Theming one trigger
:root moves every tooltip. data-tip-theme moves
one: the library copies the name onto the pill and styles nothing itself.
theme sets the same name for a whole instance. A block that
leaves --tip-transition alone keeps the motion.
[data-tip-theme="danger"]{
--tip-bg: #b3261e;
--tip-color: #fff;
--tip-radius: 8px;
--tip-font-weight: 600;
}
Accessibility
It isn't hover-only. focusin shows it for a trigger matching
:focus-visible. Esc dismisses it.
Focus and tap anchor to the element's box rather than the pointer, since there's no pointer position to use.
nothing focused
A description is not a name
data-tip is wired with aria-describedby: a
description, not an accessible name. An icon-only button
carrying only data-tip is announced as just "button".
<!-- wrong: nothing to announce but "button" -->
<button data-tip="Copy to clipboard">⧉</button>
<!-- right: named by aria-label, described by data-tip -->
<button data-tip="Copy to clipboard" aria-label="Copy">⧉</button>
An aria-describedby the trigger already had is preserved,
appended to on show, restored on hide.
Reduced motion
Under prefers-reduced-motion: reduce the pill has no
transition. It still follows the cursor.
prefers-reduced-motion:
…
@media (prefers-reduced-motion: reduce){
.hah-tip__pill{ transition: none; }
}
Touch
A device with no cursor has nothing to ride, so it gets a long press instead. Detection is capability-based and gets re-checked when the input changes, so a laptop with a touchscreen works either way.
(hover: hover) and (pointer: fine)
…
Hold a finger on a trigger for 400 ms and the pill appears above it, centred, and letting go leaves it up. The click that finger makes on the way up is swallowed: you asked what the button does, not for it to do it. A plain tap is never touched.
createTooltip();
// Hold for 400ms. Let go and it stays up.
// A tap elsewhere, or any scroll, dismisses it.
// Tapping the trigger dismisses it, and that tap goes through.
// Moving more than 10px during the hold is a scroll, so nothing shows.
None of that can be shown with a mouse, so there's no demo of it here. The
context menu stays suppressed for the length of the hold, and the trigger
carries data-tip-press while it's held, which is what the
injected CSS hangs the text-selection and iOS callout rules off.
Links are the exception. A long press on a link is also how iOS previews
it, so by default links keep theirs, and each link gets a hidden
description node wired with aria-describedby instead.
touch: 'press' takes the long press on links too.
touch: 'tap' is the blunter one: the first tap shows the pill
and suppresses the click, the second dismisses it and lets the link or the
button run. It costs you the first click on every trigger, and press costs
you nothing.
Both put the pill above the element, since a hand covers everything below it, and flip it underneath only when there's no room up there. Keyboard focus still sits below.
touch: 'off' shows nothing on touch, and wires every
trigger's text up with aria-describedby instead. Triggers
added after the mode engages don't get one.
Details
Change data-tip or data-tip-key on the trigger
while its pill is up and the pill re-fills and re-places itself. That's the
"Copy" to "Copied" case. One MutationObserver, on the active trigger and
nothing else.
Alt-tab and hidden tabs
A blur on the window, or a visibilitychange to
hidden, dismisses the pill. Leave with the cursor parked on a trigger and
nothing gets stranded behind you.
Disabled triggers
A <button disabled data-tip> raises the pill in Chromium,
which has fired mouse events on disabled form controls since 116. Firefox
and Safari have historically swallowed those events, and I haven't checked
either. Hang data-tip on a wrapping
<span> if you need it in every browser.
Source
One file and no dependencies. The geometry lives in place(), a pure
function of numbers, unit-tested with no DOM. Flip then clamp, with
asymmetric hysteresis at the edges, so a cursor dragged along an edge can't make it ping-pong. The README has the reasoning.