Back to the demo

Docs

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 is pointer-events: none.
data-tip-key
A keyboard shortcut, set after the text as a <kbd> chip. Plain text, same as data-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), right or center. 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 are data-tip if 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 that title into data-tip and 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
html

<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>
Live - each one of the three sides
Live - a toolbar that tells you the shortcut
Live - hover both cells. The clipped one shows its full text, the other stays quiet content-security-policy-report-only etag

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-x and --tip-offset-y on 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-theme on every pill this instance shows, for a block you style yourself. A trigger's own data-tip-theme wins 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 optional bounds rect.
injectStyles
true - set false and ship the CSS yourself, from the exported css string or @extramoose/tooltip/tooltip.css. For a style-src without '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 its data-tip instead); destroy() removes every listener and the pill itself.
js

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.

html

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

html

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

css

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

css

[data-tip-theme="danger"]{
  --tip-bg: #b3261e;
  --tip-color: #fff;
  --tip-radius: 8px;
  --tip-font-weight: 600;
}
Live - that block is in this page's stylesheet

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.

Live - press Tab into this row A link
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".

html

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

Your browser reports prefers-reduced-motion:
css

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

js

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.

Live - hover it, then click it, and keep hovering

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.

Live - the first is disabled, the second is wrapped

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.