Tooltips
Summary
Soma ships two tooltips on purpose. The
CSS-only one is an attribute and no JavaScript:
perfect for short static hints. The JS one adds
placement, show/hide delays, multi-line text, viewport clamping
and aria-describedby. They use different attributes,
so an element can never sprout both bubbles.
When to use
| Variant | Use it for |
|---|---|
CSS-onlydata-soma-tooltip="text" | Short single-line hints on icon buttons and controls. Zero JS. Always above, no wrapping. Not exposed to screen readers, so keep a real label/aria-label on the element. |
JSdata-soma-tip / Soma.tooltip() | Longer text, a specific side, delays, or content computed at hover time. Announced to screen readers via aria-describedby. |
| Inline dialog | Anything interactive: links, forms. Tooltips are not focusable (the bubble is pointer-events: none). |
Examples
Show / hide behaviour
| Interaction | Effect |
|---|---|
| Pointer enters / leaves | Show after delayIn (default 500 ms) / hide after delayOut (default 0). A leave during the in-delay cancels the pending show. |
Keyboard focus (:focus-visible) | Show immediately — Tab users don't wait out a pointer-rest delay. Mouse-click focus keeps the delay. |
| Focus leaves | Hide (after delayOut). |
| Click on the trigger | Hide — activating a control suppresses its hint. |
| Esc | Hide. |
While visible, the bubble (role="tooltip", a
generated id) is referenced from the trigger via
aria-describedby, so screen readers read the hint
after the element's name; the reference is removed on hide. The
bubble is appended to <body> and clamped to
the viewport (4px margins), so it never causes scrollbars.
The bubble is a native popover="manual" in the
browser's top layer, so hints stay visible
above open modal dialogs and other overlays.
Manual, deliberately: show/hide timing stays
with Soma's own hover/focus delay logic, and an
auto popover's one-at-a-time rule would close an
open menu the moment a hint appeared. Without the Popover API
the fixed-position + z-index CSS applies unchanged.
Placement
top / bottom center the bubble above
or below; left / right are
logical (inline-start / inline-end), so they swap
physical sides under RTL (the arrow follows).
auto resolves at show time: below, unless the
element sits in the bottom third of the viewport, then above.
Where the engine supports CSS anchor positioning the bubble is
placed by position-area anchored to the trigger
(the JS measure-and-clamp stands down); elsewhere the JS math
applies unchanged.
HTML
CSS-only. The text is the attribute value; shows on hover and
:focus-visible:
<button class="soma-button" data-soma-tooltip="Saves immediately">Save</button>
JS via auto-init. The text comes from title, the
attribute value is the placement (empty value = the default,
bottom). Every [data-soma-tip] element is bound at
load:
<button class="soma-button" title="Saves immediately" data-soma-tip="top">Save</button>
<!-- auto placement: top or bottom, resolved at show time -->
<button class="soma-button" title="Whichever fits" data-soma-tip="auto">Auto</button>
<!-- empty value: default placement (bottom) -->
<button class="soma-button" title="Below" data-soma-tip>Default</button>
On bind, the native title attribute is moved to
data-soma-original-title so the browser's own
tooltip doesn't double up. The text stays recoverable and
.destroy() moves it back.
CSS classes
| Class / attribute | Effect |
|---|---|
data-soma-tooltip="text" | CSS-only tooltip above the element on hover/:focus-visible. |
data-soma-tip="placement" | Auto-init hook for the JS tooltip; empty value means the default (bottom). |
data-soma-original-title | Where the JS tooltip parks the native title while bound. |
.soma-tooltip-bubble | The generated JS bubble (role="tooltip", max-width 260px, pointer-events: none), with -top/-bottom/-left/-right arrow variants. |
JavaScript
Constructor + options
| Member | Description |
|---|---|
Soma.tooltip(target, opts?) | Bind and return the singleton per element. target may be an element, an array of elements, or a selector: a call resolving to one element returns the instance, several return an array of instances. Options are read only on first bind per element. |
opts.placement | 'top' | 'bottom' | 'left' | 'right' | 'auto'. Default 'bottom'. Left/right are logical (swap sides in RTL); auto resolves per show. |
opts.title | The text: a string, or fn(el) => string evaluated on every show. Default: the element's original title attribute. Empty text = the tooltip simply doesn't show. |
opts.delayIn | ms before showing on pointer enter. Default 500. Keyboard focus ignores it. |
opts.delayOut | ms before hiding on pointer leave / focusout. Default 0. |
opts.html | true renders the title as HTML instead of text. Trusted content only. Default false. |
Instance methods
| Member | Description |
|---|---|
.show() | Show immediately: build the bubble, resolve placement, position + clamp, set aria-describedby. No-op when the resolved text is empty. |
.hide() | Cancel any pending timer, remove the bubble, drop aria-describedby. |
.destroy() | Hide, unbind all listeners, restore the original title attribute, drop the singleton. The element behaves as if never enhanced; a later Soma.tooltip(el) re-binds it. |
show() and hide() return the instance,
so calls chain. The tooltip dispatches no CustomEvents — it is a
visual/AT supplement, not a state carrier.
Bind many elements at once with a selector (one call, shared options):
// Every toolbar icon button gets a bottom tooltip from its title attribute
Soma.tooltip('.soma-toolbar [title]', { placement: 'bottom', delayIn: 300 });
Show programmatically, for example to surface a hint when a state changes, then hide it a moment later:
const hint = Soma.tooltip('#status-badge', {
title: 'Latency above threshold for 5 minutes',
placement: 'right',
});
hint.show();
setTimeout(() => hint.hide(), 3000);
Tear down before removing or re-rendering the element;
destroy() restores the native title:
const tip = Soma.tooltip('#save');
tip.destroy(); // listeners off, title attribute back
Soma.tooltip('#save', { placement: 'top' }); // fresh bind, new options
Bind elements rendered after page load (auto-init runs once, at load). The call is a get-or-create singleton:
container.insertAdjacentHTML('beforeend', renderedRow);
Soma.tooltip(container.querySelectorAll('[data-soma-tip]'));
The live examples above, exactly as this page wires them:
// Placements and auto come from markup (data-soma-tip) — no wiring needed.
// Delays
Soma.tooltip('#tip-instant', { title: 'Shown instantly', delayIn: 0 });
Soma.tooltip('#tip-linger', {
title: 'Stays for 800 ms after the pointer leaves',
delayOut: 800,
});
// Function title — evaluated on every show
Soma.tooltip('#tip-fn', {
title: () => `Computed at ${new Date().toLocaleTimeString()}`,
});
// html: true — trusted content only
Soma.tooltip('#tip-html', {
title: 'Press <kbd>g</kbd> then <kbd>d</kbd> for the dashboard',
html: true,
});
// Imperative control — show/hide/destroy (show re-binds after destroy)
const opts = { title: 'p95 latency 840 ms — threshold 500 ms', placement: 'right' };
Soma.tooltip('#tip-target', opts);
document.getElementById('tip-show')
.addEventListener('click', () => Soma.tooltip('#tip-target', opts).show());
document.getElementById('tip-hide')
.addEventListener('click', () => Soma.tooltip('#tip-target', opts).hide());
document.getElementById('tip-destroy')
.addEventListener('click', () => Soma.tooltip('#tip-target', opts).destroy());